diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a252bca556..330714e804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,6 @@ jobs: sudo apt-get install -y --no-install-recommends \ pkg-config \ build-essential \ - libssl-dev \ libxcb1-dev \ libxcb-render0-dev \ libxcb-shape0-dev \ @@ -526,12 +525,22 @@ jobs: - name: Validate interactive capability contract run: pnpm run capabilities:check && pnpm run capabilities:test && pnpm run website:test && pnpm run website:build + - name: Test core boundary contracts + run: pnpm run check:core-boundaries:test + - name: Check core boundaries - run: node --test scripts/check-core-boundaries.test.mjs + run: pnpm run check:core-boundaries - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Setup Bun for package resource preflight + if: needs.build-impact.outputs.frontend_required != 'false' + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + no-cache: true + - name: Setup Rust for release projection preflight if: needs.build-impact.outputs.frontend_required != 'false' uses: dtolnay/rust-toolchain@stable @@ -540,6 +549,12 @@ jobs: if: needs.build-impact.outputs.frontend_required != 'false' run: cargo metadata --locked --no-deps + - name: Build plugin Host resources + if: needs.build-impact.outputs.frontend_required != 'false' + run: | + bun install --cwd src/apps/extension-host --frozen-lockfile + bun run --cwd src/apps/extension-host build + - name: Generate web API bindings if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm --dir src/web-ui run gen:types diff --git a/.github/workflows/cli-package-manual.yml b/.github/workflows/cli-package-manual.yml index 41451a6aa0..00c694edb0 100644 --- a/.github/workflows/cli-package-manual.yml +++ b/.github/workflows/cli-package-manual.yml @@ -127,6 +127,16 @@ jobs: with: ref: ${{ needs.prepare.outputs.checkout_ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Build plugin Host resources + run: | + bun install --cwd src/apps/extension-host --frozen-lockfile + bun run --cwd src/apps/extension-host build + - name: Install Linux system dependencies if: runner.os == 'Linux' shell: bash @@ -135,7 +145,6 @@ jobs: sudo apt-get install -y --no-install-recommends \ pkg-config \ build-essential \ - libssl-dev \ libxcb1-dev \ libxcb-render0-dev \ libxcb-shape0-dev \ diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml index 10f37add26..14bfba0367 100644 --- a/.github/workflows/cli-package.yml +++ b/.github/workflows/cli-package.yml @@ -105,6 +105,16 @@ jobs: with: ref: ${{ needs.prepare.outputs.checkout_ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Build plugin Host resources + run: | + bun install --cwd src/apps/extension-host --frozen-lockfile + bun run --cwd src/apps/extension-host build + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/linux-binaries.yml b/.github/workflows/linux-binaries.yml index f493c7880a..af21924880 100644 --- a/.github/workflows/linux-binaries.yml +++ b/.github/workflows/linux-binaries.yml @@ -79,12 +79,23 @@ jobs: with: ref: ${{ inputs.checkout_ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + no-cache: true + - name: Setup Node.js uses: actions/setup-node@v5 with: node-version-file: package.json package-manager-cache: false + - name: Build plugin Host resources + run: | + bun install --cwd src/apps/extension-host --frozen-lockfile + bun run --cwd src/apps/extension-host build + - name: Install Linux system dependencies shell: bash run: | @@ -94,7 +105,6 @@ jobs: clang \ cmake \ curl \ - libssl-dev \ libxcb1-dev \ libxcb-render0-dev \ libxcb-shape0-dev \ diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml index 40d75a0e58..62dda2c5aa 100644 --- a/.github/workflows/nightly-artifacts.yml +++ b/.github/workflows/nightly-artifacts.yml @@ -89,6 +89,12 @@ jobs: with: ref: ${{ inputs.checkout_ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + no-cache: true + - name: Install Linux system dependencies (Tauri bundler) if: runner.os == 'Linux' shell: bash @@ -172,6 +178,11 @@ jobs: exit 1 fi + - name: Build plugin Host resources + run: | + bun install --cwd src/apps/extension-host --frozen-lockfile + bun run --cwd src/apps/extension-host build + - name: Generate web API bindings run: pnpm --dir src/web-ui run gen:types diff --git a/.gitignore b/.gitignore index 852881de86..b8a2b6d5be 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ sdk/typescript/src/internal/wire-validators.ts # The deployable Rust services use the workspace lockfile for reproducible # container builds. !Cargo.lock +# The standalone Installer resolves against the shared source tree and owns a +# generated lockfile that must not be committed. +/BitFun-Installer/src-tauri/Cargo.lock # Monaco Editor - copied from node_modules public/monaco-editor/ @@ -95,6 +98,7 @@ external/ /.flashgrep-index-engine/ .design/ +.pnpm-store/ # KMP shared mobile core and the platform apps that include it (Gradle). # Written per-directory rather than as a bare `local.properties` so the pattern diff --git a/AGENTS-CN.md b/AGENTS-CN.md index df52d88c04..22136eab82 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -28,7 +28,7 @@ Stable Contracts and Security Control Plane 的边界以 | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线、不可变内置 Agent 内容、adapter/service 注册和生态无关的来源协调 | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver 协议 adapter、外部 AI work source adapter(OpenCode/Claude Code/Codex)和外部 provider 转换 | `agent-runtime-ipc`、`ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、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`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | +| 5 | 执行原语层 | `src/crates/execution` | 可移植 agent、命名工作流策略、harness、stream、DeepReview policy/report、插件运行时客户端、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | 边界规则: diff --git a/AGENTS.md b/AGENTS.md index 2879251385..b32e1d170b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Keep crate dependencies inside each layer to the smallest set needed. | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, immutable built-in Agent content, adapter/service registration, and ecosystem-neutral source coordination | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver protocol adapters, external AI work source adapters (OpenCode/Claude Code/Codex), and external-provider translation | `agent-runtime-ipc`, `ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `miniapp-market-service`, `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`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | +| 5 | Execution primitives | `src/crates/execution` | Portable agent, named-workflow policy, harness, stream, DeepReview policy/report, plugin runtime client, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | Boundary rules: diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index 56699761f0..7461b70a7b 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-installer", - "version": "0.2.18", + "version": "0.2.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-installer", - "version": "0.2.18", + "version": "0.2.19", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index 0b34ccd0ca..ae90699962 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-installer", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", diff --git a/BitFun-Installer/scripts/sync-model-i18n.cjs b/BitFun-Installer/scripts/sync-model-i18n.cjs index 6a0640d22c..43b2812ea8 100644 --- a/BitFun-Installer/scripts/sync-model-i18n.cjs +++ b/BitFun-Installer/scripts/sync-model-i18n.cjs @@ -209,7 +209,7 @@ function syncOne(languageTag) { 'locales', localeDir, 'settings', - 'ai-model.json' + 'models.json' ); const sourceComponentsPath = path.join( PROJECT_ROOT, diff --git a/BitFun-Installer/scripts/sync-theme-i18n.cjs b/BitFun-Installer/scripts/sync-theme-i18n.cjs index 53f4505e5d..76797e02ae 100644 --- a/BitFun-Installer/scripts/sync-theme-i18n.cjs +++ b/BitFun-Installer/scripts/sync-theme-i18n.cjs @@ -25,7 +25,7 @@ function writeJson(filePath, data) { } function extractThemeNames(source, sourceLabel) { - // Theme preset names live under settings/basics.json → appearance.presets (formerly theme.json → theme.presets). + // Theme preset names live under settings/application.json → appearance.presets (formerly theme.json → theme.presets). const presets = source?.appearance?.presets; if (!presets || typeof presets !== "object") { throw new Error(`Invalid appearance.presets in ${sourceLabel}`); @@ -63,7 +63,7 @@ function main() { "locales", "en-US", "settings", - "basics.json" + "application.json" ); const sourceZhPath = path.join( PROJECT_ROOT, @@ -73,7 +73,7 @@ function main() { "locales", "zh-CN", "settings", - "basics.json" + "application.json" ); const targetEnPath = path.join( diff --git a/BitFun-Installer/src-tauri/Cargo.lock b/BitFun-Installer/src-tauri/Cargo.lock deleted file mode 100644 index 4748ef57d7..0000000000 --- a/BitFun-Installer/src-tauri/Cargo.lock +++ /dev/null @@ -1,5535 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitfun-agent-stream" -version = "0.2.18" -dependencies = [ - "anyhow", - "async-trait", - "bitfun-core-types", - "bitfun-events", - "bitfun-tool-call-jsonrepair", - "futures", - "log", - "serde", - "serde_json", - "tokio", - "tokio-util", - "uuid", -] - -[[package]] -name = "bitfun-ai-adapters" -version = "0.2.18" -dependencies = [ - "anyhow", - "async-trait", - "bitfun-agent-stream", - "bitfun-core-types", - "chrono", - "eventsource-stream", - "futures", - "hex", - "log", - "reqwest", - "serde", - "serde_json", - "sha2", - "tokio", - "tokio-stream", - "tokio-util", - "urlencoding", -] - -[[package]] -name = "bitfun-core-types" -version = "0.2.18" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "bitfun-events" -version = "0.2.18" -dependencies = [ - "anyhow", - "async-trait", - "bitfun-core-types", - "chrono", - "log", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "bitfun-installer" -version = "0.2.18" -dependencies = [ - "anyhow", - "bitfun-ai-adapters", - "chrono", - "dirs 5.0.1", - "log", - "mslnk", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "winreg 0.52.0", - "zip", -] - -[[package]] -name = "bitfun-tool-call-jsonrepair" -version = "0.2.18" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "brotli" -version = "8.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -dependencies = [ - "serde", -] - -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.13.1", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "camino" -version = "1.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.20", -] - -[[package]] -name = "cargo_toml" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "cc" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - -[[package]] -name = "cookie" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" -dependencies = [ - "bitflags 2.13.1", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.13.1", - "core-foundation", - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dbus" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.20", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.1", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dom_query" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" -dependencies = [ - "bit-set", - "cssparser", - "foldhash", - "html5ever", - "precomputed-hash", - "selectors", - "tendril", -] - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -dependencies = [ - "serde", -] - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "embed-resource" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 1.1.4+spec-1.1.0", - "vswhom", - "winreg 0.55.0", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "eventsource-stream" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" -dependencies = [ - "futures-core", - "nom", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.13.1", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "h2" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "html5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" -dependencies = [ - "log", - "markup5ever", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "ico" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" -dependencies = [ - "byteorder", - "png 0.17.16", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "ipnet" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "jiff" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link 0.2.1", -] - -[[package]] -name = "jiff-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" -dependencies = [ - "defmt", -] - -[[package]] -name = "jiff-static" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.20", - "walkdir", - "windows-link 0.2.1", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "json-patch" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "jsonptr" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.13.1", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libredox" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" -dependencies = [ - "libc", -] - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "markup5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" -dependencies = [ - "log", - "tendril", - "web_atoms", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mslnk" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86c97310150b7f496a93f31690da7822b99d95ff68ca9d30fb09d3ad54375c76" -dependencies = [ - "bitflags 1.3.2", - "byteorder", - "log", -] - -[[package]] -name = "muda" -version = "0.19.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.20", - "windows-sys 0.61.2", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.13.1", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.1", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros", - "phf_shared", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "plist" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" -dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.20", -] - -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni 0.22.4", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "indexmap 1.9.3", - "schemars_derive", - "serde", - "serde_json", - "url", - "uuid", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.119", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" -dependencies = [ - "bitflags 2.13.1", - "cssparser", - "derive_more", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "rustc-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_with" -version = "3.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" -dependencies = [ - "base64 0.22.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "jiff", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "serialize-to-javascript" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] - -[[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "bytemuck", - "js-sys", - "ndk", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", - "raw-window-handle", - "redox_syscall", - "tracing", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "string_cache" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", -] - -[[package]] -name = "string_cache_codegen" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "swift-rs" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "tao" -version = "0.35.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" -dependencies = [ - "bitflags 2.13.1", - "block2", - "core-foundation", - "core-graphics", - "crossbeam-channel", - "dbus", - "dispatch2", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni 0.21.1", - "libc", - "log", - "ndk", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "once_cell", - "parking_lot", - "percent-encoding", - "raw-window-handle", - "tao-macros", - "unicode-segmentation", - "url", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tauri" -version = "2.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "dirs 6.0.0", - "dunce", - "embed_plist", - "getrandom 0.3.4", - "glob", - "gtk", - "heck 0.5.0", - "http", - "jni 0.21.1", - "libc", - "log", - "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.20", - "tokio", - "tray-icon", - "url", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows", -] - -[[package]] -name = "tauri-build" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" -dependencies = [ - "anyhow", - "cargo_toml", - "dirs 6.0.0", - "glob", - "heck 0.5.0", - "json-patch", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "tauri-utils", - "tauri-winres", - "walkdir", -] - -[[package]] -name = "tauri-codegen" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" -dependencies = [ - "base64 0.22.1", - "brotli", - "ico", - "json-patch", - "plist", - "png 0.17.16", - "proc-macro2", - "quote", - "semver", - "serde", - "serde_json", - "sha2", - "syn 2.0.119", - "tauri-utils", - "thiserror 2.0.20", - "time", - "url", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-macros" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "tauri-codegen", - "tauri-utils", -] - -[[package]] -name = "tauri-plugin" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "walkdir", -] - -[[package]] -name = "tauri-plugin-dialog" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" -dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.20", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" -dependencies = [ - "anyhow", - "dunce", - "glob", - "log", - "objc2-foundation", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", - "url", -] - -[[package]] -name = "tauri-runtime" -version = "2.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" -dependencies = [ - "cookie", - "dpi", - "gtk", - "http", - "jni 0.21.1", - "objc2", - "objc2-ui-kit", - "objc2-web-kit", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.20", - "url", - "webkit2gtk", - "webview2-com", - "windows", -] - -[[package]] -name = "tauri-runtime-wry" -version = "2.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" -dependencies = [ - "gtk", - "http", - "jni 0.21.1", - "log", - "objc2", - "objc2-app-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows", - "wry", -] - -[[package]] -name = "tauri-utils" -version = "2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor", - "dom_query", - "dunce", - "glob", - "http", - "infer", - "json-patch", - "log", - "memchr", - "phf", - "plist", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" -dependencies = [ - "dunce", - "embed-resource", - "toml 1.1.4+spec-1.1.0", -] - -[[package]] -name = "tendril" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" -dependencies = [ - "new_debug_unreachable", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.4", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow 1.0.4", -] - -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.13.1", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tray-icon" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" -dependencies = [ - "crossbeam-channel", - "dirs 6.0.0", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.20", - "windows-sys 0.61.2", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "urlpattern" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" -dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web_atoms" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" -dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" -dependencies = [ - "thiserror 2.0.20", - "windows", - "windows-core 0.61.2", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "window-vibrancy" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" -dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "windows-sys 0.59.0", - "windows-version", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "wry" -version = "0.55.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" -dependencies = [ - "base64 0.22.1", - "block2", - "cookie", - "crossbeam-channel", - "dirs 6.0.0", - "dom_query", - "dpi", - "dunce", - "gdkx11", - "gtk", - "http", - "javascriptcore-rs", - "jni 0.21.1", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.20", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2", - "sha1", - "time", - "zstd", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index dddb579468..5e94b3e4cb 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-installer" -version = "0.2.18" +version = "0.2.19" authors = ["BitFun Team"] edition = "2021" description = "BitFun Custom Installer - Modern branded installation experience" diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index e5a53e8dc5..05dbc58eee 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -47,7 +47,7 @@ "modelName": "Model name (e.g. deepseek-v4-flash)", "skip": "Skip for now", "nextTheme": "Next: Theme", - "description": "Configure and manage AI model providers", + "description": "Manage default models, providers, subscriptions, catalog data, and network connections.", "providerLabel": "Select Model Provider", "selectProvider": "or select a preset provider", "customProvider": "Custom Configuration", diff --git a/BitFun-Installer/src/i18n/locales/zh-TW.json b/BitFun-Installer/src/i18n/locales/zh-TW.json index 1efde0d52a..3074ec69b3 100644 --- a/BitFun-Installer/src/i18n/locales/zh-TW.json +++ b/BitFun-Installer/src/i18n/locales/zh-TW.json @@ -47,7 +47,7 @@ "modelName": "模型名稱(如 deepseek-v4-flash)", "skip": "稍後設定", "nextTheme": "下一步:主題", - "description": "設定和管理 AI 模型提供商", + "description": "管理預設模型、模型服務、訂閱帳戶、模型目錄與網路連線。", "providerLabel": "選擇模型提供商", "selectProvider": "或選擇預設提供商", "customProvider": "自定義設定", diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 6bf7eb42c5..e7ee699f09 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -47,7 +47,7 @@ "modelName": "模型名称(如 deepseek-v4-flash)", "skip": "稍后配置", "nextTheme": "下一步:主题", - "description": "配置和管理 AI 模型提供商", + "description": "管理默认模型、模型服务、订阅账户、模型目录与网络连接。", "providerLabel": "选择模型提供商", "selectProvider": "或选择预设提供商", "customProvider": "自定义配置", diff --git a/Cargo.lock b/Cargo.lock index d9329d53a3..b9324ccb3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -759,7 +759,7 @@ dependencies = [ [[package]] name = "bitfun-acp" -version = "0.2.18" +version = "0.2.19" dependencies = [ "agent-client-protocol", "async-trait", @@ -785,11 +785,11 @@ dependencies = [ [[package]] name = "bitfun-agent-content" -version = "0.2.18" +version = "0.2.19" [[package]] name = "bitfun-agent-runtime" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-agent-stream", @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "bitfun-agent-runtime-ipc" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-events", @@ -836,7 +836,7 @@ dependencies = [ [[package]] name = "bitfun-agent-stream" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -856,7 +856,7 @@ dependencies = [ [[package]] name = "bitfun-agent-tools" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-core-types", @@ -867,9 +867,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "bitfun-agent-workflows" +version = "0.2.19" +dependencies = [ + "regex", +] + [[package]] name = "bitfun-ai-adapters" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "apple-native-keyring-store", @@ -904,7 +911,7 @@ dependencies = [ [[package]] name = "bitfun-app-server" -version = "0.2.18" +version = "0.2.19" dependencies = [ "agent-client-protocol", "anyhow", @@ -915,6 +922,7 @@ dependencies = [ "bitfun-core", "bitfun-product-domains", "bitfun-runtime-ports", + "dunce", "log", "serde", "serde_json", @@ -925,7 +933,7 @@ dependencies = [ [[package]] name = "bitfun-app-server-client" -version = "0.2.18" +version = "0.2.19" dependencies = [ "agent-client-protocol", "anyhow", @@ -936,7 +944,7 @@ dependencies = [ [[package]] name = "bitfun-app-server-protocol" -version = "0.2.18" +version = "0.2.19" dependencies = [ "agent-client-protocol", "bitfun-core-types", @@ -950,7 +958,7 @@ dependencies = [ [[package]] name = "bitfun-claude-code-adapter" -version = "0.2.18" +version = "0.2.19" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -969,7 +977,7 @@ dependencies = [ [[package]] name = "bitfun-cli" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "arboard", @@ -979,6 +987,7 @@ dependencies = [ "bitfun-agent-runtime", "bitfun-agent-runtime-ipc", "bitfun-agent-tools", + "bitfun-app-server", "bitfun-core", "bitfun-core-types", "bitfun-events", @@ -1014,6 +1023,7 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "tokio", + "tokio-util", "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", @@ -1026,7 +1036,7 @@ dependencies = [ [[package]] name = "bitfun-codex-adapter" -version = "0.2.18" +version = "0.2.19" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -1043,7 +1053,7 @@ dependencies = [ [[package]] name = "bitfun-core" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -1053,6 +1063,7 @@ dependencies = [ "bitfun-agent-runtime", "bitfun-agent-stream", "bitfun-agent-tools", + "bitfun-agent-workflows", "bitfun-ai-adapters", "bitfun-claude-code-adapter", "bitfun-codex-adapter", @@ -1062,6 +1073,7 @@ dependencies = [ "bitfun-external-sources", "bitfun-harness", "bitfun-opencode-adapter", + "bitfun-opencode-plugin-host", "bitfun-plugin-runtime-client", "bitfun-product-capabilities", "bitfun-product-domains", @@ -1106,12 +1118,13 @@ dependencies = [ "tool-runtime", "ts-rs", "unic-langid", + "urlencoding", "uuid", ] [[package]] name = "bitfun-core-types" -version = "0.2.18" +version = "0.2.19" dependencies = [ "serde", "serde_json", @@ -1120,7 +1133,7 @@ dependencies = [ [[package]] name = "bitfun-desktop" -version = "0.2.18" +version = "0.2.19" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1204,7 +1217,7 @@ dependencies = [ [[package]] name = "bitfun-dsh-adapter" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-plugin-runtime-client", @@ -1221,7 +1234,7 @@ dependencies = [ [[package]] name = "bitfun-events" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -1235,7 +1248,7 @@ dependencies = [ [[package]] name = "bitfun-external-sources" -version = "0.2.18" +version = "0.2.19" dependencies = [ "bitfun-product-domains", "futures", @@ -1245,7 +1258,7 @@ dependencies = [ [[package]] name = "bitfun-harness" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "thiserror 2.0.19", @@ -1254,7 +1267,7 @@ dependencies = [ [[package]] name = "bitfun-miniapp-market-server" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "axum", @@ -1266,7 +1279,7 @@ dependencies = [ [[package]] name = "bitfun-miniapp-market-service" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "axum", @@ -1296,7 +1309,7 @@ dependencies = [ [[package]] name = "bitfun-opencode-adapter" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-plugin-runtime-client", @@ -1322,9 +1335,26 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "bitfun-opencode-plugin-host" +version = "0.2.19" +dependencies = [ + "base64 0.22.1", + "bitfun-services-core", + "log", + "rand 0.8.7", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tokio", + "url", + "urlencoding", +] + [[package]] name = "bitfun-page-function-runtime" -version = "0.2.18" +version = "0.2.19" dependencies = [ "rquickjs", "serde", @@ -1334,7 +1364,7 @@ dependencies = [ [[package]] name = "bitfun-plugin-runtime-client" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-runtime-ports", @@ -1343,7 +1373,7 @@ dependencies = [ [[package]] name = "bitfun-product-capabilities" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-agent-runtime", @@ -1356,7 +1386,7 @@ dependencies = [ [[package]] name = "bitfun-product-domains" -version = "0.2.18" +version = "0.2.19" dependencies = [ "dirs 6.0.0", "hex", @@ -1373,7 +1403,7 @@ dependencies = [ [[package]] name = "bitfun-relay-server" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "axum", @@ -1390,7 +1420,7 @@ dependencies = [ [[package]] name = "bitfun-relay-service" -version = "0.2.18" +version = "0.2.19" dependencies = [ "aes-gcm", "anyhow", @@ -1419,7 +1449,7 @@ dependencies = [ [[package]] name = "bitfun-runtime-ports" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -1434,7 +1464,7 @@ dependencies = [ [[package]] name = "bitfun-runtime-services" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -1449,7 +1479,7 @@ dependencies = [ [[package]] name = "bitfun-sdk-host" -version = "0.2.18" +version = "0.2.19" dependencies = [ "async-trait", "bitfun-agent-runtime", @@ -1468,7 +1498,7 @@ dependencies = [ [[package]] name = "bitfun-sdk-host-app" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -1485,11 +1515,12 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "url", ] [[package]] name = "bitfun-server" -version = "0.2.18" +version = "0.2.19" dependencies = [ "agent-client-protocol", "anyhow", @@ -1515,7 +1546,7 @@ dependencies = [ [[package]] name = "bitfun-services-core" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -1551,7 +1582,7 @@ dependencies = [ [[package]] name = "bitfun-services-integrations" -version = "0.2.18" +version = "0.2.19" dependencies = [ "aes", "aes-gcm", @@ -1562,6 +1593,7 @@ dependencies = [ "base64 0.22.1", "bitfun-agent-runtime", "bitfun-agent-tools", + "bitfun-agent-workflows", "bitfun-core-types", "bitfun-events", "bitfun-product-domains", @@ -1627,7 +1659,7 @@ dependencies = [ [[package]] name = "bitfun-skin-market-server" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "axum", @@ -1639,7 +1671,7 @@ dependencies = [ [[package]] name = "bitfun-skin-market-service" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "axum", @@ -1668,7 +1700,7 @@ dependencies = [ [[package]] name = "bitfun-static-hook-support" -version = "0.2.18" +version = "0.2.19" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -1681,7 +1713,7 @@ dependencies = [ [[package]] name = "bitfun-tool-call-jsonrepair" -version = "0.2.18" +version = "0.2.19" dependencies = [ "serde", "serde_json", @@ -1689,11 +1721,11 @@ dependencies = [ [[package]] name = "bitfun-tool-packs" -version = "0.2.18" +version = "0.2.19" [[package]] name = "bitfun-transport" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -1707,7 +1739,7 @@ dependencies = [ [[package]] name = "bitfun-webdriver" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "axum", @@ -4133,9 +4165,6 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", ] [[package]] @@ -5511,7 +5540,6 @@ dependencies = [ "cc", "libc", "libz-sys", - "openssl-sys", "pkg-config", ] @@ -6606,40 +6634,12 @@ dependencies = [ "libc", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -8635,7 +8635,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", "security-framework", @@ -10451,7 +10451,7 @@ dependencies = [ [[package]] name = "terminal-core" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "async-trait", @@ -10882,7 +10882,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tool-runtime" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anydoc", "bitfun-agent-tools", diff --git a/Cargo.toml b/Cargo.toml index 316cb95efe..76310fbbbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", "src/crates/adapters/dsh-adapter", + "src/crates/adapters/opencode-plugin-host", "src/crates/adapters/claude-code-adapter", "src/crates/adapters/codex-adapter", "src/crates/adapters/static-hook-support", @@ -34,10 +35,11 @@ members = [ "src/crates/assembly/product-capabilities", "src/crates/contracts/product-domains", "src/crates/execution/agent-runtime", + "src/crates/execution/agent-workflows", "src/crates/execution/agent-stream", + "src/crates/execution/harness", "src/crates/execution/tool-call-jsonrepair", "src/crates/execution/tool-contracts", - "src/crates/execution/harness", "src/crates/execution/plugin-runtime-client", "src/crates/execution/runtime-services", "src/crates/execution/tool-provider-groups", @@ -55,7 +57,7 @@ resolver = "2" # Shared package metadata — single source of truth for version [workspace.package] -version = "0.2.18" # x-release-please-version +version = "0.2.19" # x-release-please-version authors = ["BitFun Team"] edition = "2021" @@ -121,7 +123,7 @@ regex = "1" base64 = "0.22" # Keep macOS Tauri's dispatch2/bitflags expansion on the known-good bitflags release. bitflags = "=2.11.1" -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } +image = { version = "0.25", default-features = false } md5 = "0.7" dashmap = "6" indexmap = "2" @@ -138,8 +140,8 @@ reqwest = { version = "0.13.4", default-features = false } semver = "1.0" # Debug Log HTTP Server -axum = { version = "0.8", features = ["json", "ws"] } -tower-http = { version = "0.6.11", features = ["cors", "fs"] } +axum = { version = "0.8", features = ["json"] } +tower-http = "0.6.11" # File system glob = "0.3" @@ -163,7 +165,7 @@ zstd = "0.13" toml = "0.9" # Git -git2 = { version = "0.21", default-features = false, features = ["https", "vendored-libgit2"] } +git2 = { version = "0.21", default-features = false, features = ["vendored-libgit2"] } # Terminal portable-pty = "0.8" @@ -259,7 +261,7 @@ hostname = "0.4" qrcode = { version = "0.14", default-features = false } # WebSocket client -tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } +tokio-tungstenite = "0.29" # Local speech recognition sherpa-onnx = "1.13.4" diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index ee95012869..2f283f16f6 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -399,7 +399,7 @@ request/result/event 映射及私有 IPC 的协议与连接控制;Agent Runtim - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 - 交互式 TUI 的启动页和会话页直接依赖 `CliAgentRuntimeClient`;Embedded 与 Shared Runtime 调用都进入该 client。TUI controller 不直接依赖 Rust Runtime SDK、Runtime 实现或 IPC operation。 -- 非 Runtime 能力由 controller 直接调用 owner-owned 的稳定 service/API;每个使用 controller-local owner 的调用点单独裁剪 Remote workspace scope。CLI 不依赖 App Server implementation/client,也不复制 management DTO;当前可复用 behavior-light protocol 合同,后续若下沉 semantic payload,必须与 JSON-RPC envelope 分离。 +- 非 Runtime 能力由 controller 直接调用 owner-owned 的稳定 service/API;每个使用 controller-local owner 的调用点单独裁剪 Remote workspace scope。CLI 仅在 `bitfun server` 的独立 stdio Server Host 装配点(`src/apps/cli/src/server_host.rs`)依赖 App Server implementation;该 Host 是独立 Host 表面,注入显式 allowlist 与 canonical workspace scope 收敛能力,而非 TUI/Headless 的默认路径。TUI、controller 与 Headless CLI 仍不依赖 App Server implementation/client,也不复制 management DTO;Headless CLI 默认保持 Embedded。当前可复用 behavior-light protocol 合同,后续若下沉 semantic payload,必须与 JSON-RPC envelope 分离。 - App Server wiring 独立于 TUI composition;TUI 重构不以 App Server parity 或兼容性作为验收条件。 - Web 当前独立使用自己的 loopback WebSocket App Server Host,不进入 TUI backend composition。Shared 当前只有 private Runtime IPC v17;Shared App Server 只存在于第 1.3 节的 Phase 6 candidate 图中。 - Headless CLI 和 Peer Host 使用同一 Runtime 订阅入口,但分别保留确定性退出与 Peer fanout 语义;共享订阅入口不等于共享 renderer 或产品生命周期。 diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 671b5a611c..a064f01e41 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -16,7 +16,7 @@ Shared Agent Runtime 与 Plugin Host 的进程关系见 本文中的接口片段只说明依赖方向和职责,不自动构成当前 API 或实施承诺。当前接口名称、字段和消费方以代码为准; 新增公共类型前必须有真实生产调用方、版本边界和验证路径。现有 `agent-runtime::sdk` 是 Rust Runtime SDK(当前 preview),不是公开 Python/TypeScript BitFun Agent SDK。CLI、ACP、 -Desktop 仍保留 `bitfun-core/product-full` 兼容 owner。CLI 与 CLI 托管的 ACP server 已消费各自的产品组装结果; +Desktop 仍复用 `bitfun-core` 的兼容 owner;只有 Desktop 与本机兼容 Server Host 选择 `product-full`。CLI 与 CLI 托管的 ACP server 已消费各自的产品组装结果; Desktop 主交互只消费由现有 Core 归属模块构造的少量应用接口,尚未组装完整 Desktop profile。这些接入都不等于 协调器、调度器、持久化或工具执行 owner 已迁移;ACP 的完整持久化历史、模型/模式目录与提供方配置、MCP、客户端路径与 Desktop 的其余入口仍保留明确的兼容边界,活动会话的模型/模式写入已通过 Agent Runtime API 回到 Core owner。 @@ -68,7 +68,7 @@ Agent Runtime API 的逻辑归属与物理部署分离:相同归属模块可 私有 SDK Host 或目标机器 Runtime 中。任何 Rust 部署都只管理自己进程树内的服务与 Node/Bun Plugin Host;不能因为多个 GUI/TUI/Remote Client 连接就复制 Runtime 状态模块,或按 Client/Workspace 创建 Plugin Host。 -Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v6 preview: +Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v8 preview: 小版本更新允许增加可选 builder hook、有默认实现的端口方法或注册表查询能力,但不得向外部可用 Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段,也不得改变既有端口语义、错误分类、session / turn 标识含义或 默认 feature 依赖。任何需要调用方改写现有嵌入代码的变更,必须提升接口版本并提供兼容迁移路径。 @@ -85,10 +85,20 @@ v4 将活动 Turn 的文本 steer 纳入 `AgentDialogTurnPort`,复用同一个 v6 将完整 Rust Runtime SDK 从空默认编译面移入 `agent-runtime` owner feature。现有 Rust embedder 迁移时在 `bitfun-agent-runtime` 依赖上显式选择 `features = ["agent-runtime"]`;启用后 `sdk` 模块、 -公开路径和运行时行为保持不变。只消费 DeepResearch 编号或 Hook 设置的调用方应分别选择 -`deep-research` 或 `native-hook-settings`,不需要继承完整 Runtime。仓库内最小 SDK example 通过 +公开路径和运行时行为保持不变。只消费 Hook 设置的调用方选择 +`native-hook-settings`,不需要继承完整 Runtime。DeepResearch 编号策略归 +`agent-workflows`,不再通过 Agent Runtime feature 暴露。仓库内最小 SDK example 通过 `required-features = ["agent-runtime"]` 明确记录这一版本边界。 +v7 增加持久 Session 的显式卸载,用于 SDK Host 重启后的恢复与同 Session 单写释放。 + +通用 Harness descriptor registry、builder 注入和查询接口继续作为 SDK 兼容面保留。Product Assembly 仍决定 +内置 provider;CLI、ACP 和 SDK Headless profile 不自动注册 DeepReview、DeepResearch 或 MiniApp provider。 +命名工作流策略由 `agent-workflows` / 现有产品 owner 持有,不能把 Harness registry 当成第二套产品工作流 owner。 + +v8 增加 `TurnTokenUsage` 聚合事实。对应 SDK Host protocol v5 支持本地图片输入并在 Query 终态返回用量; +该版本不删除或改写 Harness registry 注入/查询接口。 + 只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有具体服务管理器、读取产品命令 注册表、理解 ACP/内部端口或依赖全局可变状态,公开 SDK 发布边界就不成立。公开 SDK 的完整 术语、能力等价和版本要求以 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准。 @@ -144,8 +154,8 @@ Server/WebSocket 或 OpenCode v1/v2 的版本化事件清单必须随真实消 |---|---|---|---|---| | 产品组装接口 | `src/crates/assembly/*` | 特性包、内核接口、执行层接口、运行时服务、平台提供方 | 智能体内部状态机、具体 UI 组件实现作为下层依赖 | 按产品形态组装能力,输出类型化运行时部件 | | 产品特性接口 | `product-capabilities`、`product-domains`、对应入口归属模块 | 内核接口、能力状态只读接口、能力/副作用接口、领域接口 | OS 具体实现、Tauri 句柄、执行层具体实现、最终权限策略 | 把内核能力映射为用户功能、入口视图和默认策略 | -| Rust 内核接口 | `agent-runtime`、`agent-stream`、`runtime-services`、`runtime-ports`、`events`、`core-types` | 稳定接口、工具/工作流注册表、类型化服务 | `bitfun-core`、Tauri、Web UI、ACP 协议、提供方具体实现 | 会话 / 轮次 / 事件 / 权限 / 调度 / 上下文等 SDK 候选接口 | -| 执行层接口 | `tool-contracts`、`tool-provider-groups`、`tool-execution`、`harness` | 稳定接口、运行时端口、注入的服务端口 | 产品注册表、UI、具体文件系统/Git/终端/MCP 客户端 | 工具、skills、MCP 工具桥接、沙箱、工作流执行语义 | +| Rust 内核接口 | `agent-runtime`、`agent-stream`、`runtime-services`、`runtime-ports`、`events`、`core-types` | 稳定接口、通用 Agent/Tool/Harness/Hook 注册接口、类型化服务 | `bitfun-core`、命名产品工作流、Tauri、Web UI、ACP 协议、提供方具体实现 | 会话 / 轮次 / 事件 / 权限 / 调度 / 上下文等 SDK 候选接口 | +| 执行层接口 | `agent-workflows`、`tool-contracts`、`tool-provider-groups`、`tool-execution`、`harness` | 稳定接口、运行时端口、注入的服务端口 | 产品注册表、UI、具体文件系统/Git/终端/MCP 客户端 | 命名工作流策略、通用 Harness 契约、工具、skills、MCP 工具桥接、沙箱和执行语义 | | 扩展接口 | `PluginRuntimeClient` / OpenCode 兼容 / ACP 适配器归属模块 | Rust 内核接口、工具/事件/权限子接口、能力/副作用接口 | Web UI React 实现、Tauri 状态、内核权威状态写入 | 把外部生态能力转换为工具、Hook 变换、界面贡献和诊断 | | 平台/提供方适配器接口 | `services/*`、`adapters/*`、app-local provider | 运行时端口、稳定 DTO、允许的第三方库 | 产品特性、智能体内核状态机、UI 命令 | 实现文件系统、终端、网络、远端、Git、MCP 传输、AI 提供方等边界外 I/O | | 稳定数据接口 | `contracts/*` | 低层无行为依赖或标准序列化依赖 | 上层 crate、具体管理器、UI 渲染 | DTO、事件、端口、能力/副作用、权限、沙箱、审计、类型化错误 | @@ -155,7 +165,9 @@ Server/WebSocket 或 OpenCode v1/v2 的版本化事件清单必须随真实消 - `contracts/*` 或 `runtime-ports` 依赖 `bitfun-core`、assembly、apps、UI 或具体服务。 - `agent-runtime` 依赖 `bitfun-core`、Tauri、Web UI、ACP 协议、AI 提供方具体实现、MCP 客户端具体实现或 OS 服务管理器。 - `tool-contracts` 依赖具体 service crate;`tool-execution` 依赖产品注册表、产品权限策略或具体 UI。 -- `harness` 依赖具体文件系统/Git/终端管理器;它只通过端口和提供方接口获取能力。 +- 禁止 `agent-runtime` 反向依赖 `agent-workflows`;命名工作流可以消费 Runtime / contracts,Runtime 不感知工作流名称。 +- 禁止 `agent-workflows` 依赖具体文件系统/Git/终端管理器;具体 I/O 由 Services 持有,工作流只保留无 I/O 决策或通过窄端口调用。 +- `harness` 不依赖具体文件系统/Git/终端管理器;它只定义 SDK 可注入的通用描述符和 registry primitive,不拥有内置产品工作流。 - `plugin-runtime-client` 不能依赖 Web UI React 组件实现、Tauri app 状态或具体 core 管理器。 - 产品特性直接依赖平台适配器具体实现、执行层具体实现、全局可变运行时状态或边界外资源客户端。 @@ -257,7 +269,7 @@ pub trait WorkspacePort: Send + Sync { ```text bitfun-runtime-services - bundle.rs # RuntimeServices / ToolServices / HarnessServices + bundle.rs # RuntimeServices / narrow service views builder.rs # 类型化 builder capability.rs # capability ids 与 availability registry.rs # provider 注册 @@ -432,7 +444,7 @@ impl AgentRuntime { 该 Rust 接口是内部产品入口复用的当前形态,不是公开 Python/TypeScript SDK 的目标 API。它必须只接收 已组装的类型化部件,不负责创建 文件系统、终端、MCP、AI 客户端、Remote 提供方或产品命令。 -当前 v6 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把 +当前 v8 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把 model-round cancellation token、结构化 AgentInput 或更复杂的事件游标纳入公开 SDK, 必须分别评审 Rust Runtime SDK、SDK Host protocol 和公开 SDK API 的版本,并保留旧路径兼容。 @@ -665,76 +677,37 @@ pub struct ToolExecutionContext { ### 3.3 工作流层 -目标归属 crate:`bitfun-harness`。 +目标归属 crate:`bitfun-agent-workflows`。 职责: -- 把 SDD、DeepReview、DeepResearch、MiniApp、function-agent 等工作流从运行时内核中分离。 -- 定义工作流描述符、路由计划、提供方注册表、工作流计划、步骤、策略、产物、 - review gate 和 post-processor。 -- 通过 Agent Runtime API、工具运行时和服务端口编排。 +- 承载 DeepReview、DeepResearch 等按名称定义、可独立测试的工作流策略。 +- 复用 Agent Runtime 的通用 Session / Turn / Tool / Event 能力,不复制 Agent loop。 +- 将具体文件、Git、网络、终端和模型调用留在 Services 或当前兼容 owner。 -建议内部模块: +当前最小模块: ```text -bitfun-harness - provider.rs - registry.rs - plan.rs - context.rs - artifact.rs - hooks.rs - review_gate.rs - sdd/ - deep_review/ - deep_research/ - miniapp/ -``` - -核心接口: - -```rust -#[async_trait::async_trait] -pub trait HarnessProvider: Send + Sync { - fn id(&self) -> HarnessId; - fn capabilities(&self) -> HarnessCapabilities; - - async fn plan( - &self, - ctx: HarnessPlanningContext, - input: HarnessInput, - ) -> Result; - - async fn execute( - &self, - ctx: HarnessExecutionContext, - plan: HarnessPlan, - ) -> Result; -} - -pub struct HarnessExecutionContext { - pub runtime: Arc, - pub tools: Arc, - pub services: HarnessServices, - pub events: Arc, -} +agent-workflows + deep_research.rs # citation renumbering and post-process gate ``` 设计约束: -- 工作流允许编排运行时/工具,但不拥有会话管理器内部结构。 -- 工作流不直接访问具体文件系统 / Git / 终端。 +- `agent-workflows` 可以单向依赖 `agent-runtime` 和 contracts;`agent-runtime` 禁止依赖命名工作流。 +- 当前没有第二个可执行 provider,因此不建立通用 workflow trait、registry、descriptor 或 step engine。 +- 工作流策略保持无 I/O;需要编排 Runtime / Tool 时,只增加当前生产调用链所需的窄接口。 +- `bitfun-harness` 继续提供可注入、可查询的通用描述符 registry;它不选择内置 provider,也不承接命名产品工作流策略。 - 产品命令只映射到工作流能力,不把命令展示逻辑下沉。 -- 新工作流通过提供方注册,不改 Agent Runtime 或 Agent Runtime API 的稳定用例。 -- 描述符专用或旧兼容接口只能表达路由计划;不得被描述为已经拥有具体工作流执行。 - 执行语义移动必须单独证明行为等价。 +- MiniApp、Canvas 是产品产物与呈现能力,不进入该 crate。 +- DeepReview 仍有兼容逻辑位于 `agent-runtime` 与 `assembly/core`;只有真实调用方切换、行为等价测试通过且旧写入方删除后,才算迁移完成。 ## 4. 产品组装与扩展 ### 4.1 产品组装 产品组装是组装根,不是另一个业务内核。当前 `src/crates/assembly/product-capabilities` 已提供 -`DeliveryProfile`、静态能力计划、运行时服务校验、Harness 注册和插件运行时绑定; +`DeliveryProfile`、静态能力计划、Agent ID / 原子工具组选择、profile-scoped Harness registry、运行时服务校验和插件运行时绑定; `src/crates/assembly/core` 仍承担 `bitfun-core` 兼容组装。现有 `ProductAssembler` 是具体结构体, 通过 `assemble(ProductAssemblyInput)` 产生 `ProductRuntimeParts`,本文件不再为它定义第二套目标接口。 @@ -743,7 +716,7 @@ pub struct HarnessExecutionContext { SDK profile 当前从共享产品事实获得与 Headless CLI 相同的能力集合,但保持独立产品身份和 `AgentSubmissionSource::SdkHost`;这不建立 CLI crate/协议依赖。Desktop 主交互直接从现有协调器和调度器端口构造窄口径 Rust Runtime SDK,不注册未实现的 `RuntimeServices` 能力,也不宣称完整 Desktop profile 可用。CLI 通过 -一个调用级上下文把该 Rust 接口、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 +一个调用级上下文把该 Rust 接口、Harness registry、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 交互模式下的 Peer Host。Rust Runtime SDK 已承接会话创建/列举/删除/基础恢复、重命名/归档、会话模型更新、thread-goal 查询、类型化转录读取、本地分支、用量生成、 轮次提交/取消与精确结算、用户显式 Shell 命令,以及 CLI/TUI 的工具确认、拒绝和用户问题回答;Shell 命令通过窄端口回到 Core 的正常 ToolPipeline、权限、工作区路由和持久化 owner,不构成通用 Tool 或进程执行 API。固定 ID 创建使用独立的 `create_session_with_id` 方法,普通创建 DTO 只增加可选工作区 ID 与模型 ID 事实,不承载调用方指定的会话 ID。 @@ -775,7 +748,7 @@ Desktop 与 CLI Peer Host 还各自注入同一个 Core-backed `LocalWorkspaceSn 职责: - 接收入口唯一选择的 `DeliveryProfile` 与具体 `RuntimeServices`,生成静态能力计划并校验必需服务。 -- 构造 Harness 注册表和类型化 `PluginRuntimeBinding`;不使用全局注册表。 +- 输出 profile-scoped Agent ID / 原子工具组计划、Harness registry 和类型化 `PluginRuntimeBinding`;Headless profile 的内置 Harness registry 为空,SDK 调用方仍可显式注入自定义 registry。 - 把组装结果交给运行时 builder;不拥有会话、工具执行、工作流执行或 UI 生命周期。 - 对缺失服务和不支持的插件运行时返回类型化错误,不让下层按产品形态分支。 - 产品定义、品牌资源、凭据、用户运行时配置和任意构建脚本不进入运行时组装输入。 @@ -801,7 +774,7 @@ Desktop 与 CLI Peer Host 还各自注入同一个 Core-backed `LocalWorkspaceSn `interfaces/sdk-host` 只保留版本化协议和连接用例。Host 不通过 CLI 启动,也不使用 CLI submission source。 - CLI 的 `json` 输出为单结果文档,`stream-json` 直接复用现有 Agent 事件对象;协议层不新增 `schema_version`、`sequence` 或平行事件 taxonomy。 -- 能力计划选择工具提供方组计划和 Harness 描述符;当前不存在供任意模块注册所有对象的通用组装注册表。 +- 能力计划选择 Agent ID、原子工具提供方组和内置 Harness 描述符;当前不存在供任意模块注册所有对象的通用组装注册表。 - 插件运行时通过 `runtime-ports` 的 `PluginRuntimeBinding` 注入;`assembly/core` 负责构造当前 `PluginRuntimeClient` 与生态适配器组合,当前受管 package 链路不创建 Plugin Host。 - 智能体、命令、skill 和 UI 继续由各自归属模块管理。仓库尚无稳定的 `ProductCommandRegistry` 或 通用 `AgentDefinitionRegistry`,不得为未来入口先行引入。 @@ -812,7 +785,7 @@ Desktop 与 CLI Peer Host 还各自注入同一个 Core-backed `LocalWorkspaceSn - 产品组装允许依赖具体实现;运行时内核不允许依赖具体实现。 - 不同产品允许注册不同入口命令和入口视图,但必须映射到稳定能力。 -- 组装层只选择能力计划、提供方/Harness 描述符和插件 binding;命令、审核、MiniApp、ACP、工具、智能体、 +- 组装层只选择能力计划、Harness provider 和插件 binding;命令、审核、MiniApp、ACP、工具、智能体、 skill 与 UI 定义仍由各自 owner 管理,并按已选能力消费可用性事实。 - 组装层不得改变底层运行时语义来适配某个入口。 - `DeliveryProfile` 只能影响能力/提供方选择,不得让下层出现 `if desktop` @@ -880,7 +853,7 @@ Desktop 与 CLI Peer Host 还各自注入同一个 Core-backed `LocalWorkspaceSn ### 4.3 Product Capability 设计 Product Capability 是产品能力的静态声明,由 `assembly/product-capabilities` 归属。当前实现已经声明能力集合、 -feature group、运行时服务要求、工具提供方组、Harness 描述符和插件可用性;它不拥有 UI、动态健康、权限决策 +feature group、运行时服务要求、内置 Agent ID、原子工具提供方组和插件可用性;它不拥有 UI、动态健康、权限决策 或具体 IO。运行时插件不得成为裁剪内置产品功能的主机制,Cargo feature 也不得直接当作用户可见能力事实。 当前 crate 中不存在通用 `CapabilityPack` trait,也没有理由仅为文档中的候选模块预先固化该 ABI。新增能力先复用 @@ -986,7 +959,7 @@ remote probing 或 startup timeout。 建议归属: - prompt module:Agent Runtime 的 prompt assembly contract。 -- skill:prompt / resource / instruction 扩展,作为 agent definition 或 harness input 的一部分。 +- skill:prompt / resource / instruction 扩展,作为 agent definition 或工作流输入的一部分。 - subagent definition:现有 `RuntimeAgentRegistry` 与智能体定义 owner。 - subagent execution:Agent Runtime。 - Task tool:Tool Runtime entrypoint,经 Agent Runtime API 调用 Agent Runtime。 @@ -1066,7 +1039,7 @@ pub trait BeforeToolExecution: Send + Sync { 取消: -- turn、tool、subagent、harness step 都必须接收 cancellation。 +- turn、tool、subagent 和实际工作流任务都必须接收 cancellation。 - cancellation outcome 必须可观测。 - background task 必须有 result delivery 或 explicit detached state。 @@ -1119,13 +1092,16 @@ Runtime 测试: - fork context seeding。 - background result delivery。 -Harness 测试: +命名工作流测试: + +- 无 I/O 策略输入输出。 +- 重复执行的确定性。 +- Runtime 取消 / 恢复契约的集成边界(只有真实编排路径出现后再增加)。 + +Harness SDK 契约测试: -- provider 注册。 -- plan 结构。 -- artifact 输出。 -- review gate。 -- hook order。 +- fake registry 注入和 provider 查询。 +- Headless profile 不自动装载产品 Harness provider。 Product 测试: @@ -1143,7 +1119,8 @@ Product 测试: - `bitfun-agent-runtime` 不依赖 `bitfun-core`,Rust Runtime SDK 已有最小测试保护。 - `bitfun-runtime-services` 提供类型化服务注入;工具 contracts、provider groups 与 execution 已分层。 -- `bitfun-harness` 已提供类型化工作流描述与注册能力。 +- `bitfun-agent-workflows` 已接管 DeepResearch 的无 I/O 报告后处理;没有建立通用工作流 registry 或第二套执行引擎。 +- `bitfun-harness` 的 SDK 注入和查询兼容面继续保留;Headless 产品组装不会自动装载 DeepReview、DeepResearch 或 MiniApp provider。 - `bitfun-core` 可继续作为 `product-full` 兼容接口,避免迁移期间一次性重写入口。 - CLI 已以 `DeliveryProfile::Cli` 构造真实 Runtime Parts 和 Rust Runtime SDK;本地 Agent 入口、会话、用量和 Peer Host 共用一个调用级上下文与广播事件源,审批策略不再写回全局配置。Peer Host 通过该 Rust 接口提交/精确取消 diff --git a/docs/architecture/app-server-architecture.md b/docs/architecture/app-server-architecture.md index 901902e161..bb5f9b8837 100644 --- a/docs/architecture/app-server-architecture.md +++ b/docs/architecture/app-server-architecture.md @@ -68,6 +68,7 @@ Embedded 已选择 B/C 的受限组合,A 不再是 Embedded 默认方案。Sha | Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | 保留 v17;是否迁入 Shared App Server 由可靠性、安全、性能和回滚证据决定 | | Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Embedded 时使用 direct Runtime adapter;需要连接边界时使用 App Server,Tauri 保留平台能力 | | Web Host | 当前 Server 已组装 Embedded Runtime,WebSocket 直接承载 `BitfunAppServer`;仅适用于 loopback 单用户模式 | 补齐连接身份、作用域绑定和 Host allowlist 后才能扩展部署范围 | +| CLI stdio Server Host | `bitfun server` 命令在 `src/apps/cli/src/server_host.rs` 独立装配 stdio `BitfunAppServer`;该装配点是 CLI 唯一允许依赖 App Server implementation 的位置。Host 注入 canonical cwd workspace scope、显式 method allowlist、transport limits 与 stdin EOF disconnect 信号 | 保持独立 Host 表面:stdout 只承载 JSON-RPC line 流量,frame 超限 fail closed,断连后取消在途 Turn 并确定性退出;TUI/controller/Headless CLI 不依赖 App Server | | App Server protocol/client | 已拆为 behavior-light crate,已有版本、能力、限制、错误和部分事件恢复类型 | 补齐 Host 注入能力、可靠性语义及跨 transport 合同测试 | | App Server server | 已注册 app、agent、session、permission、TUI/workspace、git、config 和 i18n handler | 按真实 owner 和 Host 装配收窄能力,不以已存在 DTO 代替可用性证据 | @@ -284,7 +285,9 @@ Tauri 继续拥有窗口、菜单、系统托盘、文件选择器、剪贴板 - Desktop Host 可在 direct Embedded 与 Shared/App Server 之间切换,但 UI 不包含部署分支; route 选择留在 Host/infrastructure。 -## 9. Web 与远程 Host +## 9. Web、stdio 与远程 Host + +CLI 的 `bitfun server` 是同一 App Server 合同的独立 stdio Server Host:stdout 只承载 JSON-RPC line 流量;canonical cwd 是唯一 workspace scope;Host 注入显式 method allowlist;`app/initialize` 返回该 Host 的实际能力与 transport limits;stdin 读取端按 advertised frame limit fail closed;stdin EOF 触发断连生命周期(取消在途 Turn 并确定性退出)。该 Host 是独立 Host surface,不是 TUI/Headless CLI 的默认路径。 WebSocket 是 App Server 的一种 transport,不是另一套业务 API。Web Host 必须使用同一 method、DTO、错误和事件合同,同时根据部署场景构造显式 capability allowlist。 diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index 968f4d8b67..fab35c825f 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -16,7 +16,7 @@ | 名称 | 唯一含义 | |---|---| -| Plugin Host | 运行 Node/Bun 与第三方 JS/TS 插件的受监督子进程;Host 不在 Rust 主应用进程内 | +| Plugin Host | 运行 Bun 与第三方 JS/TS 插件的受监督子进程;Host 不在 Rust 主应用进程内 | | `PluginRuntimeClient` | Rust 主应用内部现有调用端口;校验请求和响应,管理超时、同一插件的串行调用、重复请求结果缓存、诊断与故障隔离 | | `ScriptToolRuntime` / `NodeScriptToolRuntime` | 现有脚本执行端口及 services 实现;当前负责 standalone Tool worker,后续 Plugin Host 的物理进程职责也应沿此边界扩展 | | 插件实例 | 由来源、插件身份和当前内容版本确定的已启用插件;启停事实仍由现有来源与能力模块管理 | @@ -26,8 +26,9 @@ workspace、project、session、turn、run 和 working directory 是不同事实 决定 Plugin Host 进程数量。只有某项并发或权威状态确实要求单一实例时,负责该状态的归属模块才能把 workspace 或其他身份加入自己的状态键,并说明清理与迁移语义。 -Rust 主应用不执行第三方 JS/TS,也没有 Plugin Host 对象。它通过 `PluginRuntimeClient`、生态适配器和 services 层 -现有脚本执行实现向子进程发送类型明确的请求,并把结果交回 Tool、Config、Permission、Session、Event、TUI 等现有归属模块。 +当前产品运行路径不执行第三方 package 插件。仓库中的 Bun Host、RPC 和 OpenCode 适配代码是协议与进程隔离基础, +由 fixture/mock 验证;Desktop 与 CLI 的自动启动策略保持关闭。在 contribution 归属、执行许可和故障恢复接入既有 +Tool、Config、Permission、Session、Event、TUI 等模块之前,不得把这套基础设施视为已交付的插件执行能力。 ## 2. 职责 @@ -37,7 +38,7 @@ flowchart LR Client["PluginRuntimeClient"] Adapter["生态适配器"] Service["Process service"] - Host["Plugin Host\nNode/Bun"] + Host["Plugin Host\nBun"] Owners <--> Client Client <--> Adapter @@ -256,6 +257,7 @@ flowchart LR subgraph Current["current implementation"] Manifest["Plugin manifest"] --> Static["Static preview"] Script["Standalone .js tool"] --> Worker["Dedicated worker"] + Fixture["Protocol fixtures"] --> Foundation["Bun Host foundation"] end subgraph Planned["planned runtime"] @@ -272,6 +274,8 @@ flowchart LR 当前受管 `bitfun.plugin.json` 链路仍只有来源校验、启停记录、CLI 诊断和 custom tool 静态预览,不执行 package plugin、Hook、完整 Client 或 TUI 插件入口。与其独立的 standalone `.js` Tool 端到端能力当前由 `ScriptToolRuntime` 为每个脚本启动 Node worker;这是现有窄实现事实,不是目标 package-plugin 的进程模型。 +Bun Host 基础设施仅覆盖模块加载、RPC/HTTP 桥和进程树生命周期等隔离边界;配置插件时 CLI 会明确报告该执行链路 +尚未启用,不会静默导入或运行插件代码。 因此当前代码不得声称已经具备共享 Plugin Host、安全重启、通用进程级恢复或 Bun 兼容。目标实现应先用 固定 OpenCode fixture 验证多个插件的顺序初始化、Hook 顺序、共享进程崩溃、安全重启和状态恢复,再替换现有 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 0628f6aa7b..37e6637934 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -1,263 +1,249 @@ -# BitFun 产品运行时架构 - -本文件定义 BitFun 产品运行时的稳定架构边界。详细执行计划见 -[`../plans/core-decomposition-plan.md`](../plans/core-decomposition-plan.md);智能体内核、运行时服务和 crate -约束见 [`agent-runtime-services-design.md`](agent-runtime-services-design.md);插件运行时、Plugin Host 进程和生态适配细节见 -[`plugin-runtime-design.md`](extensions/plugin-runtime-design.md);跨 GUI/TUI 的产品定制、布局选择和 -内置扩展边界见 [`product-customization-blueprint.md`](product-customization-blueprint.md);CLI 产品入口和配置 -兼容见 [`cli-product-line-design.md`](cli-product-line-design.md);HarmonyOS PC 原生 CLI/TUI 平台规约见 -[`platform-portability-design.md`](platform-portability-design.md)。跨专题实施顺序见 -[`../plans/product-architecture-evolution-plan.md`](../plans/product-architecture-evolution-plan.md)。外部 AI 工作内容架构见 -[`external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md);OpenCode 扩展总矩阵、配置资产、插件执行、 -终端插件和外部集成适配分别见 -[`opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md)、 -[`opencode-config-assets-adapter-design.md`](extensions/opencode-config-assets-adapter-design.md)、 -[`opencode-plugin-runtime-adapter-design.md`](extensions/opencode-plugin-runtime-adapter-design.md)、 -[`opencode-tui-plugin-adapter-design.md`](extensions/opencode-tui-plugin-adapter-design.md) 和 -[`opencode-external-integration-adapter-design.md`](extensions/opencode-external-integration-adapter-design.md);BitFun 能力如何 -可装配并双向接入 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 实例共存时的 Agent Runtime 部署、 -状态共享、隔离、容量与 Plugin Host 关系见 -[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);Desktop GUI、Web UI 和交互式 TUI 的产品后端边界、 -Embedded direct-runtime、Shared App Server 及迁移约束见 -[`app-server-architecture.md`](app-server-architecture.md);GUI、Agent、搜索和说明书如何共享同一产品功能/设置 owner 见 -[`product-control-plane.md`](product-control-plane.md)。Embedded interactive TUI direct-runtime 已交付;Shared App Server -仍是待评审提案。当前调用路径和稳定 owner 边界以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 - -Cargo feature、第三方依赖 owner、测试目标和本地/CI 验证分工见 -[`rust-build-dependency-boundaries.md`](rust-build-dependency-boundaries.md)。该文档补充本架构的构建视图,不改变本文定义的运行时 owner 和分层依赖方向。 - -本文件只约束稳定边界,不记录单次 PR 进度,也不把未来可能支持的生态能力提前声明为公开接口。 +# BitFun 产品架构 -## 1. 架构目标 +本文件定义 BitFun 产品架构的稳定边界,并通过 4+1 视图分别描述逻辑职责、代码组织、运行协作、部署拓扑和关键场景。生产实现以已接线代码为事实基线;专题设计如需改变本文边界,必须同步更新本文件。提案、静态发现和未形成生产闭环的能力不得表述为已交付接口。 -BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和插件生态。架构目标是降低后端实现高频变更对稳定接口的影响,同时保持插件生态和 OpenCode-compatible 能力可以按受控路径扩展。 - -设计原则: - -1. **接口少而稳定**:每个接口边界只有一个主入口;不能因为新增生态适配或实现重构而新增平行接口。 -2. **实现不外溢**:运行时、平台服务、生态适配器、插件执行单元和传输实现只能通过稳定接口、只读视图或内部 ABI 被消费。 -3. **外部语义可变换,最终提交有归属**:OpenCode Hook 可以按其稳定语义修改输入、输出和权限决定;BitFun - 归属模块负责顺序、结构、一致性和用户/组织策略校验并提交最终状态,不能把可写 Hook 一律降级成只读候选。 -4. **OpenCode 是兼容目标,不是内部模型**:适配层尽量保持 OpenCode plugin、hook、custom tool、TUI plugin、 - Client、配置和加载顺序的外部可观察行为,但这些类型不能反向成为 BitFun 智能体、配置或界面的内部数据模型。 -5. **公开接口有预算**:新增公开 DTO、trait、模块或入口必须同时具备归属模块、真实消费方、版本策略、验证方式和删除条件。 -6. **入口形态受宿主约束**:TUI、GUI、Web、Headless CLI 和公开 SDK adapter 共享 Agent Runtime API - 用例、能力服务接口和只读视图,不共享公开语言包、传输、渲染句柄、主题键、键位模型或界面状态; - 插件界面贡献必须先声明目标入口形态,再由对应宿主适配。 -7. **产品定制先解析,运行时扩展后加载**:产品身份、能力上限和 GUI/TUI 布局选择在构建/组装期解析;用户配置和插件只能在该上限内扩展,不能反向改写产品事实。 -8. **平台差异留在入口和具体能力实现**:target 只选择 ABI,feature 只控制确实可选的依赖;共享内核不按平台 - 分叉业务语义,也不新增包含所有 OS 方法的总接口。新端口必须有当前调用方。 -9. **发现无感,生效按风险分级**:外部用户/项目来源后台发现,不阻塞产品入口;无冲突的低风险声明式内容可自动应用并提供撤销; - Command、Tool、Subagent 等可执行来源与产品本地能力或独立外部 provider 同名时必须由用户选择,且选择只在候选身份与内容版本不变时复用。现有 Skill 根继续按已发布顺序解析,并展示来源和默认覆盖项;带模式的管理界面展示应用模式开关后的实际采用项; - 可执行来源首次启用或能力扩大时形成非阻塞确认。激活后的本地 OpenCode 扩展默认按当前用户能力 - 运行;经 BitFun 能力接口的调用可细分限制,脚本直接文件/网络/进程能力只在真实操作系统或容器边界存在时可 - 粗粒度收紧,否则停用相应插件。策略降级必须与待确认、解析错误和插件故障分开显示。 -10. **开放权限不降低可靠性**:第三方 JS/TS 始终位于受监督子进程;standalone Tool 使用现有 worker,完整 package plugin - 使用 Plugin Host。目标边界具备期限、取消、流量控制、崩溃回收、错误去重和结构校验;业务等待不得被单个插件无限阻塞。 - 缺少平台硬资源限制时,内存、CPU 或进程风暴仍是明确残余风险,不能用“独立进程”宣称完全隔离。 -11. **来源发现与执行许可分离**:生态来源和加载顺序只决定候选输入,不自动授予执行权限。任何可执行来源在 - 首次激活、启动或 import 前,以及来源身份、内容版本、执行域/用户、策略上限或凭据/环境可见范围 - 变化时,由既有归属模块重新检查是否允许执行。经 BitFun 接口发起的调用仍执行调用时权限判断;脚本运行时的 - 直接文件、网络和进程副作用只能依靠真实 OS/容器边界限制。来源的首次选择由产品来源体验保存, - 不因此新增对内部准备阶段的重复激活、通用 trusted-folder 模型或独立信任服务。 -12. **一个能力核心,多种宿主适配**:Memory、Context、Workflow、Subagent、Tool 等能力只在已有 owner 中按真实 - 第二实现增量开放 Provider/策略装配;对外通过少量能力接口和宿主 adapter 暴露。MCP、Plugin、Hook、SDK 或 - Server 入口不能反向替换状态 owner、权限上限、取消树、资源硬额度、事件身份或审计,也不能被描述为一个 - 跨产品通用插件包。 -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 可以共享 Agent Runtime,一次性 Headless CLI 保留 Embedded,公开 SDK 默认使用私有 SDK Host; - 这些 Rust 部署都只通过 `PluginRuntimeClient` 和 services 归属模块管理自己的 Node/Bun Plugin Host 子进程。 - -调用路径长度只作为工程成本处理,不作为独立架构目标。允许保留承担兼容隔离、只读视图或能力选择职责的中间层;不允许为了兼容而长期暴露没有消费方的抽象接口。 - -## 2. 4+1 Architecture Views +| 专题 | 详细设计 | +|---|---| +| Agent Runtime | [运行时服务](agent-runtime-services-design.md)、[部署模型](agent-runtime-deployment-design.md)、[App Server](app-server-architecture.md) | +| 扩展体系 | [Plugin Runtime](extensions/plugin-runtime-design.md)、[能力集成](extensions/capability-runtime-integration-design.md)、[外部来源](extensions/external-ai-work-sources-design.md)、[OpenCode 兼容](extensions/opencode-extension-compatibility.md) | +| 产品交付 | [产品定制](product-customization-blueprint.md)、[CLI 产品线](cli-product-line-design.md)、[Agent SDK](agent-sdk-product-architecture.md)、[平台可移植性](platform-portability-design.md) | +| 产品控制面 | [Product Control](product-control-plane.md) | +| 架构演进 | [Core 拆分](../plans/core-decomposition-plan.md)、[演进计划](../plans/product-architecture-evolution-plan.md)、[Rust 依赖边界](rust-build-dependency-boundaries.md) | -4+1 视图分别描述系统职责、代码组织、运行协作、部署边界和关键场景,避免把逻辑模块、crate、进程和调用链混在同一张图中。分类沿用 [Kruchten 4+1](https://www3.software.ibm.com/ibmdl/pub/software/rational/web/whitepapers/2003/Pbk4p1.pdf),图的层级、动态协作和部署节点表达参考 [C4](https://c4model.com/diagrams) 以及 arc42 的 [Building Block](https://docs.arc42.org/section-5/)、[Runtime](https://docs.arc42.org/section-6/) 和 [Deployment](https://docs.arc42.org/section-7/) 视图;这些方法只提供视角和表达规则,不替代 BitFun 的真实 owner 与代码边界。 +Embedded interactive TUI direct-runtime 已交付。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Shared App Server 的跨进程共享形态仍需按生产调用路径逐项验收。当前调用路径和稳定 owner 边界以本文及已接线代码为准。 -Level 0 展示系统级主要边界和依赖方向;Level 1 再按 Level 0 的模块或范围展开。每张图必须能独立说明范围和图例,关系使用明确方向或协议,逻辑模块、crate、运行任务和部署实例不要求一一对应。Agent Runtime 的 Embedded/Shared 逻辑、开发、进程、物理和场景视图集中在 -[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md),本文件不重复其连接和性能细节。 +## 1. 架构目标 -### 2.1 Logical View · Level 0 +BitFun 面向 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 与扩展生态。架构以稳定 owner 为核心,通过受控适配支持多种产品入口和外部生态语义。 -Logical View 只表达当前系统的职责模块与依赖方向,不表达 crate 归属或进程位置。 +1. **稳定归属**:每项行为只有一个状态 owner 和主入口;适配、传输与重构不得建立平行业务路径。 +2. **最小契约**:运行时、平台服务和扩展实现只通过必要的稳定接口或只读视图被消费;新增公开抽象必须有真实调用方、版本策略和验证方式。 +3. **平台隔离**:产品逻辑保持平台无关,操作系统差异留在宿主入口和具体能力实现;target 选择 ABI,feature 只选择真实可选能力。 +4. **语义转换**:OpenCode 等生态是兼容目标而非内部模型;adapter 保留外部可观察语义,再映射到 BitFun owner,由 owner 校验并提交最终状态。 +5. **先装配后扩展**:产品身份、能力上限和入口布局在构建或组装期确定;用户配置、Hook 和 Plugin 只能在该上限内扩展。 +6. **发现执行分离**:发现与加载顺序只产生候选输入,不授予执行许可;可执行来源在激活、身份或能力范围变化时重新授权,调用时仍执行权限判断。 +7. **受监督执行**:第三方代码运行在受监督子进程并具备期限、取消、流控和故障回收;没有 OS 或容器硬边界时,不宣称完全隔离。 +8. **单一 Runtime**:GUI、TUI、CLI、ACP、Server、Remote 与 SDK 通过各自 adapter 使用同一 Agent Runtime 行为;共享能力事实,不共享界面、传输或宿主状态。 -```mermaid -%%{init: {"theme":"base","flowchart":{"curve":"basis","nodeSpacing":28,"rankSpacing":34},"themeVariables":{"fontFamily":"Inter, ui-sans-serif, system-ui","primaryColor":"#ffffff","primaryTextColor":"#171717","primaryBorderColor":"#737373","lineColor":"#525252","secondaryColor":"#fafafa","tertiaryColor":"#ffffff","clusterBkg":"#ffffff","clusterBorder":"#a3a3a3"}}}%% -flowchart TB - subgraph Consumers["Consumers"] - direction LR - Users["Users"] ~~~ APIClients["API Clients"] - end +调用路径长度是工程成本而非独立目标。兼容隔离、能力选择和只读视图可以保留必要中间层,但不得长期维持无消费方的抽象。 - subgraph BitFun["BitFun"] - direction TB +## 2. 4+1 架构视图 - subgraph Product["Product"] - direction LR - ProductHosts["Product Hosts"] +4+1 视图分别描述系统职责、代码组织、运行协作、部署边界和关键场景,避免把逻辑模块、crate、进程和调用链混在同一张图中。分类沿用 [Kruchten 4+1](https://www3.software.ibm.com/ibmdl/pub/software/rational/web/whitepapers/2003/Pbk4p1.pdf),图的层级、动态协作和部署节点表达参考 [C4](https://c4model.com/diagrams) 以及 arc42 的 [Building Block](https://docs.arc42.org/section-5/)、[Runtime](https://docs.arc42.org/section-6/) 和 [Deployment](https://docs.arc42.org/section-7/) 视图;这些方法只提供视角和表达规则,不替代 BitFun 的真实 owner 与代码边界。 - subgraph RuntimeAssembly["Runtime Assembly"] - direction LR - CapabilityPlan["Capability Plan"] ~~~ RuntimeWiring["Runtime Wiring"] - end +Level 0 展示系统级主要边界和依赖方向;Level 1 再按 Level 0 的模块或范围展开。每张图必须能独立说明范围和图例,关系使用明确方向或协议,逻辑模块、crate、运行任务和部署实例不要求一一对应。Agent Runtime 的 Embedded/Shared 逻辑、开发、进程、物理和场景视图集中在 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md),本文件不重复其连接和性能细节。 - ProductFeatures["Product Features"] - end +### 2.1 Logical View · Level 0 - RuntimeAPI["Runtime API"] +Logical View 面向产品、领域和架构设计者,表达系统为用户提供能力所需要的稳定职责、职责分解及其主要依赖。 +它不表达 crate、contract、接口签名、进程部署或运行步骤;这些信息分别属于 Development、Process 和 Physical View。 +Level 0 只保留具有独立职责、生命周期或策略边界的模块;成熟度变化不改变模块的位置和依赖。 - subgraph AgentCore["Agent Core"] - direction LR +实线箭头表示区域级依赖,不表示模块调用链。状态:**绿色实框** = Complete;**黄色实框** = Partial;**灰色虚框** = Planned。 + +```mermaid +%%{init: {"theme":"base","block":{"padding":8}}}%% +block-beta + columns 5 - subgraph AgentKernel["Agent Kernel"] - direction LR - SessionState["Session State"] ~~~ TaskControl["Task Control"] - end + block:Application:5 + columns 5 + ApplicationTitle["Application"] Workspace["Workspace"] Conversation["Conversation"] Task["Task"] Artifact["Artifact"] + end - subgraph Execution["Execution"] - direction LR - AgentLoop["Agent Loop"] ~~~ ToolRuntime["Tool Runtime"] - end + block:MainColumn:4 + columns 1 + block:AgentCore + columns 5 + AgentCoreTitle["Agent Core"] AgentLoop["Agent Loop"] Session["Session"] Scheduling["Scheduling"] Context["Context"] + Memory["Memory"] ModelRouting["Model
Routing"] HumanInteraction["Human
Interaction"] DFX["DFX"] space end - - subgraph Extensions["Extensions"] - direction LR - Contributions["Contributions"] ~~~ PluginRuntime["Plugin Runtime"] + block:ToolExecution + columns 4 + ToolExecutionTitle["Tools & Execution"] BuiltInTools["Built-in
Tools"] ToolProtocols["Tool
Protocols"] ToolExecutionRuntime["Tool
Execution"] + ExecutionPolicy["Execution
Policy"] Sandbox["Sandbox"] Terminal["Terminal"] ComputerUse["Computer
Use"] end - - ServicePorts["Service Ports"] - - subgraph SharedBoundary["Shared Boundary"] - direction LR - StableContracts["Stable Contracts"] - SecurityControl["Security Control"] - PlatformServices["Platform Services"] - StableContracts ~~~ SecurityControl ~~~ PlatformServices + block:CrossPlatform + columns 5 + CrossPlatformTitle["Cross-platform"] Windows["Windows"] MacOS["macOS"] Linux["Linux"] OpenHarmony["OpenHarmony"] end end - PluginEcosystems["Plugin Ecosystems"] - - subgraph PlatformSystems["Platform Resources"] - direction LR - AIProviders["AI Providers"] ~~~ OS["OS"] ~~~ RemoteSystems["Remote Systems"] + block:Extensions + columns 1 + ExtensionsTitle["Extension
Dimension"] + ProductCustomization["Product
Customization"] + CustomAgents["Custom Agents"] + Skills["Skills"] + Hooks["Hooks"] + ToolExtensions["Tool
Extensions"] end - Consumers --> ProductHosts - ProductHosts --> ProductFeatures - ProductHosts --> RuntimeAPI - ProductFeatures --> RuntimeAPI - RuntimeAssembly -.-> ProductHosts - RuntimeAssembly -.-> ProductFeatures - RuntimeAssembly -.-> AgentKernel - RuntimeAssembly -.-> Extensions - RuntimeAPI --> AgentKernel - AgentKernel --> Execution - Execution --> ServicePorts - Extensions ==> Execution - Extensions ==> ServicePorts - ServicePorts --> SharedBoundary - Extensions ==> PluginEcosystems - SharedBoundary --> PlatformSystems - - classDef module fill:#ffffff,stroke:#737373,stroke-width:1.3px,color:#171717; - classDef interface fill:#fafafa,stroke:#404040,stroke-width:1.6px,color:#171717; - class Users,APIClients,ProductHosts,CapabilityPlan,RuntimeWiring,ProductFeatures,SessionState,TaskControl,AgentLoop,ToolRuntime,Contributions,PluginRuntime,StableContracts,SecurityControl,PlatformServices,PluginEcosystems,AIProviders,OS,RemoteSystems module; - class RuntimeAPI,ServicePorts interface; - - style BitFun fill:#ffffff,stroke:#171717,stroke-width:2.2px; - style Product fill:#fafafa,stroke:#737373,stroke-width:1.3px; - style AgentCore fill:#fafafa,stroke:#737373,stroke-width:1.3px; - style SharedBoundary fill:#fafafa,stroke:#737373,stroke-width:1.3px; - style RuntimeAssembly fill:#ffffff,stroke:#a3a3a3; - style AgentKernel fill:#ffffff,stroke:#a3a3a3; - style Execution fill:#ffffff,stroke:#a3a3a3; - style Extensions fill:#ffffff,stroke:#737373,stroke-width:1.3px; - style Consumers fill:#ffffff,stroke:#a3a3a3; - style PlatformSystems fill:#ffffff,stroke:#a3a3a3; + Application --> AgentCore + Application --> Extensions + AgentCore --> ToolExecution + ToolExecution --> CrossPlatform + Extensions --> CrossPlatform + + classDef complete fill:#eaf8ef,stroke:#238636,stroke-width:1.5px,color:#123a1c + classDef partial fill:#fff4ce,stroke:#9a6700,stroke-width:1.5px,color:#4d3500 + classDef planned fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,stroke-dasharray:6 4,color:#334155 + classDef sectionTitle fill:transparent,stroke:transparent,color:#171717,font-size:12px,font-weight:600 + + class Workspace,Conversation,Task,AgentLoop,Session,Scheduling,ModelRouting,BuiltInTools,ToolExecutionRuntime,ExecutionPolicy,Terminal,Windows,MacOS complete + class Artifact,Context,Memory,HumanInteraction,DFX,ToolProtocols,ComputerUse,Linux,ProductCustomization,CustomAgents,Skills,Hooks,ToolExtensions partial + class Sandbox,OpenHarmony planned + class ApplicationTitle,AgentCoreTitle,ToolExecutionTitle,CrossPlatformTitle,ExtensionsTitle sectionTitle + + style Application fill:#f8fafc,stroke:#334155,stroke-width:2px + style AgentCore fill:#f8fafc,stroke:#334155,stroke-width:2px + style ToolExecution fill:#f8fafc,stroke:#334155,stroke-width:2px + style CrossPlatform fill:#f8fafc,stroke:#334155,stroke-width:2px + style Extensions fill:#faf8ff,stroke:#7c3aed,stroke-width:2px + style MainColumn fill:transparent,stroke:transparent ``` -实线表示依赖,虚线表示装配,粗线表示扩展路径。 +| Area | Elements | Responsibility | +|---|---|---| +| Application | Workspace、Conversation、Task、Artifact | 用户工作范围、交互历史、工作意图与交付结果 | +| Agent Core | Agent Loop、Session、Scheduling、Context、Memory、Model Routing、Human Interaction、DFX | 推理循环、运行状态、任务编排、模型决策、人机协同与可观测事实 | +| Tools & Execution | Built-in Tools、Tool Protocols、Tool Execution、Execution Policy、Sandbox、Terminal、Computer Use | 工具接入、策略决策和受控执行 | +| Cross-platform | Windows、macOS、Linux、OpenHarmony | 隔离操作系统差异,提供上层所需的平台能力 | +| Extension Dimension | Product Customization、Custom Agents、Skills、Hooks、Tool Extensions | 作为正交维度扩展 Application、Agent Core 和 Tools & Execution,不改变原业务 owner | -| Boundary | Responsibility | -|---|---| -| Product | 承载产品入口、能力选择和用户功能 | -| Runtime API | 向产品入口提供稳定用例接口 | -| Agent Core | 管理会话与任务,并推进 Agent 和工具执行 | -| Extensions | 接收生态贡献并隔离插件执行 | -| Service Ports | 隔离执行逻辑与具体平台能力 | -| Shared Boundary | 统一稳定契约、安全控制和平台服务 | +Application → Extension Dimension 表示产品入口消费受控扩展;Extension Dimension → Cross-platform 表示扩展的发现与执行受平台能力约束。两项依赖均不改变 Agent Core → Tools & Execution → Cross-platform 的主分层关系。 + +Application 表达产品领域对象;Conversation 与 Task 分别区别于运行时的 Session 与 Scheduling。Code Agent、Deep Review、Deep Research 属于由多个逻辑职责组合而成的场景;Mini Apps 与 Canvas 是 Artifact 的呈现机制;Desktop、CLI、Web、Mobile 是产品交付形态。`Agent Runtime`、contract、port、adapter 和外部系统不构成 Level 0 逻辑模块。DFX 在本视图中表示诊断、Tracing、指标、审计和运行质量反馈,不包含测试工程或开发流程。 + +当前生产代码与验证证据支持以下成熟度判定: + +| Area | Complete | Partial | Planned | +|---|---|---|---| +| Application | Workspace、Conversation、Task | Artifact | — | +| Agent Core | Agent Loop、Session、Scheduling、Model Routing | Context、Memory、Human Interaction、DFX | — | +| Tools & Execution | Built-in Tools、Tool Execution、Execution Policy、Terminal | Tool Protocols、Computer Use | Sandbox | +| Cross-platform | Windows、macOS | Linux | OpenHarmony | +| Extension Dimension | — | Product Customization、Custom Agents、Skills、Hooks、Tool Extensions | — | -稳定边界:Product Hosts 只经过 Runtime API 和只读投影消费能力,不能直接调用插件执行单元或具体平台实现;Extensions 只能提交受控贡献,最终状态、权限结果、工具结果和审计事实仍由对应 owner 提交。`PluginRuntimeClient` 持有类型化调用、期限、串行化和响应校验;物理进程健康与进程树回收属于 Services。 +Complete 要求职责在生产入口形成完整闭环;Partial 表示已进入生产路径但仍有关键缺口;Planned 表示职责已确定但尚未形成生产闭环。BitFun 的判定依据是生产 owner、实际入口和已知限制,具体证据见 [Agent Runtime 服务边界](agent-runtime-services-design.md)、[Agent Runtime 部署边界](agent-runtime-deployment-design.md)、[Agent Hooks](../features/agent-hooks.zh-CN.md)、[Plugin Runtime](extensions/plugin-runtime-design.md)、[产品定制边界](product-customization-blueprint.md)、[平台可移植性](platform-portability-design.md) 与 [OpenCode 兼容边界](extensions/opencode-extension-compatibility.md)。[Codex Sandbox](https://github.com/openai/codex/blob/main/codex-rs/README.md#experimenting-with-the-codex-sandbox)、[Claude Code Sandboxing](https://code.claude.com/docs/en/sandboxing)、[Claude Code Monitoring](https://code.claude.com/docs/en/monitoring-usage) 和 [OpenCode Plugins](https://opencode.ai/v2/docs/build/plugins) 仅用于能力边界对照,不作为 BitFun 的交付证据;设计、静态发现、空 port 或单入口演示不提升成熟度。 ### 2.2 Development View · Level 0 -Development View 展示仓库的静态代码组织。层间依赖只允许向下,可跨过中间层,但不能反向依赖上层;图中子项表示主要 crate 家族或产品入口,不等同于 Logical View 的职责模块。 +Development View 展示仓库的静态代码组织。层间依赖只允许向下,可跨过中间层,但不能反向依赖上层。图中子项表示代码家族,当前 workspace 的完整模块库存见下表。Contract 与 port 是支撑多个逻辑职责的静态代码边界,仅在 Development View 中表达。 ```mermaid -flowchart TB - subgraph AppsLayer[" "] - direction LR - AppsTitle["1 · Apps & Interfaces"] ~~~ Desktop["Desktop"] ~~~ CLI["CLI"] ~~~ Server["Server"] ~~~ Relay["Relay"] ~~~ WebUI["Web UI"] ~~~ MobileUI["Mobile UI"] ~~~ ACP["ACP"] ~~~ SDKHost["SDK Host"] +%%{init: {"theme":"base","block":{"padding":8}}}%% +block-beta + columns 1 + + block:AppsLayer + columns 5 + AppsTitle["1 · Apps & Interfaces"] ProductApps["Product Apps"] WebUI["Web UI"] MobileUI["Mobile UI"] Interfaces["Interfaces"] end - subgraph AssemblyLayer[" "] - direction LR - AssemblyTitle["2 · Assembly"] ~~~ AgentContent["Built-in Agent Content"] ~~~ CoreAssembly["Core Assembly"] ~~~ ExternalSources["External Sources"] ~~~ ProductCaps["Product Capabilities"] + block:AssemblyLayer + columns 5 + AssemblyTitle["2 · Assembly"] AgentContent["Built-in
Agent Content"] CoreAssembly["Core
Assembly"] ExternalSources["External
Sources"] ProductCaps["Product
Capabilities"] end - subgraph AdaptersLayer[" "] - direction LR - AdaptersTitle["3 · Adapters"] ~~~ RuntimeIPC["Runtime IPC"] ~~~ ModelAdapters["Model Adapters"] ~~~ SourceAdapters["Source Adapters"] ~~~ Transport["Transport"] ~~~ WebDriver["WebDriver"] + block:AdaptersLayer + columns 7 + AdaptersTitle["3 · Adapters"] RuntimeIPC["Runtime
IPC"] AIAdapters["AI
Adapters"] SourceAdapters["Source
Adapters"] HookSupport["Hook
Support"] Transport["Transport"] WebDriver["WebDriver"] end - subgraph ServicesLayer[" "] - direction LR - ServicesTitle["4 · Services"] ~~~ CoreServices["Core Services"] ~~~ Integrations["Integrations"] ~~~ MiniAppMarket["MiniApp Market"] ~~~ RelayService["Relay Service"] ~~~ Terminal["Terminal"] ~~~ PageRuntime["Page Runtime"] + block:ServicesLayer + columns 6 + ServicesTitle["4 · Services"] CoreServices["Core
Services"] Integrations["Integrations"] RelayService["Relay
Service"] PageRuntime["Page
Runtime"] Terminal["Terminal"] end - subgraph ExecutionLayer[" "] - direction LR - ExecutionTitle["5 · Execution"] ~~~ AgentRuntime["Agent Runtime"] ~~~ AgentStream["Agent Stream"] ~~~ ToolRuntime["Tool Runtime"] ~~~ PluginClient["Plugin Client"] ~~~ Harness["Harness"] ~~~ RuntimeServices["Runtime Services"] + block:ExecutionLayer + columns 11 + ExecutionTitle["5 · Execution"] AgentRuntime["Agent
Runtime"] AgentWorkflows["Agent
Workflows"] AgentStream["Agent
Stream"] Harness["Harness"] PluginClient["Plugin
Client"] RuntimeServices["Runtime
Services"] ToolContracts["Tool
Contracts"] ToolGroups["Tool
Groups"] ToolExecution["Tool
Execution"] JSONRepair["JSON
Repair"] end - subgraph ContractsLayer[" "] - direction LR - ContractsTitle["6 · Contracts"] ~~~ CoreTypes["Core Types"] ~~~ Events["Events"] ~~~ RuntimePorts["Runtime Ports"] ~~~ ProductDomains["Product Domains"] + block:ContractsLayer + columns 5 + ContractsTitle["6 · Contracts"] CoreTypes["Core Types"] Events["Events"] RuntimePorts["Runtime
Ports"] ProductDomains["Product
Domains"] end - AppsTitle --> AssemblyTitle --> AdaptersTitle --> ServicesTitle --> ExecutionTitle --> ContractsTitle - - classDef header fill:#fafafa,stroke:#404040,stroke-width:1.6px,color:#171717; - classDef module fill:#ffffff,stroke:#737373,stroke-width:1.3px,color:#171717; - class AppsTitle,AssemblyTitle,AdaptersTitle,ServicesTitle,ExecutionTitle,ContractsTitle header; - class Desktop,CLI,Server,Relay,WebUI,MobileUI,ACP,SDKHost,AgentContent,CoreAssembly,ExternalSources,ProductCaps,RuntimeIPC,ModelAdapters,SourceAdapters,Transport,WebDriver,CoreServices,Integrations,MiniAppMarket,RelayService,Terminal,PageRuntime,AgentRuntime,AgentStream,ToolRuntime,PluginClient,Harness,RuntimeServices,CoreTypes,Events,RuntimePorts,ProductDomains module; - style AppsLayer fill:#ffffff,stroke:#a3a3a3; - style AssemblyLayer fill:#ffffff,stroke:#a3a3a3; - style AdaptersLayer fill:#ffffff,stroke:#a3a3a3; - style ServicesLayer fill:#ffffff,stroke:#a3a3a3; - style ExecutionLayer fill:#ffffff,stroke:#a3a3a3; - style ContractsLayer fill:#ffffff,stroke:#a3a3a3; + AppsLayer --> AssemblyLayer + AssemblyLayer --> AdaptersLayer + AdaptersLayer --> ServicesLayer + ServicesLayer --> ExecutionLayer + ExecutionLayer --> ContractsLayer + + classDef module fill:#ffffff,stroke:#737373,stroke-width:1.3px,color:#171717 + classDef sectionTitle fill:transparent,stroke:transparent,color:#171717,font-size:12px,font-weight:600 + class ProductApps,WebUI,MobileUI,Interfaces,AgentContent,CoreAssembly,ExternalSources,ProductCaps,RuntimeIPC,AIAdapters,SourceAdapters,HookSupport,Transport,WebDriver,CoreServices,Integrations,RelayService,PageRuntime,Terminal,AgentRuntime,AgentWorkflows,AgentStream,Harness,PluginClient,RuntimeServices,ToolContracts,ToolGroups,ToolExecution,JSONRepair,CoreTypes,Events,RuntimePorts,ProductDomains module + class AppsTitle,AssemblyTitle,AdaptersTitle,ServicesTitle,ExecutionTitle,ContractsTitle sectionTitle + + style AppsLayer fill:#f8fafc,stroke:#334155,stroke-width:2px + style AssemblyLayer fill:#f8fafc,stroke:#334155,stroke-width:2px + style AdaptersLayer fill:#f8fafc,stroke:#334155,stroke-width:2px + style ServicesLayer fill:#f8fafc,stroke:#334155,stroke-width:2px + style ExecutionLayer fill:#f8fafc,stroke:#334155,stroke-width:2px + style ContractsLayer fill:#f8fafc,stroke:#334155,stroke-width:2px ``` -箭头表示允许的依赖方向;实际 crate 可以直接依赖任意更低层。Logical 与 Development 的主要映射如下,映射是多对多关系: +箭头表示允许的依赖方向;实际 crate 可以直接依赖任意更低层。当前 Cargo metadata、pnpm workspace 与非 Rust 产品入口核验后的完整库存如下: -| Development layer | Repository scope | Logical elements | +| Development area | Repository scope | Current modules | |---|---|---| -| Apps & Interfaces | `src/apps/*`、Web/Mobile UI、`interfaces/*` | Product Hosts、Product Features、Runtime API | -| Assembly | `assembly/*` | Runtime Assembly、Product Features、Agent Kernel | -| Adapters | `adapters/*` | Extensions、Platform Services | -| Services | `services/*` | Agent Kernel、Security Control、Platform Services | -| Execution | `execution/*` | Agent Core、Extensions、Service Ports | -| Contracts | `contracts/*` | Stable Contracts、Security Control、Service Ports | +| Apps | `src/apps/*` | `desktop`、`cli`、`server`、`relay-server`、`sdk-host`、`miniapp-market-server`、`skin-market-server` | +| Web and delivery | product roots | `src/web-ui`、`src/mobile-web`、`src/miniapp-market-web`、`src/skin-market-web`、`src/apps/mobile`、`BitFun-Installer`、`tests/e2e` | +| SDK | `sdk/*` | `typescript` | +| Shared frontend | `src/shared` | `shared` | +| Interfaces | `src/crates/interfaces/*` | `acp`、`app-server`、`app-server-client`、`app-server-protocol`、`sdk-host` | +| Assembly | `src/crates/assembly/*` | `agent-content`、`core`、`external-sources`、`product-capabilities` | +| Adapters | `src/crates/adapters/*` | `agent-runtime-ipc`、`ai-adapters`、`claude-code-adapter`、`codex-adapter`、`dsh-adapter`、`opencode-adapter`、`static-hook-support`、`transport`、`webdriver` | +| Services | `src/crates/services/*` | `services-core`、`services-integrations`、`miniapp-market-service`、`skin-market-service`、`relay-service`、`page-function-runtime`、`terminal` | +| Execution | `src/crates/execution/*` | `agent-runtime`、`agent-workflows`、`agent-stream`、`harness`、`plugin-runtime-client`、`runtime-services`、`tool-contracts`、`tool-provider-groups`、`tool-execution`、`tool-call-jsonrepair` | +| Contracts | `src/crates/contracts/*` | `core-types`、`events`、`runtime-ports`、`product-domains` | + +Installer、E2E 以及 MiniApp/Skin market server 和对应 service 在 Level 0 图中分别归入交付入口、测试范围或 Services 家族,不作为独立架构模块。 +Logical 与 Development 的主要映射如下,映射是多对多关系: + +| Development area | Logical coverage | +|---|---| +| Apps & Interfaces | Application、Cross-platform 的宿主入口,Extensions 的用户控制面,以及 Desktop Computer Use 的平台实现 | +| Assembly | Application、Agent Core、Tools & Execution 和 Extensions 的能力选择、产品编排与装配 | +| Adapters | Tool Protocols 和外部生态接入所需的协议转换;adapter 本身不是逻辑层 | +| Services | Tools & Execution 的具体执行支持,以及 Cross-platform 的操作系统能力实现 | +| Execution | Agent Core、Tools & Execution 的可移植原语与 Computer Use 契约,以及 Custom Agents、Tool Extensions、Hooks 的运行支持 | +| Contracts | 为多个逻辑职责提供稳定事实与 port;不构成独立逻辑模块 | Assembly 是唯一组装根,只选择下层能力和实现,不能反向依赖 app。每个生态 adapter 独立保留外部格式和顺序语义,再映射到 BitFun owner;生态 adapter 之间不能形成兄弟依赖。 +各层的静态职责按下表执行。层间只允许由上向下依赖;同层依赖必须有明确的单向 owner 关系,不能为了复用方便形成环。 + +| 层 | 负责 | 不负责 | +|---|---|---| +| Apps & Interfaces | 选择一个交付入口,提交唯一 Delivery Profile,转换 UI / CLI / ACP / SDK Host 协议 | 复制 Session、Agent loop、权限或工具执行状态 | +| Assembly | 选择能力包、Agent 清单、原子工具组和具体 provider,校验组装结果 | 执行产品工作流、持有协议连接或读取 app 状态 | +| Adapters | 把外部协议、生态格式和提供方请求转换为 BitFun contracts | 决定产品能力、写入 Runtime 权威状态 | +| Services | 实现文件系统、Git、终端、网络、MCP、远端和平台 I/O | 读取 Delivery Profile、依赖 Assembly 或产品入口 | +| Execution | 提供可移植 Runtime 语义、命名工作流策略、工具契约与执行原语 | 选择产品形态、包含 UI/协议/平台实现 | +| Contracts | 定义稳定事实、DTO、事件和 port | 持有运行时行为或依赖任何上层 | + +#### Agent Runtime 与命名工作流边界 + +`agent-runtime` 的合理范围是所有交付形态都需要的一套通用执行语义:Agent / Session / Turn 生命周期,调度、取消和恢复,Context → Model → Tool 循环,权限与人工交互,事件、用量、checkpoint,以及通用 Tool、Hook、Skill、Custom Agent 扩展接口。 + +以下内容不属于 Agent Runtime: + +- Deep Review、Deep Research 等按名称定义的产品工作流、提示词和报告策略; +- MiniApp、Canvas 等产物产品及其发布、市场或界面生命周期; +- CLI、ACP、SDK Host、Tauri、HTTP/WebSocket 等入口和协议; +- 文件系统、Git、终端、网络、MCP、远端与 AI 提供方的具体实现; +- Delivery Profile 和某个产品应该装载哪些 Agent / Tool 的选择。 + +命名工作流的无 I/O 决策归 `agent-workflows`;具体 I/O 归 Services;`product-capabilities` 只选择工作流能力、Agent ID 和原子工具组。依赖方向固定为 `Assembly → agent-workflows → agent-runtime / contracts`,`agent-runtime` 禁止反向依赖 `agent-workflows`。当前 `agent-workflows` 的 DeepResearch 报告后处理不需要 Runtime 类型,因此直接依赖基础库;这不改变上述长期方向。 + +原子逻辑模块应满足三个条件:一个明确 owner、一个可独立测试的输入输出契约、一个变化原因。`core.basic`、`core.agent`、`core.session`、`core.git`、`core.web`、`core.mcp`、`core.computer-use`、`core.review`、`core.miniapp`、`core.canvas` 分别表达实际工具职责;不得重新合成 `core.integration` 这类同时包含网络、Git、MCP、产品工具和平台能力的大组。 + +当前生产循环仍由 `assembly/core` 的 `ConversationCoordinator → Scheduler → ExecutionEngine → RoundExecutor → ToolPipeline` 持有;`agent-runtime` 已拥有可移植决策与 Rust preview facade,但尚未独立拥有完整循环。`assembly/core` 因此仍是过渡期兼容组装与实际 owner,不能被描述成只有 wiring。后续迁移必须逐条切换真实调用方、保留行为等价测试并删除旧写入方;移动 DTO、增加 feature 或 re-export 均不算 owner 迁移。当前仍位于 Runtime/Core 的 DeepReview 兼容逻辑只允许迁出和修复,不继续承接新的产品行为。 + `assembly/agent-content` 只持有随产品发布的不可变内置 Agent prompt 字节和兼容 key;选择、渲染、模式策略、 Memory/Insights 工作流与运行时状态仍由 Core 的既有 owner 持有。该 crate 不是通用 prompt registry,也不加载 用户、项目、产品定制或插件内容。 @@ -507,7 +493,7 @@ adapter 映射到稳定 owner 接口,不因该目标复用 App Server wire。 | 能力归属模块 / Agent Runtime API | 字段明确的请求和响应、状态事实、权限/取消规则、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket/ACP/SDK Host 消息结构 | | Desktop Tauri / product Host adapter | 当前组装 Tauri adapter;目标按部署组装 direct Runtime adapter 或 App Server transport、注入真实 capability 与平台 provider、管理窗口和桌面生命周期、投递 typed Runtime/App Server notification 或桌面专属事件 | 复制业务校验、持有第二份权威状态、在目标迁移完成后为同一能力保留第二条 Runtime 旁路、把 Tauri 类型传入下层 | | Server / Remote adapter | 路由鉴权、协议消息、连接生命周期、流量控制与取消转换 | 为同一能力另建业务含义不同的 DTO 或 handler | -| GUI / Web / TUI frontend | 当前依赖各自 infrastructure;Embedded/Shared TUI controller 直接组合 `CliAgentRuntimeClient` 与所需的 owner/service API,Web 或其他确需连接边界的 surface 才组合 App Server client;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime 实现或私有 Shared IPC、公开 Python/TypeScript SDK、Tauri 业务 command;创建 catch-all TUI client、surface service、owner adapter、统一 TUI management 模块,或让 CLI 依赖 App Server implementation/client | +| GUI / Web / TUI frontend | 当前依赖各自 infrastructure;Embedded/Shared TUI controller 直接组合 `CliAgentRuntimeClient` 与所需的 owner/service API,Web 或其他确需连接边界的 surface 才组合 App Server client;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime 实现或私有 Shared IPC、公开 Python/TypeScript SDK、Tauri 业务 command;创建 catch-all TUI client、surface service、owner adapter、统一 TUI management 模块,或让 CLI 在 `bitfun server` 之外依赖 App Server implementation/client(唯一经评审的例外是 `src/apps/cli/src/server_host.rs` 中的独立 stdio Server Host 装配点:它选择 `DeliveryProfile::Cli` 复用已评审的 CLI Agent 内核装配,再以 Host 注入的 allowlist/scope 收敛能力;TUI/controller/Headless CLI 仍禁止依赖 App Server) | 本文其他章节和历史设计中出现的“Runtime SDK”,如果指 `agent-runtime::sdk`,统一称为 **Rust Runtime SDK(当前 preview)**;它是共享 **Agent Runtime API** 的当前 Rust 入口。只有 @@ -789,7 +775,7 @@ flowchart LR 唯一 profile、消费组装结果和统一能力可用性,并通过入口级行为验证后,才能把该 profile 标为已接入。 - 产品入口向组装根提交唯一 Delivery Profile;组装根只校验并派生静态计划,不在内部再次选择交付形态。 - 入口必须在任何配置规范化或全局工具 registry 首次读取之前提交 Delivery Profile,避免进程级 registry 被兼容默认值提前锁定。Desktop 提交 `Desktop`;当前 loopback Server Host 仍承载完整兼容能力,因此提交 `ProductFull`,空的 `Server` profile 仍表示尚未交付的独立 Server 产品形态。 -- Agent Runtime 的最小工具计划不是 Delivery Profile。Product Assembly 单独生成 `ProductToolPlan`,显式列出工具 owner;基线只选择 `Basic` 与 `AgentControl`,完整交付计划由已提交的 Delivery Profile 派生。 +- Agent Runtime 的最小工具计划不是 Delivery Profile。Product Assembly 单独生成 `ProductToolPlan`,显式列出工具 owner;基线只选择 `core.basic`、`core.agent` 与 `core.session`,完整交付计划由已提交的 Delivery Profile 派生。 - Runtime Configuration 承载用户、项目、工作区和本次运行的可变配置;不能启用产品定义 未组装的能力,也不能放宽产品或组织策略。 - Capability Availability 是根据产品计划、服务健康和当前策略计算出的能力状态;所有入口读取同一状态, @@ -847,9 +833,9 @@ flowchart LR | 当前入口 | 已有能力 | 明确边界 | |---|---|---| | Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | -| CLI / TUI | 使用显式 Core owner closure:`agent-runtime` 基线、实际 service owner(包括 Remote Connect、DeepResearch、external/plugin source 与 SSH)以及九组 `tools-*`;`/extensions` 只提供状态、启停和刷新,`/hooks`、`/tools`、`/agent` 和 `/mcp` 处理各自能力 | `agent-runtime` 不再隐式携带完整 MCP/Remote/Browser/Web/Git/模型目录闭包;非交互不等待权限输入,生态解析仍在适配器,远程能力未接入时不回退本机 | -| ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、`agent-runtime` 基线、所需 service owner 与九组 `tools-*`,但不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理;未选择的能力不得借 Cargo feature union 偶然出现 | -| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts 和与当前本机协议能力一致的显式 Core owner closure;TLS provider 由 Host 进程入口安装 | 当前协议不暴露远程 workspace/SSH 执行,因此不选择 Remote Connect、SSH 或 Function Agent owner;未来远程 SDK 必须复用 Server/Remote 的认证和执行域,不能回退到本机执行 | +| CLI / TUI | 使用 `DeliveryProfile::Cli` 和显式 Core owner closure;Runtime 只注册 Code Agent 清单及 `basic / agent / session / git / web / mcp / computer-use` 七个原子工具组,外部来源、插件和 Remote/SSH 仍由各自 owner 按入口需要装配 | 不注册 DeepReview、DeepResearch、MiniApp 或 Canvas Agent / Tool;非交互不等待权限输入,远程能力未接入时不回退本机 | +| ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、Code Agent 清单与同一组七个原子工具组,不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;完整历史和兼容配置仍由 Core/ACP 管理;未选择的产品工作流不得借 Cargo feature union 偶然注册 | +| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts、Code Agent 清单和七个原子工具组;TLS provider 由 Host 进程入口安装;通用 Harness registry 注入和查询 API 保持兼容 | 不自动注册 DeepReview、DeepResearch、MiniApp 或 Canvas 的产品 Harness provider;当前协议也不暴露远程 workspace/SSH,未来远程 SDK 必须复用 Server/Remote 的认证和执行域 | | Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 57727b599c..2392668862 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -217,6 +217,10 @@ CI 负责 workspace 级检查、真实产品 feature 组合、跨平台、完整 ### 7.1 Hosted CI 关键路径与缓存 - 验证 job 只依赖自身的编译期前置条件。Tauri `check`/`test` 只要求配置中的前端和资源目录存在时,Rust job 自行创建空目录,不等待或传递可发布前端产物;真实静态资源仍由前端构建和产品打包 owner 负责。 +- Rust/CLI 影响分类必须保守且 fail-closed:只有活动路径全部位于 `src/web-ui/**` 时才允许跳过 Rust 与 CLI matrix;仓库根目录/`docs/**` 的 Markdown 和 `png/**` 可作为已知中性伴随路径。其他嵌套 Markdown 可能是 `include_str!`/`include_bytes!` 输入,workflow 不得宽泛忽略全部 Markdown。空变更、非法或不可用 diff range、Rust/build 输入、CI 脚本、其他产品 surface 和所有未识别路径都必须运行完整验证,不能依赖易漏维护的“已知 Rust 目录”名单。PR 使用 base/head 的 merge-base range,只分类 PR 自身改动;`main` push 使用事件 before/head 的 direct range,保留 force-push 或回退提交的实际影响。 +- “纯 Web”前提必须是可执行边界:影响分类 job 在计算 diff 前,从 Git 跟踪文件列表扫描全仓 `.rs`(包括 workspace 外 Installer 和根 build script)。除逐行精确登记的既有测试 fixture 外,非注释 Rust 源码不得出现 `web-ui` 路径 token;因此 `include_dir!`、编译嵌入、分段 `PathBuf::join` 和间接路径变量都会在分类前失败。Frontend job 同时显式运行边界 contract tests 和真实 repository boundary check;跨 surface 的命令注册和调用分别由 owner 测试看护。新增类似源码读取时应修复所有权,而不是给影响分类器追加隐式例外。 +- 可跳过的 matrix 必须汇总到一个稳定的 `Rust / CLI Validation` 结果:分类失败或输出缺失时下游按需要 Rust 处理;分类为 Web-only 时只接受 matrix 全部 skipped,分类为需要 Rust 时只接受全部 success。昂贵的 Rust/CLI 子 job 保留 `!cancelled()`,无 checkout 的轻量汇总使用 `always()` 检查上游 result,因此旧 run 被 concurrency 取消时明确失败而不是被当成 skipped success。 +- 该汇总只保证已触发 CI run 内的稳定结果;在 workflow 仍忽略 `png/**` 时不能直接宣称为全局 required check。若未来接入 branch protection,必须先保证所有受保护提交都会产生该 check,或同步调整 trigger 覆盖。 - Pull Request 可以恢复可信分支产生的 Cargo 缓存,但不得写入 merge-ref 缓存。只有可信 `main` push 可以保存共享缓存;若依赖编译已经完成而后段测试失败,允许该可信构建保存依赖缓存,避免下一次跨平台构建无谓冷启动。 - 未先修改 Cargo manifest 的 PR/main 验证 job 以仓库提交的 `Cargo.lock` 为唯一解析结果并通过 `--locked` 验证,不在 cache restore 前重新生成 lockfile。依赖解析更新必须作为可评审的源码变更提交,不能让同一 commit 因上游兼容版本发布而自然产生新的 cache key;先改写版本号的发布 job 不属于该前提。 - 缓存只承载可复用依赖产物,不为追求命中率启用 workspace crate 或 incremental artifact 缓存;缓存容量、失效粒度和可信边界优先于单次命中率。 diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index e0548bb187..ecd337a1d4 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ BitFun Playbook currently contains **22 features**, **16 settings pages**, and * - Generated runtime catalog: `src/crates/contracts/product-domains/src/generated/product-control-catalog.json` - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **651** 个 Tauri 命令,以及 **364** 个产品交互源码文件中的 **4402** 个交互候选,只用于实现覆盖审计。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **651** 个 Tauri 命令,以及 **365** 个产品交互源码文件中的 **4408** 个交互候选,只用于实现覆盖审计。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **651** Tauri commands and **4402** interaction candidates across **364** product UI source files remain implementation-audit evidence only. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **651** Tauri commands and **4408** interaction candidates across **365** product UI source files remain implementation-audit evidence only. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/ui-interaction-inventory.json b/docs/interactive-capabilities/technical/ui-interaction-inventory.json index 5392b863d5..0316a687be 100644 --- a/docs/interactive-capabilities/technical/ui-interaction-inventory.json +++ b/docs/interactive-capabilities/technical/ui-interaction-inventory.json @@ -5,9 +5,9 @@ "roots": [ "src/web-ui/src" ], - "digest": "e94ff7da7ba104f8bce70a0d11c8e3137ef9ec87dfea22b40b4e1c0cc038c7fd", - "fileCount": 364, - "interactionCount": 4402, + "digest": "154fb37d1f29ab10e8f342c0de543258a8ca07584c08cfa659f4a161346eac58", + "fileCount": 365, + "interactionCount": 4408, "files": [ { "sourceFile": "src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx", @@ -779,6 +779,11 @@ "interactionCount": 91, "digest": "5921d31e205fe8c7f72bfe76744cd593fa223334685709f909b0242bd717406e" }, + { + "sourceFile": "src/web-ui/src/flow_chat/components/AcpModeSelector.tsx", + "interactionCount": 8, + "digest": "4fb25e7b3a84e494b0508cbefd3014aeac3d165e061d13548543094720a00563" + }, { "sourceFile": "src/web-ui/src/flow_chat/components/ChatInput.tsx", "interactionCount": 72, @@ -851,8 +856,8 @@ }, { "sourceFile": "src/web-ui/src/flow_chat/components/ModelSelector.tsx", - "interactionCount": 51, - "digest": "4ed5c1325e9a0badcecef22b8cefbbb24d0e9dd3e1a118840199ab3a203abf1e" + "interactionCount": 49, + "digest": "b1b2ef2ebd07ae3dbbe8425b42c51ac35871064675b2c00b3bb1cc3e65461b09" }, { "sourceFile": "src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx", diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index 23658dcf49..432dff9eb7 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -379,9 +379,37 @@ Web 实际引用的 19 个直接类型及其递归导入闭包保持生成内容 导出文件全部视为兼容面。根 `Cargo.lock` package 集合和版本不变,只从 App Server package 记录删除不再直接消费的依赖边;`.github`、CI job 和矩阵均不变。 +后续在 `6cbb62d08` 基线上继续收敛 protocol 内部闭包:默认 `rpc` feature 保持现有 +JSON-RPC trait、role 和 transport 兼容,独立的 `ts` feature 只编译 wire DTO 与 TS derive, +不再带入 `agent-client-protocol` 及其 Tokio/RMCP runtime 子图。同一 Windows 主机、两个全新 +`CARGO_TARGET_DIR`、相同 `pnpm --dir src/web-ui run gen:types` 命令的 focused A/B 如下; +package 数按 `normal,build` 边、`{p}` package identity 去重,因此不与上方三平台 +`normal,build,dev` 表混用: + +| Protocol TS focused 指标 | 变更前 | 变更后 | 收敛 | +|---|---:|---:|---:| +| unique normal/build package | 192 | 94 | -98(-51.0%) | +| 冷 `gen:types` wall-clock | 34.41 s | 26.78 s | -7.63 s(-22.2%) | + +两侧生成目录的 48 个文件逐文件 SHA-256 差异为 0;默认 RPC 编译路径另行通过 focused check。 +该 wall-clock 只代表同机单次冷样本,不外推为 CI 或完整产品构建收益。根 `Cargo.lock`、 +`.github`、CI job 和矩阵均不变。 + ### 3.4 CI 与本地验证 -- 现有 CI 已覆盖 workspace check、Core/Desktop lib、平台敏感 owner 测试和独立 runtime/CLI 验证;本轮不新增 job、矩阵或 changed-path 分类器。 +- 现有 CI 覆盖 workspace check、Core/Desktop lib、平台敏感 owner 测试和独立 runtime/CLI 验证。四次成功的纯 Web `main` 样本中,两个 CLI job 与三个 Rust Build Check job 没有消费变更,却合计占用 `58.9–68.2` runner-minutes;最长单 job 为 `17.3–22.7` 分钟。runner-minutes 为五个 job 的 `completed_at - started_at` 之和,不等于可直接相加的用户等待时间。 + +| main 样本 | CI run | Rust + CLI runner-minutes | 最长 Rust/CLI job | +|---|---:|---:|---:| +| `b3eb18b5`(2026-08-20) | 32350538939 | 62.6 | 21.8 min | +| `f43611e7`(2026-08-20) | 32348769484 | 58.9 | 17.3 min | +| `b1d7c6b9`(2026-08-17) | 32018012719 | 67.1 | 22.7 min | +| `b5ed01e3`(2026-08-17) | 31989419631 | 68.2 | 21.0 min | + +- 当前 CI 增加一个无依赖安装的影响分类 job 和一个稳定结果汇总 job,不增加平台矩阵。只有活动路径全部属于 `src/web-ui/**` 才跳过上述五个 Rust/CLI job;仓库根目录/`docs/**` Markdown 与 `png/**` 作为已知中性路径。其他嵌套 Markdown 可能被 Rust 编译期嵌入,因此 workflow 不再宽泛忽略全部 Markdown,它们与 `.github/**`、`scripts/**`、其他 Web 产品、Rust/build 输入和未知路径一样运行完整矩阵;diff 不可验证时也 fail-closed。PR 按 merge-base range 分类,`main` push 按 before/head direct range 分类。 +- Desktop 的 Rust 测试不再编译期读取 Web API 源码;Desktop 注册与 Web 调用分别在各自 owner 验证。影响分类在 diff 前扫描 Git 跟踪的全仓 Rust 源码;除精确登记的既有测试 fixture 外,任何非注释 `web-ui` 路径 token 都直接使分类失败,因此目录嵌入、分段路径和间接读取也不能绕过。Frontend job 另行执行 boundary contract tests 与真实 repository check。 +- 昂贵的 Rust/CLI 子 job 在 concurrency cancellation 时不再启动;轻量汇总始终读取上游 result,把分类失败、取消、异常 skipped 和 matrix 失败统一报告为失败。该汇总目前只是在已触发 CI run 内提供稳定结果;workflow 仍忽略 `png/**`,接入全局 required check 前必须先补齐所有受保护提交的 trigger 覆盖。 +- 引入该 CI 收敛的变更自身修改了 CI、脚本和 Rust 边界,因此当时按设计运行完整 Rust/CLI matrix。跳过后的实际 runner-minutes 与等待时间要用合入后的纯 Web PR/main run 记录;在取得样本前只能报告历史可避免成本和目标调度行为,不能把目标值表述为已实测收益。 - CI 不负责穷举所有测试;新增验证只有具备独立 owner、平台矩阵或失败归因价值时才进入既有流水线,否则由最近模块的 focused command 维护。 - 本地从 owner 文档的最小 package/target/feature 入口开始;仅名称过滤不能阻止无关 target 编译。 - CI 收敛必须先有多次 job/step 耗时、缓存状态和失败历史;`SKIPPED`、未触发或只编译未运行都不算通过证据。 @@ -394,7 +422,7 @@ Web 实际引用的 19 个直接类型及其递归导入闭包保持生成内容 | 开发循环 | mobile-web 支持输入 mtime 短路;Vite 默认使用原生文件事件;前端准备步骤已并行 | | Rust profile | release 使用 thin LTO;dev 使用 `line-tables-only` 和高 codegen-units,并保留调试逃生口 | | 可复现解析 | 根 lockfile 已提交,普通 CI 使用 `--locked`;build.rs 输出已排序 | -| CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录 | +| CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录;经 fail-closed 证明的纯 Web 变更跳过 Rust/CLI matrix,并由稳定结果 job 汇总 | | 依赖收敛 | Desktop 直接 image 版本和 Reqwest TLS 双栈已治理 | | Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 | | Core/ACP 默认与角色 | Core 默认 feature 为空;ACP 默认精确保持 client + server,Desktop client-only、CLI 双角色均由现有边界检查锁定 | @@ -405,6 +433,7 @@ Web 实际引用的 19 个直接类型及其递归导入闭包保持生成内容 | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | | External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | | Contracts/AI/Assembly 测试 | 五个 crate 从 28 个 target 收敛到 10 个;AI loopback 与纯协议、Product Domains 各 owner feature 保持独立 | +| App Server TS 闭包 | protocol 默认保持 RPC 兼容,独立 `ts` 导出不再编译 ACP/Tokio/RMCP runtime 子图;生成文件哈希不变 | | 未使用直接依赖 | 删除 CLI/Desktop/Core/MiniApp Market/Page Function 的失效直接边;保留 Syntect 实际 Oniguruma 后端,根 lock 只减 7 个 package | 内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作; @@ -417,9 +446,9 @@ Web 实际引用的 19 个直接类型及其递归导入闭包保持生成内容 | 范围 | 启动条件 | |---|---| -| CI 收敛 | 先积累多次相同 owner 的 step wall-clock、cache hit/miss 和失败历史;只有能证明收益且不会静默缩小覆盖时再独立设计 | +| CI 收敛 | 观察首个合入后的纯 Web PR/main 样本,核对五个 Rust/CLI job 均为 skipped、稳定汇总通过且 Frontend Build 正常;只有出现新的同类浪费和充分证据时才扩大范围 | | Desktop 截图后端 | 新候选同时满足三平台行为等价、区域捕获无性能回退、系统依赖可 feature-gate,且根 lock package 不增加 | -| App Server / Server | 观察 protocol 单一 schema owner 合入后的 Frontend Build 样本;没有新的生产 owner 或稳定行为收益前,不继续拆 handler 路径 | +| App Server / Server | 观察 protocol TS/RPC 闭包隔离合入后的 Frontend Build 样本;没有新的生产 owner 或稳定行为收益前,不继续拆 handler 路径 | | 其他产品入口重型 capability | 证明入口不消费该能力,具备 typed unsupported/fallback 行为,并能让一个真实重依赖子图退出 | | 重复 native/sys 库版本 | 同一 owner 能升级收敛且三平台打包/ABI 有证据;不因版本数字重复强行 patch | diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 75c50029cb..d3df4e455b 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -17,7 +17,7 @@ - 已建立 `product-full` 作为兼容入口的完整产品能力保护开关,产品入口显式启用当前兼容能力集合;它不是未来按产品形态拆分能力的唯一事实源。 - 已抽取 `bitfun-core-types`、`bitfun-events`、`bitfun-runtime-ports`、`bitfun-agent-stream` 等基础契约。 -- 已建立 `bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`tool-runtime`、`bitfun-tool-packs`、`bitfun-agent-runtime`、`bitfun-runtime-services`、`bitfun-harness`、`bitfun-product-domains`、`bitfun-product-capabilities` 等归属 crate。 +- 已建立 `bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`tool-runtime`、`bitfun-tool-packs`、`bitfun-agent-runtime`、`bitfun-agent-workflows`、`bitfun-runtime-services`、`bitfun-harness`、`bitfun-product-domains`、`bitfun-product-capabilities` 等归属 crate;命名工作流策略由最小的 `bitfun-agent-workflows` 承载,通用 Harness 注入与查询契约继续由 `bitfun-harness` 提供。 - `src/crates` 已按 `interfaces / assembly / adapters / services / execution / contracts` 六层布局整理,DeepReview path classifier、边界规则、Cargo workspace 路径和根/层级 AGENTS 已同步。 - Cargo metadata 实际解析图检查已覆盖 workspace 与独立 manifest 的 normal、build、dev 依赖及 optional/target 变体;未知 crate 层级与反向依赖会直接失败。 @@ -34,11 +34,11 @@ - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy、provider-entry materialization、materialized tool snapshot、provider identity、permission/effect filter、cancellation contract 和 stale-call guard;core 只保留 Computer Use 旧 public path re-export / compatibility shim、产品 Tool trait 适配与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy、WebFetch readable extraction / fallback / title / format facts、WebSearch Exa text result parsing,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用、Web tool network provider 调用和旧工具入口。 - `runtime-ports` / `terminal-core` / `services-integrations` 已承接 ExecCommand 会话执行端口和 concrete provider:`TerminalPort` 暴露本地命令执行、stdin 写入、会话控制和生命周期事件边界,`RemoteExecPort` 暴露远端 SSH 命令执行、bounded one-shot command、stdin、会话控制和生命周期事件边界;`TerminalRuntimePort` 复用原本地 `ExecProcessManager` 行为,`RemoteExecRuntimePort` 复用原 remote exec manager 与旧 SSH one-shot 行为,当前 desktop / CLI 产品入口和保留 server bootstrap 初始化路径通过 `CoreRuntimeServicesProvider` 构造 provider 并显式注入 `ConversationCoordinator` / 执行上下文 / `ToolRuntimeHandles`;core `ExecCommand` / `WriteStdin` / `ExecControl` 只消费端口,不再直接调用全局本地或远端进程 manager。 -- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、session state manager / event emission owner、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 +- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、session state manager / event emission owner、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。DeepResearch citation renumber 与 report post-process gate 已迁入 `agent-workflows`;SDK facade 继续支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 - `harness` 已建立 descriptor、route plan 和 legacy provider registry。 - `product-domains` 已承接 MiniApp state/workflow planning、built-in seed orchestration / host adapter contract、compile / permission adaptation、import lifecycle、AI / Agent permission、rate-limit、model/message/session/workspace/turn-text bridge rules、AI / Agent 请求计划、stream / runtime event payload、worker restart / draft key / workspace input 规则、function-agent prompt/parser/response policy 和部分 Git snapshot/fallback 逻辑。 - `bitfun-core` 的 function-agent AI concrete acquisition 已从旧 `runtime_services` 路径收拢到明确的 core port adapter;Git / AI compatibility re-export 仍保留旧 public path。 -- 产品组装已承接 `DeliveryProfile`、当前交付形态入口矩阵、`CapabilitySet`、feature group matrix、profile-scoped capability plan、product-full provider plan、service availability report、profile-scoped harness registry 入口与 legacy-route 行为保护,以及 `ProductAssembler` 对 explicit profile input、runtime services、harness registry 和 service requirement 的验证;core 只保留兼容 re-export。ProductFull / Desktop / CLI / ACP 保留完整能力;Server / Remote / Web / MobileWeb 不再 materialize product-full capability packs、feature groups、runtime services、tool groups 或 harness routes。 +- 产品组装已承接 `DeliveryProfile`、当前交付形态入口矩阵、`CapabilitySet`、feature group matrix、profile-scoped capability plan、product-full provider plan、service availability report、profile-scoped harness registry 入口与 legacy-route 行为保护,以及 `ProductAssembler` 对 explicit profile input、runtime services、harness registry 和 service requirement 的验证;core 只保留兼容 re-export。ProductFull / Desktop 保留完整能力;CLI / ACP / SDK 只注册 CodeAgent 清单和七个原子工具组,内置 Harness registry 为空但 SDK 显式注入接口保留;Server / Remote / Web / MobileWeb 不再 materialize product-full capability packs、feature groups、runtime services、tool groups 或 harness routes。 - 插件运行时边界基础已建立:`runtime-ports` 持有 `PluginRuntimeClient`、binding、availability、插件调用与响应 DTO、disabled stub 和 `ProjectionOnlyPluginRuntimeClient`;产品组装输出扩展可用性事实与插件运行时绑定,并通过 Agent Runtime 内部 builder 注入该 binding;Agent Runtime SDK 接口不导出原始插件运行时 client 契约。默认产品启动不运行 JS/TS、worker 或子进程。 - OpenCode-compatible P0-C.1/P0-C.2 已建立受管包发现、完整性校验、工作区来源审核、精确内容哈希激活、CLI 管理与诊断,以及按需创建 OpenCode 适配器、`PluginRuntimeClient` 和 `PluginRuntimeBinding` 的唯一生产组装点。当前组装只返回需要权限的 custom tool 静态候选,不注册工具或执行插件代码。 - 插件停用已支持按工作区和包清理缺失或损坏包的残留激活记录;停用状态在扫描前提交,后续受限发现负责结果分类,并在稳定发现同 ID 不同来源时协调旧审核记录。包暂时缺失或损坏时保留来源审核记录;同一 `idempotency_key` 沿用已保存结果;请求携带的项目、信任、策略或工具目录版本过期时会被拒绝;持久化结果不确定时不报告成功。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 69faf6c53c..8d955e16ff 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -25,7 +25,7 @@ | CLI / Desktop / ACP | CLI 与 ACP 已分别提交对应 `DeliveryProfile`、消费 Runtime Parts/SDK,并选择经过评审的 Core owner feature closure;Desktop 仍启用 `bitfun-core/product-full`,主交互已消费由现有 owner 构造的窄口径 SDK 接口 | 三个入口仍复用单一 Core runtime owner;完整 Desktop profile 和剩余兼容操作仍需逐项迁移 | | Server | 当前生产路由只形成 health/info/ping 基线 | 没有插件状态或独立产品组装完整流程 | | Server / Remote / Web / Mobile Web / SDK profile | 当前为空计划、未接入入口或仅有 preview 测试 | 不得据枚举值宣称产品能力已交付 | -| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v6 preview 接口和 smoke test | 发布边界仍需真实嵌入方证明 | +| Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v8 preview 接口和 smoke test,并保留通用 Harness registry 注入/查询兼容面 | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用消息结构或静态候选扩张稳定 ABI | | Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded bind、静态 fallback 和任务生命周期由 Desktop 窄宿主端口持有 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位已完成并由生命周期与边界测试保护 | | CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期及本地确定性流式模型夹具驱动的活动 turn 进程测试,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;本地模型 HTTP 403 授权拒绝、流中断后的重试失败、Linux PTY/Windows ConPTY Chat resize/取消、`exec` Ctrl+C 及 Patch I/O 失败已有分层回归,真实供应商审批交互、macOS 活动 PTY 与 OS 级终端故障注入仍需补齐 | @@ -37,7 +37,7 @@ | apps / interfaces | 选择唯一入口形态,提交 profile,转换协议或界面数据 | 成为共享运行时归属模块,复制会话/工具/权限逻辑 | | assembly | 选择能力、提供方和兼容接口,输出类型化 runtime parts | 依赖 app crate,持有平台进程/协议实现,重新解释动态配置 | | adapters / services | 协议转换、平台 I/O、可复用具体实现 | 反向依赖 assembly 或产品入口 | -| execution | Agent、Tool、Harness 和 `PluginRuntimeClient` 的可移植执行语义 | 读取交付形态,依赖 app/adapter 具体实现,或持有 Plugin Host 物理进程 | +| execution | Agent、Tool、命名工作流和 `PluginRuntimeClient` 的可移植执行语义 | 读取交付形态,依赖 app/adapter 具体实现,或持有 Plugin Host 物理进程 | | contracts | 稳定 DTO、事实和端口 | 依赖上层或持有运行时行为 | 需要同时被独立应用和嵌入式模式复用的能力,先下沉为 services/adapters owner,再由 app 与 assembly 同向消费。 @@ -58,8 +58,8 @@ assembly → apps;embedded 的 bind、静态 fallback 和任务生命周期由 CLI 是首个入口迁移对象,因为它已有独立产品诉求、显式设计和最小 CI 命令。 -当前端到端能力已经完成:入口只提交一次 `DeliveryProfile::Cli`,通过现有 `ProductAssembler` 获得计划、服务可用性、 -Harness 和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。主会话客户端的创建(包括 +当前端到端能力已经完成:入口只提交一次 `DeliveryProfile::Cli`,通过现有 `ProductAssembler` 获得计划、服务可用性 +和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。主会话客户端的创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、本地分支、用量生成、 会话模型更新、ACP 活动会话模式更新、轮次提交/取消和精确轮次结算均走 Agent Runtime SDK;Desktop 与 Peer Host 的本地工作区准备、 会话文件清单、类型化快照统计和工作区文件回滚通过不属于 SDK 的窄 owner port 复用现有 Core 快照实现;Desktop 保留既有远程空结果, @@ -111,7 +111,7 @@ Core 只为插件兼容提供已有 owner 的窄接口:真实工具、类型 - 不扩张现有通用插件调用与效果 DTO 去承载 Hook、Client 或 TUI; - 不为未来生态新增公共注册表或多用途 DTO; - 不把静态名称、`ready` 或 adapter fixture 当作工具可调用; -- 不让 SDLC Harness 定义第二套插件接口。 +- 不再引入只有描述符、没有真实执行调用方的通用工作流框架。 ## 6. 固定执行流程 diff --git a/package-lock.json b/package-lock.json index 6391fa184c..72cdeccc16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.2.18", + "version": "0.2.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.2.18", + "version": "0.2.19", "hasInstallScript": true, "dependencies": { "jszip": "^3.10.1", diff --git a/package.json b/package.json index 4fe3416893..e33c1044aa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "BitFun", "private": true, - "version": "0.2.18", + "version": "0.2.19", "type": "module", "engines": { "node": ">=22.12.0" @@ -83,6 +83,10 @@ "test:skin-market": "pnpm --dir src/skin-market-web test", "prepare:mobile-web": "node scripts/mobile-web-build.cjs", "prepare:dsh-profile": "node scripts/prepare-dsh-profile.mjs", + "plugin-host:install": "bun install --cwd src/apps/extension-host --frozen-lockfile", + "plugin-host:build": "bun run --cwd src/apps/extension-host build", + "plugin-host:prepare": "pnpm run plugin-host:install && pnpm run plugin-host:build", + "plugin-host:test": "bun test --cwd src/apps/extension-host", "frontend:build-all": "node scripts/frontend-build-all.mjs", "preview": "pnpm --dir src/web-ui preview", "desktop:dev": "node scripts/dev.cjs desktop", @@ -109,11 +113,11 @@ "installer:build:only": "pnpm --dir BitFun-Installer run installer:build:only", "installer:build:only:fast": "pnpm --dir BitFun-Installer run installer:build:only:fast", "installer:dev": "pnpm --dir BitFun-Installer run installer:dev", - "cli:dev": "node scripts/cli-product.mjs dev", - "cli:build": "node scripts/cli-product.mjs build", - "cli:install": "node scripts/install-cli.mjs", - "cli:install:unix": "bash src/apps/cli/install.sh", - "cli:install:windows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1", + "cli:dev": "pnpm run plugin-host:prepare && node scripts/cli-product.mjs dev", + "cli:build": "pnpm run plugin-host:prepare && node scripts/cli-product.mjs build", + "cli:install": "pnpm run plugin-host:prepare && node scripts/install-cli.mjs", + "cli:install:unix": "pnpm run plugin-host:prepare && bash src/apps/cli/install.sh", + "cli:install:windows": "pnpm run plugin-host:prepare && powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1", "cli:run": "cd src/apps/cli && cargo run --release --", "cli:exec": "cd src/apps/cli && cargo run -- exec", "cli:check": "cd src/apps/cli && cargo check", diff --git a/packages/dsh-acp/README.md b/packages/dsh-acp/README.md index 6e735a9429..1b74d1d650 100644 --- a/packages/dsh-acp/README.md +++ b/packages/dsh-acp/README.md @@ -28,6 +28,11 @@ the harness's own `dsh-settings-file`, `dsh-credentials-local`, and `dsh-agent-default-model` services, so switching models in dsh switches them in BitFun too. BitFun writes no DeepSeek credentials of its own. +A session also opens with a model picker of its own: the bridge publishes every +model your dsh providers advertise as the `model` session config option, +grouped by provider, starting on the default above. Picking one applies from +the next message and lasts that session — it does not rewrite your dsh default. + ## How BitFun launches it BitFun runs `dsh --profile bitfun-acp`. A dsh profile is just a directory under @@ -71,8 +76,8 @@ reopened conversation loses its history, its context, and the mode it ran under `session/load` resumes the stored session out of the harness's own persistence (`$DSH_HOME/acp-sessions///`), replays its events to the -client as `session/update` notifications, and answers with the session's mode. -Three consequences worth knowing: +client as `session/update` notifications, and answers with the session's mode +and model. Four consequences worth knowing: - **The stored mode wins over the roster default.** Which preset a session ran under is read back from its own log, so a conversation started in `minimal` @@ -80,6 +85,10 @@ Three consequences worth knowing: - **A conversation that has started comes back locked.** The mode picker shrinks to the one mode in force, because the composition is already baked into the transcript — the same rule a live session follows after its first turn. +- **The model comes back off the log too, and stays switchable.** The picker + opens on the provider/model the session's own turns were logged under, not on + whatever the dsh default has become. Unlike the mode it is never locked: + swapping which model answers the next step leaves every logged turn valid. - **A session belongs to the directory it was created in.** Loading it against another `cwd` is refused rather than answered with a session whose sandbox boundary points somewhere else. diff --git a/packages/dsh-acp/cordis.yml b/packages/dsh-acp/cordis.yml index 1232076c72..b05b7f8af9 100644 --- a/packages/dsh-acp/cordis.yml +++ b/packages/dsh-acp/cordis.yml @@ -19,7 +19,9 @@ # the harness the user already installed and configured: `$DSH_HOME/settings.yaml` # (what dsh's web Models page writes) and `$DSH_HOME/.credentials.yaml`, read # through the two provider rows below. Nothing about an account is stored in -# this repository, and BitFun never asks for a key of its own. +# this repository, and BitFun never asks for a key of its own. The bridge offers +# those same routes to the client as the `model` session config option, so an +# IDE picks per session out of the catalog dsh already holds. # User-settings document ($DSH_HOME/settings.yaml, hot-reloaded): its # `llm-deepseek:` / `llm-pi-ai:` sections override the adapter rows below @@ -104,9 +106,12 @@ # keeps its registries (tools, skills, goals, jobs, agents, loop) and hands the # model-facing plugins to ./presets. `persona` is likewise absent — each preset # carries its own through `@deepseek-ai/dsh-persona`. -# `provider`/`model` are deliberately absent: each session starts on +# `provider`/`model` are deliberately absent: each session STARTS on # `agent-default-model`'s live selection, so the model chosen in dsh is the -# model an IDE session runs. Sessions live under the harness home rather than +# model an IDE session opens on. From there the client picks per session +# through the `model` config option, over the catalog the rows above register; +# that pick lasts the session and is not written back as the dsh default. +# Sessions live under the harness home rather than # beside whatever project happens to be open — an ACP client launches this # adapter with the USER's workspace as the working directory. - id: acp-agent diff --git a/packages/dsh-acp/scripts/smoke.mjs b/packages/dsh-acp/scripts/smoke.mjs index ca374c5dd6..b5ced2a92f 100644 --- a/packages/dsh-acp/scripts/smoke.mjs +++ b/packages/dsh-acp/scripts/smoke.mjs @@ -27,7 +27,8 @@ * a directory name under `$DSH_HOME/acp-sessions//` — and `--cwd` must * name the workspace it was created in. * - * Usage: `node scripts/smoke.mjs [--mode code] [--load ] [--prompt "…"] [--reject] [--cancel-after 3000]` + * Usage: `node scripts/smoke.mjs [--mode code] [--model deepseek-official/deepseek-v4] + * [--load ] [--prompt "…"] [--reject] [--cancel-after 3000]` */ import { spawn } from 'node:child_process' @@ -45,6 +46,7 @@ const { values } = parseArgs({ prompt: { type: 'string' }, load: { type: 'string' }, mode: { type: 'string' }, + model: { type: 'string' }, profile: { type: 'string' }, cwd: { type: 'string' }, reject: { type: 'boolean' }, @@ -99,8 +101,13 @@ function describe(update) { function describeOptions(configOptions) { if (configOptions === undefined || configOptions.length === 0) return '(none)' return configOptions.map(option => { + // A select's values are either flat or grouped by provider; a picker + // renders both as one list, so this flattens the grouped form too. const values = option.type === 'select' - ? option.options.map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value)).join(' ') + ? option.options + .flatMap(entry => (entry.options === undefined ? [entry] : entry.options)) + .map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value)) + .join(' ') : String(option.currentValue) return `${option.id}(${option.category ?? '-'}): ${values}` }).join(' | ') @@ -133,7 +140,7 @@ try { : { sessionId: values.load, ...await client.loadSession({ sessionId: values.load, cwd: WORKSPACE, mcpServers: [] }) } const sessionId = session.sessionId process.stdout.write(`${values.load === undefined ? 'newSession' : 'loadSession'}: ${sessionId}\n`) - process.stdout.write(`modes: ${describeOptions(session.configOptions)}\n`) + process.stdout.write(`options: ${describeOptions(session.configOptions)}\n`) if (values.mode !== undefined) { const switched = await client.setSessionConfigOption({ @@ -144,6 +151,17 @@ try { process.stdout.write(`mode ${values.mode}: ${describeOptions(switched.configOptions)}\n`) } + // `--model provider/model` is the composer's model dropdown: unlike the mode + // it stays live for the whole session, so this can follow a prompt too. + if (values.model !== undefined) { + const switched = await client.setSessionConfigOption({ + sessionId, + configId: 'model', + value: values.model, + }) + process.stdout.write(`model ${values.model}: ${describeOptions(switched.configOptions)}\n`) + } + if (values.prompt !== undefined) { const pending = client.prompt({ sessionId, prompt: [{ type: 'text', text: values.prompt }] }) if (values['cancel-after'] !== undefined) { diff --git a/packages/dsh-acp/src/bridge.ts b/packages/dsh-acp/src/bridge.ts index d1f36d22ab..df0f368a4a 100644 --- a/packages/dsh-acp/src/bridge.ts +++ b/packages/dsh-acp/src/bridge.ts @@ -21,7 +21,9 @@ import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' import Schema from '@deepseek-ai/schemastery' -import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { + createUserMessage, errorChain, type LlmModelInfo, type LlmProviderInfo, +} from '@deepseek-ai/dsh-llm' import { AgentSideConnection, ndJsonStream, @@ -40,6 +42,7 @@ import { type PromptRequest, type PromptResponse, type SessionConfigOption, + type SessionConfigSelectGroup, type SessionConfigSelectOption, type SessionNotification, type SetSessionConfigOptionRequest, @@ -48,7 +51,9 @@ import { type Stream, type ToolCallUpdate, } from '@agentclientprotocol/sdk' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { + installModelSelection, type Agent, type ModelSelection, type ModelSelectionRef, +} from '@deepseek-ai/dsh-agent' // The roster itself is read through `ctx.get`; only the stored-preset resolver // is a value here. This import also carries the `agent-preset/selected` // session-event declaration the switch below logs and the resolver reads back. @@ -169,13 +174,26 @@ interface SessionRecord { */ presetId: string | undefined /** - * Tail of this session's preset switches. Two `session/set_config_option` - * requests must not recompose concurrently: the second one's blank-session - * check has to see the first one's result, not the state it raced past. + * Tail of this session's `session/set_config_option` requests. Two of them + * must not run concurrently: a preset switch's blank-session check has to see + * the one ahead of it, and every reply carries the WHOLE option set, so an + * out-of-order reply would re-render the picker from state that has moved on. */ - presetSwitch: Promise | undefined + configSwitch: Promise | undefined /** Whether the client has been told the mode is now fixed, so it is said once. */ presetLockPublished: boolean + /** + * This session's live model override, installed into its agent's prompt + * assembly and request routing. `current` is undefined until the client picks + * a model, which leaves the agent on the provider/model it was created with. + */ + modelSelection: ModelSelectionRef + /** + * The provider/model the agent was created with, captured at creation rather + * than re-read: the deployment default is a live setting, and the picker must + * report what THIS session runs on, not what the next one would. + */ + modelSeed: { provider?: string; model?: string } /** In-flight prompt and its captured turn number for exact settlement. */ inflight: { resolve: (reason: StopReason) => void @@ -212,6 +230,43 @@ const PRESET_CONFIG_ID = 'agent-preset' const PRESET_LOCKED = 'This conversation has already started, so its mode is fixed. ' + 'Start a new session to pick another mode.' +/** + * The session config option the model catalog is offered under. + * + * ACP 0.25 has no model state of its own — `session/set_model` and + * `SessionModelState` do not exist in this schema version — so a model picker + * IS a config option, distinguished from the mode picker only by its category. + * Clients key off `category: 'model'` first and fall back to matching the id, + * which is why the id is the bare word rather than something namespaced. + */ +const MODEL_CONFIG_ID = 'model' + +/** + * Encode one provider/model pair as a config option value. + * + * A value id is one opaque string, but a model is only identified by its pair, + * and the same model id can be served by two providers. Joining them on the + * first `/` is also what clients parse to label a value with its provider when + * they render an ungrouped list: provider routes carry no `/`, model ids may. + * @param provider - the registered provider route. + * @param model - the provider-owned model id. + * @returns the wire value. + */ +function modelValue(provider: string, model: string): string { + return `${provider}/${model}` +} + +/** + * Split a config option value back into its provider/model pair. + * @param value - a value id previously produced by {@link modelValue}. + * @returns the pair, or undefined when the value carries no separator. + */ +function parseModelValue(value: string): { provider: string; model: string } | undefined { + const cut = value.indexOf('/') + if (cut <= 0 || cut === value.length - 1) return undefined + return { provider: value.slice(0, cut), model: value.slice(cut + 1) } +} + /** * Whether a session may still change its preset. * @@ -251,6 +306,11 @@ export function apply(ctx: Context, config: AcpConfig): void { // plugin in its host composition has no modes to offer, and this bridge then // behaves exactly as it did before they existed. const presets = ctx.get('agentPresets') + // Optional for the same reason: a composition whose model routing lives + // elsewhere simply offers no model picker, and the bridge behaves as it did + // before there was one. The catalog itself is read lazily, so an adapter + // registered after this plugin still appears in the list. + const llm = ctx.get('llm') const logger = ctx.logger const sessions = new Map() const publishReasoning = config.reasoning ?? true @@ -321,6 +381,120 @@ export function apply(ctx: Context, config: AcpConfig): void { }] } + /** + * The provider/model this session is running on right now. + * + * Three sources in falling order of authority: an explicit pick, the request + * header the session's own turns were logged under, and the selection the + * agent was created with. A resumed session therefore reports what it + * actually ran on rather than what the deployment default has since become. + * @param record - the session. + * @returns the pair, or undefined when nothing pins one. + */ + const currentModel = (record: SessionRecord): { provider: string; model: string } | undefined => { + const picked = record.modelSelection.current + if (picked !== undefined) return { provider: picked.provider, model: picked.model } + const logged = record.agent.session.requestHeader()?.config + if (logged !== undefined) return { provider: logged.provider, model: logged.model } + const { provider, model } = record.modelSeed + if (provider === undefined || model === undefined) return undefined + return { provider, model } + } + + /** + * The session's model picker: the harness's model catalog as one `select` + * config option, grouped by provider, one row per model. + * + * Unlike the mode, the model is switchable for the whole life of a session — + * a mid-conversation switch changes only which model answers the next step, + * and leaves the logged history valid. The catalog is advisory (an adapter + * may accept ids it does not advertise), so the model in force is offered + * even when it is not listed: a picker whose current value is missing from + * its own options renders blank. + * + * A model reachable through two providers is listed once. The harness's own + * DeepSeek adapter and a pi-ai profile pointed at the same account are both + * routes to the same models, and a user who configured the second in dsh gets + * every model twice — same name, same vendor, nothing to choose between. The + * provider carrying the session's current model owns those rows, so the list + * stays on the route the session is actually running. + * @param record - the session. + * @returns the one option, or nothing when no model can be named. + */ + const modelOptions = async (record: SessionRecord): Promise => { + if (llm === undefined) return [] + const current = currentModel(record) + if (current === undefined) return [] + const currentValue = modelValue(current.provider, current.model) + + const listed: { provider: LlmProviderInfo; models: LlmModelInfo[] }[] = [] + for (const provider of llm.listProviders()) { + // One unreachable provider costs its own entries, not the whole picker. + const models = await llm.listModels(provider.id).catch((error: unknown) => { + logger.warn(`acp: cannot list models for "${provider.id}": ${String(error)}`) + return [] + }) + listed.push({ provider, models }) + } + + // Which provider each model id is listed under. Seeded with the session's + // own route so the model in force keeps its provider even when that + // provider's listing failed, then filled current-provider-first so a + // duplicated catalog collapses onto the route already in use. + const owner = new Map([[current.model, current.provider]]) + const byCurrentFirst = [ + ...listed.filter(entry => entry.provider.id === current.provider), + ...listed.filter(entry => entry.provider.id !== current.provider), + ] + for (const { provider, models } of byCurrentFirst) { + for (const model of models) { + if (!owner.has(model.id)) owner.set(model.id, provider.id) + } + } + + const groups: SessionConfigSelectGroup[] = [] + for (const { provider, models } of listed) { + const values: SessionConfigSelectOption[] = models + .filter(model => owner.get(model.id) === provider.id) + .map(model => ({ + value: modelValue(provider.id, model.id), + name: model.name, + ...model.description === undefined ? {} : { description: model.description }, + })) + if (provider.id === current.provider && !values.some(value => value.value === currentValue)) { + values.push({ value: currentValue, name: current.model }) + } + if (values.length === 0) continue + groups.push({ group: provider.id, name: provider.name, options: values }) + } + if (!groups.some(group => group.group === current.provider)) { + groups.push({ + group: current.provider, + name: current.provider, + options: [{ value: currentValue, name: current.model }], + }) + } + return [{ type: 'select', id: MODEL_CONFIG_ID, name: 'Model', category: 'model', currentValue, options: groups }] + } + + /** + * Everything this session lets the client configure. + * + * Always published as one set, because that is how a client consumes it: a + * `session/new` response, a `set_config_option` reply, and a + * `config_option_update` each REPLACE the picker row wholesale, so an answer + * that carried only the option that changed would drop the other one. + * @param record - the session. + * @param locked - whether the mode is already fixed; read off the session by default. + * @returns the options, in the order a composer renders them. + */ + const sessionOptions = async ( + record: SessionRecord, locked?: boolean, + ): Promise => [ + ...await presetOptions(record, locked), + ...await modelOptions(record), + ] + const settlePrompt = (record: SessionRecord, reason: StopReason): void => { const inflight = record.inflight if (inflight === undefined) return @@ -450,7 +624,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // Locked is asserted rather than read: whether this very event is // already in `session.events` is the session's business, not the // bridge's, and the answer here is known. - void presetOptions(record, true).then(configOptions => { + void sessionOptions(record, true).then(configOptions => { if (closed || configOptions.length === 0) return if (sessions.get(record.agent.session.id) !== record) return update(record, { sessionUpdate: 'config_option_update', configOptions }) @@ -623,16 +797,19 @@ export function apply(ctx: Context, config: AcpConfig): void { // default is a hot-reloaded setting, and a resumed session must rebuild // the composition its turns actually ran under. const composed = presets === undefined ? undefined : (await presets.resolve()).id + const modelSeed = agentOptions(ctx, config) + const modelSelection: ModelSelectionRef = { current: undefined, assembled: undefined } const handle = await agents.create({ sessionId, meta: { cwd: params.cwd, ...composed === undefined ? {} : { agentPreset: composed } }, - agentOptions: agentOptions(ctx, config), + agentOptions: modelSeed, // Composition belongs in `setup`, which the factory awaits before the // agent is published: a preset that fails to mount rolls the whole // creation back rather than yielding a session on the empty global // tool layer. - ...presets === undefined || composed === undefined ? {} : { - setup: async (agentCtx: Context) => void await presets.mount(agentCtx, composed), + setup: async (agentCtx: Context) => { + installModelSelection(agentCtx, modelSelection) + if (presets !== undefined && composed !== undefined) await presets.mount(agentCtx, composed) }, }) /* v8 ignore next 4 -- a real stdio close can race an in-flight create. */ @@ -647,15 +824,17 @@ export function apply(ctx: Context, config: AcpConfig): void { streamedReasoning: new Set(), published: new Map(), presetId: composed, - presetSwitch: undefined, + configSwitch: undefined, presetLockPublished: false, + modelSelection, + modelSeed, inflight: undefined, } sessions.set(sessionId, record) // Carried by the response rather than announced afterwards: a client // registers its update handler for a session only once `session/new` // has returned, so anything published before that can be dropped. - const configOptions = await presetOptions(record, false) + const configOptions = await sessionOptions(record, false) return { sessionId, ...configOptions.length === 0 ? {} : { configOptions } } }, @@ -683,7 +862,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const live = sessions.get(sessionId) if (live !== undefined) { replayHistory(live) - const configOptions = await presetOptions(live) + const configOptions = await sessionOptions(live) return configOptions.length === 0 ? {} : { configOptions } } @@ -704,11 +883,14 @@ export function apply(ctx: Context, config: AcpConfig): void { const composed = presets === undefined ? undefined : resolveSessionPreset({ header: inspected.meta, events: inspected.events }) + const modelSeed = agentOptions(ctx, config) + const modelSelection: ModelSelectionRef = { current: undefined, assembled: undefined } const handle = await agents.resume({ resumeSessionId: sessionId, - agentOptions: agentOptions(ctx, config), - ...presets === undefined || composed === undefined ? {} : { - setup: async (agentCtx: Context) => void await presets.mount(agentCtx, composed), + agentOptions: modelSeed, + setup: async (agentCtx: Context) => { + installModelSelection(agentCtx, modelSelection) + if (presets !== undefined && composed !== undefined) await presets.mount(agentCtx, composed) }, }) /* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */ @@ -723,34 +905,67 @@ export function apply(ctx: Context, config: AcpConfig): void { streamedReasoning: new Set(), published: new Map(), presetId: composed, - presetSwitch: undefined, + configSwitch: undefined, // A session with turns is already locked, and the response below says // so; only a session reopened before its first turn can still be // told, by the `turn/start` that starts it. presetLockPublished: !sessionBlank(handle.agent.session), + modelSelection, + modelSeed, inflight: undefined, } sessions.set(sessionId, record) replayHistory(record) - const configOptions = await presetOptions(record) + const configOptions = await sessionOptions(record) return configOptions.length === 0 ? {} : { configOptions } }, /** - * Choose the session's mode. + * Choose the session's mode or its model. * - * Refused once the conversation has started, for the same reason the - * picker is down to one entry by then: the turns already logged ran on the - * current preset's tools. + * A mode switch is refused once the conversation has started, for the same + * reason the picker is down to one entry by then: the turns already logged + * ran on the current preset's tools. A model switch is not — swapping which + * model answers the next step leaves every logged turn valid — so the model + * picker stays live for the whole session. */ async setSessionConfigOption( params: SetSessionConfigOptionRequest, ): Promise { assertOpen() const record = requireSession(SessionId(params.sessionId)) - if (params.configId !== PRESET_CONFIG_ID) { + if (params.configId !== PRESET_CONFIG_ID && params.configId !== MODEL_CONFIG_ID) { throw invalidParams(`unknown config option: ${params.configId}`) } + if (params.configId === MODEL_CONFIG_ID) { + if (llm === undefined) throw invalidParams('this deployment offers no models') + if (typeof params.value !== 'string') { + throw invalidParams(`"${MODEL_CONFIG_ID}" is a select option, not a boolean`) + } + const value = params.value + const pair = parseModelValue(value) + if (pair === undefined) throw invalidParams(`unknown model: ${value}`) + // Resolved rather than trusted: this both rejects a route the harness + // cannot serve — before it becomes a failing turn — and materializes + // the adapter's own defaults, so a model whose provider configures a + // reasoning effort runs at that effort instead of the previous + // model's inherited one. + const resolved = await llm.resolveCallConfig(pair).catch((error: unknown) => { + throw invalidParams(`cannot use model "${value}": ${errorChain(error)}`) + }) + const selection: ModelSelection = { + provider: resolved.provider, + model: resolved.model, + ...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort }, + } + const pick = async (): Promise => { + record.modelSelection.current = selection + return await sessionOptions(record) + } + const queuedPick = (record.configSwitch ?? Promise.resolve()).then(pick) + record.configSwitch = queuedPick.then(() => undefined, () => undefined) + return { configOptions: await queuedPick } + } if (presets === undefined) throw invalidParams('this deployment offers no modes') if (typeof params.value !== 'string') { throw invalidParams(`"${PRESET_CONFIG_ID}" is a select option, not a boolean`) @@ -764,7 +979,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } // Asking for the mode already in force is not a switch, so it is // answered even on a locked session: it changes nothing either way. - if (record.presetId === target.id) return await presetOptions(record) + if (record.presetId === target.id) return await sessionOptions(record) // Re-read inside the queue: a switch ahead of this one may have run, // and a conversation may have started, since this request arrived. if (!sessionBlank(record.agent.session)) throw invalidParams(PRESET_LOCKED) @@ -781,11 +996,11 @@ export function apply(ctx: Context, config: AcpConfig): void { // the session stays usable and the user can pick again. throw internalError(`failed to switch to "${id}": ${errorChain(error)}`) } - return await presetOptions(record) + return await sessionOptions(record) } - const queued = record.presetSwitch ?? Promise.resolve() + const queued = record.configSwitch ?? Promise.resolve() const turn = queued.then(swap) - record.presetSwitch = turn.then(() => undefined, () => undefined) + record.configSwitch = turn.then(() => undefined, () => undefined) return { configOptions: await turn } }, diff --git a/packages/dsh-acp/tests/harness.ts b/packages/dsh-acp/tests/harness.ts index 7ec8a50c8f..17fe7a7260 100644 --- a/packages/dsh-acp/tests/harness.ts +++ b/packages/dsh-acp/tests/harness.ts @@ -20,6 +20,7 @@ import { type Client, type RequestPermissionRequest, type RequestPermissionResponse, + type SessionConfigOption, type SessionNotification, type Stream, } from '@agentclientprotocol/sdk' @@ -30,23 +31,44 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as AcpPlugin from '../src/bridge.ts' import type { AcpConfig } from '../src/bridge.ts' +/** One provider route the fixture serves, and the models it advertises. */ +export interface CatalogProvider { + /** Human-readable provider name — what a grouped model picker labels the group with. */ + name: string + /** Advertised models, in the order a picker lists them. */ + models: { id: string; name: string; description?: string }[] +} + +/** + * The single-provider, single-model catalog every test gets unless it asks for + * more, which is what the fixture advertised before it could advertise + * anything else. + */ +const DEFAULT_CATALOG: Record = { + mock: { name: 'Mock', models: [{ id: 'mock', name: 'Mock' }] }, +} + /** Scripted adapter for protocol tests. */ class MockAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] private readonly script: (StreamChunk[] | 'hang')[] + private readonly catalog: Record - constructor(script: (StreamChunk[] | 'hang')[]) { + constructor(script: (StreamChunk[] | 'hang')[], catalog: Record) { super() this.script = script + this.catalog = catalog } override providerInfo(provider: string) { - if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`) - return { id: 'mock', name: 'Mock' } + const entry = this.catalog[provider] + if (entry === undefined) throw new Error(`MockAdapter: unknown provider ${provider}`) + return { id: provider, name: entry.name } } override listModels(provider: string) { - return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : []) + const entry = this.catalog[provider] + return Promise.resolve((entry?.models ?? []).map(model => ({ provider, ...model }))) } async * stream(options: GenerateOptions): AsyncIterable { @@ -147,8 +169,16 @@ export async function makeBridgeHarness(options: { * persists nothing and the bridge takes its no-archive path. */ persistenceRoot?: string + /** + * Provider routes and models the one scripted adapter serves, so a test can + * exercise a model picker with something to pick. Every route runs the same + * script, so a switch still produces a real turn. Defaults to the lone + * `mock/mock` route. + */ + catalog?: Record } = {}): Promise { - const adapter = new MockAdapter(options.script ?? []) + const catalog = options.catalog ?? DEFAULT_CATALOG + const adapter = new MockAdapter(options.script ?? [], catalog) const ctx = new Context() await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) // Before the loop, as the app composes it: the coordinator captures a @@ -157,7 +187,7 @@ export async function makeBridgeHarness(options: { await ctx.plugin(JsonlSessionPersistence, { root: options.persistenceRoot }) } const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) + ctx.llm.registerAdapter(Object.keys(catalog), adapter) if (options.presets !== undefined) { // Before the bridge: it captures the roster during `apply`, exactly as the // app's composition orders them. @@ -266,6 +296,38 @@ export async function waitForUpdates( } } +/** + * Every published config option of one semantic category. + * + * A session publishes several pickers at once and the set is replaced whole on + * every publication, so a test reads the one it means by category rather than + * by position. + * @param configOptions - a published option set, however it arrived. + * @param category - the `category` to keep. + * @returns the matching options, in publication order. + */ +export function optionsOfCategory( + configOptions: SessionConfigOption[] | null | undefined, category: string, +): SessionConfigOption[] { + return (configOptions ?? []).filter(option => option.category === category) +} + +/** + * The one option of a category, asserted to be a select so its values can be read. + * @param configOptions - a published option set. + * @param category - the `category` the option is expected under. + * @returns that option. + */ +export function selectOption( + configOptions: SessionConfigOption[] | null | undefined, category: string, +): Extract { + const [option, ...rest] = optionsOfCategory(configOptions, category) + if (rest.length > 0 || option?.type !== 'select') { + throw new Error(`expected one ${category} select option, got ${JSON.stringify(configOptions)}`) + } + return option +} + /** * Collect every session update of one kind that the client has observed. * @param harness - the fixture to read. diff --git a/packages/dsh-acp/tests/model-selection.spec.ts b/packages/dsh-acp/tests/model-selection.spec.ts new file mode 100644 index 0000000000..17d137d677 --- /dev/null +++ b/packages/dsh-acp/tests/model-selection.spec.ts @@ -0,0 +1,259 @@ +/** + * The model picker: the harness's model catalog offered as a `model` session + * config option, and the pick routed to the next model call. + * + * ACP 0.25 has no model state of its own, so a client's model dropdown IS a + * config option — a session that publishes none leaves the IDE with nothing to + * pick from, which is the failure these tests exist to keep out. The switch is + * driven the way a composer drives it, through `session/set_config_option`, and + * read back off the request the scripted adapter actually received rather than + * off anything the bridge reports about itself. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + makeBridgeHarness, optionsOfCategory, selectOption, textResponse, updatesOfKind, waitForUpdates, + type BridgeHarness, type CatalogProvider, +} from './harness.ts' + +/** Two providers, so grouping and cross-provider switching are both observable. */ +const CATALOG: Record = { + mock: { + name: 'Mock', + models: [ + { id: 'mock', name: 'Mock' }, + { id: 'mock-pro', name: 'Mock Pro', description: 'the bigger one' }, + ], + }, + alt: { name: 'Alt Cloud', models: [{ id: 'alt-1', name: 'Alt One' }] }, +} + +/** The provider/model each recorded model call was routed to, in order. */ +function routed(harness: BridgeHarness): string[] { + return harness.adapter.requests.map(request => `${request.provider}/${request.model}`) +} + +describe('model selection', () => { + let harness: BridgeHarness | undefined + let root: string | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + }) + + it('offers the catalog grouped by provider, on the model the session runs', async () => { + harness = await makeBridgeHarness({ catalog: CATALOG }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const option = selectOption(session.configOptions, 'model') + expect(option.id).toBe('model') + expect(option.currentValue).toBe('mock/mock') + // Grouped by provider and keyed by the pair, because the same model id can + // be served by two routes and a bare id would not say which one ran. + expect(option.options).toEqual([ + { + group: 'mock', + name: 'Mock', + options: [ + { value: 'mock/mock', name: 'Mock' }, + { value: 'mock/mock-pro', name: 'Mock Pro', description: 'the bigger one' }, + ], + }, + { group: 'alt', name: 'Alt Cloud', options: [{ value: 'alt/alt-1', name: 'Alt One' }] }, + ]) + }) + + it('lists a model shared by two routes once, under the route in force', async () => { + // Two provider ids serving the same catalog is a real deployment, not a + // mistake: a second route to the same vendor, mounted under its own id so + // it can carry its own key. Listed per provider, every model appeared + // twice, with only the value prefix telling the rows apart. + harness = await makeBridgeHarness({ + catalog: { + mock: { + name: 'Mock', + models: [ + { id: 'mock', name: 'Mock' }, + { id: 'mock-pro', name: 'Mock Pro', description: 'the bigger one' }, + ], + }, + mirror: { + name: 'Mirror', + models: [ + { id: 'mock', name: 'Mock' }, + { id: 'mock-pro', name: 'Mock Pro', description: 'the bigger one' }, + { id: 'mirror-only', name: 'Mirror Only' }, + ], + }, + }, + config: { provider: 'mirror', model: 'mock' }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const option = selectOption(session.configOptions, 'model') + // The session runs on `mirror`, so `mirror` owns every id it shares — the + // checked row keeps the route that actually answers, and `mock` is left + // with nothing of its own to list. + expect(option.currentValue).toBe('mirror/mock') + expect(option.options).toEqual([ + { + group: 'mirror', + name: 'Mirror', + options: [ + { value: 'mirror/mock', name: 'Mock' }, + { value: 'mirror/mock-pro', name: 'Mock Pro', description: 'the bigger one' }, + { value: 'mirror/mirror-only', name: 'Mirror Only' }, + ], + }, + ]) + }) + + it('routes the next turn to the model the client picked', async () => { + harness = await makeBridgeHarness({ + catalog: CATALOG, + script: [textResponse('before'), textResponse('after')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }] }) + + const result = await harness.client.setSessionConfigOption({ + sessionId, configId: 'model', value: 'alt/alt-1', + }) + + // Picking a model is a control, not a turn. + expect(routed(harness)).toEqual(['mock/mock']) + expect(selectOption(result.configOptions, 'model').currentValue).toBe('alt/alt-1') + + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'again' }] }) + expect(routed(harness)).toEqual(['mock/mock', 'alt/alt-1']) + }) + + it('stays switchable after the conversation has started, unlike the mode', async () => { + harness = await makeBridgeHarness({ + catalog: CATALOG, + presets: { default: 'alpha' }, + script: [textResponse('hi')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }] }) + + // The mode is fixed by now; the model is not, because swapping which model + // answers the next step leaves every logged turn valid. + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'beta', + })).rejects.toThrow(/already started/) + const result = await harness.client.setSessionConfigOption({ + sessionId, configId: 'model', value: 'mock/mock-pro', + }) + + expect(selectOption(result.configOptions, 'model').currentValue).toBe('mock/mock-pro') + // The mode rides along, still locked: every publication replaces the whole + // set, so an answer carrying only the model would drop the mode picker. + expect(selectOption(result.configOptions, 'mode').description).toMatch(/already started/) + }) + + it('keeps the picker in the update that locks the mode', async () => { + harness = await makeBridgeHarness({ + catalog: CATALOG, presets: { default: 'alpha' }, script: [textResponse('hi')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }] }) + + await waitForUpdates(harness, 'config_option_update', 1) + const [locked] = updatesOfKind(harness, 'config_option_update') + expect(selectOption(locked?.configOptions, 'model').currentValue).toBe('mock/mock') + }) + + it('refuses a model the harness cannot serve', async () => { + harness = await makeBridgeHarness({ catalog: CATALOG }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'model', value: 'ghost/anything', + })).rejects.toThrow(/cannot use model "ghost\/anything"/) + // A value with no provider half names nothing: the pair is the identity. + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'model', value: 'alt-1', + })).rejects.toThrow(/unknown model: alt-1/) + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'model', type: 'boolean', value: true, + })).rejects.toThrow(/select option, not a boolean/) + + const session = await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) + expect(selectOption(session.configOptions, 'model').currentValue).toBe('mock/mock') + }) + + it('offers the model in force even when the catalog does not list it', async () => { + // The catalog is advisory — an adapter may accept ids it never advertised — + // and a picker whose current value is missing from its own options renders + // blank, which is the same broken dropdown as having no options at all. + harness = await makeBridgeHarness({ catalog: CATALOG, config: { model: 'unlisted' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const option = selectOption(session.configOptions, 'model') + expect(option.currentValue).toBe('mock/unlisted') + expect(option.options[0]).toEqual({ + group: 'mock', + name: 'Mock', + options: [ + { value: 'mock/mock', name: 'Mock' }, + { value: 'mock/mock-pro', name: 'Mock Pro', description: 'the bigger one' }, + { value: 'mock/unlisted', name: 'unlisted' }, + ], + }) + }) + + it('reopens a cold session on the model its turns ran under', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-acp-model-')) + harness = await makeBridgeHarness({ + catalog: CATALOG, persistenceRoot: root, script: [textResponse('stored answer')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const cwd = process.cwd() + // Seeded outside the bridge and left to go cold: nothing about the session + // is in memory when the load arrives, so the picker has only the log. + const sessionId = SessionId(randomUUID()) + const handle = await harness.ctx.agents.create({ + sessionId, meta: { cwd }, agentOptions: { provider: 'alt', model: 'alt-1' }, + }) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'stored question' }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + await handle.dispose() + + const loaded = await harness.client.loadSession({ sessionId, cwd, mcpServers: [] }) + + // `mock/mock` is what a fresh session would open on; the logged header wins. + expect(selectOption(loaded.configOptions, 'model').currentValue).toBe('alt/alt-1') + }) + + it('offers no model when nothing pins one', async () => { + harness = await makeBridgeHarness({ + catalog: CATALOG, config: { provider: undefined, model: undefined }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + // Naming a current value the session does not actually run on would be a + // lie the client renders as fact, so the picker is withheld instead. + expect(optionsOfCategory(session.configOptions, 'model')).toEqual([]) + }) +}) diff --git a/packages/dsh-acp/tests/preset-modes.spec.ts b/packages/dsh-acp/tests/preset-modes.spec.ts index 9f26103090..0fe6ac7b28 100644 --- a/packages/dsh-acp/tests/preset-modes.spec.ts +++ b/packages/dsh-acp/tests/preset-modes.spec.ts @@ -11,7 +11,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { PROTOCOL_VERSION, type SessionConfigOption } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' import { - makeBridgeHarness, textResponse, updatesOfKind, waitForUpdates, type BridgeHarness, + makeBridgeHarness, optionsOfCategory, selectOption, textResponse, updatesOfKind, waitForUpdates, + type BridgeHarness, } from './harness.ts' /** The tools a session's model can call, i.e. what its preset composed. */ @@ -30,14 +31,14 @@ function selections(harness: BridgeHarness, sessionId: string): unknown[] { .map(event => (event as { data: { agentPreset: string } }).data.agentPreset) } -/** The one mode option, asserted to be a select so its values can be read. */ +/** + * The one mode option. A session publishes a model picker alongside it, so the + * mode is read by category rather than by being the only thing on the wire. + */ function modeOption(configOptions: SessionConfigOption[] | null | undefined): Extract< SessionConfigOption, { type: 'select' } > { - const [option, ...rest] = configOptions ?? [] - expect(rest).toEqual([]) - if (option?.type !== 'select') throw new Error(`expected one select option, got ${JSON.stringify(configOptions)}`) - return option + return selectOption(configOptions, 'mode') } /** The values a client's picker would list, in roster order. */ @@ -154,12 +155,13 @@ describe('agent-preset modes', () => { expect(updatesOfKind(harness, 'config_option_update')).toHaveLength(1) }) - it('offers nothing when the deployment has no roster', async () => { + it('offers no mode when the deployment has no roster', async () => { harness = await makeBridgeHarness({ script: [textResponse('ok')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(session.configOptions ?? []).toEqual([]) + // The model picker is orthogonal and still published; only the mode is gone. + expect(optionsOfCategory(session.configOptions, 'mode')).toEqual([]) await expect(harness.client.setSessionConfigOption({ sessionId: session.sessionId, configId: 'agent-preset', value: 'beta', })).rejects.toThrow(/offers no modes/) diff --git a/packages/dsh-acp/tests/session-load.spec.ts b/packages/dsh-acp/tests/session-load.spec.ts index 5e390fea3f..e7d0d247a1 100644 --- a/packages/dsh-acp/tests/session-load.spec.ts +++ b/packages/dsh-acp/tests/session-load.spec.ts @@ -18,17 +18,17 @@ import { PROTOCOL_VERSION, RequestError, type SessionConfigOption } from '@agent import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { - makeBridgeHarness, textResponse, updatesOfKind, waitForUpdates, type BridgeHarness, + makeBridgeHarness, selectOption, textResponse, updatesOfKind, waitForUpdates, type BridgeHarness, } from './harness.ts' -/** The one mode option, asserted to be a select so its values can be read. */ +/** + * The one mode option. A reopened session publishes a model picker alongside + * it, so the mode is read by category rather than by position. + */ function modeOption(configOptions: SessionConfigOption[] | null | undefined): Extract< SessionConfigOption, { type: 'select' } > { - const [option, ...rest] = configOptions ?? [] - expect(rest).toEqual([]) - if (option?.type !== 'select') throw new Error(`expected one select option, got ${JSON.stringify(configOptions)}`) - return option + return selectOption(configOptions, 'mode') } /** The text of every update of one kind, in arrival order. */ diff --git a/scripts/check-build-prereqs.mjs b/scripts/check-build-prereqs.mjs index 10908401c6..d4a4a0cc0e 100644 --- a/scripts/check-build-prereqs.mjs +++ b/scripts/check-build-prereqs.mjs @@ -10,6 +10,8 @@ * - src/mobile-web/dist missing → cargo check -p bitfun-desktop and * cargo check --workspace fail with "resource path '../../mobile-web/dist' * doesn't exist" in the bitfun-desktop build script + * - OpenCode extension Host dist missing → CLI builds cannot bundle the + * Bun plugin Host resources * - sherpa-onnx prebuilt libs missing → sherpa-onnx-sys build script attempts * a network download from GitHub that fails on poor connectivity * @@ -57,7 +59,25 @@ function runChecks(rootDir) { }); } - // --- Check 3: sherpa-onnx prebuilt libs --- + // --- Check 3: OpenCode extension Host dist (CLI bundled resource) --- + const pluginHostDist = join( + rootDir, + 'src', + 'apps', + 'extension-host', + 'dist', + ); + const pluginHostEntries = [join(pluginHostDist, 'extension-host.js')]; + if (pluginHostEntries.some((entry) => !existsSync(entry))) { + errors.push({ + name: 'OpenCode extension Host dist', + message: + 'src/apps/extension-host/dist is missing the Bun Host entry. CLI builds bundle this directory as the plugin Host resource.', + fix: ['pnpm', 'run', 'plugin-host:prepare'], + }); + } + + // --- Check 4: sherpa-onnx prebuilt libs --- // sherpa-onnx-sys build.rs auto-detects target/sherpa-onnx-prebuilt//lib/ // and returns immediately without downloading. Only warn for the first-build // scenario where no prebuilt cache exists yet. @@ -116,7 +136,15 @@ function runFixes(pendingFixes, rootDir) { const [cmd, ...args] = fix; console.log(`$ ${fix.join(' ')}`); try { - execFileSync(cmd, args, { stdio: 'inherit', cwd: rootDir }); + if (process.platform === 'win32') { + execFileSync( + process.env.ComSpec || 'cmd.exe', + ['/d', '/s', '/c', fix.join(' ')], + { stdio: 'inherit', cwd: rootDir }, + ); + } else { + execFileSync(cmd, args, { stdio: 'inherit', cwd: rootDir }); + } } catch { console.error(`Fix command failed: ${fix.join(' ')}\n`); allSucceeded = false; diff --git a/scripts/check-build-prereqs.test.mjs b/scripts/check-build-prereqs.test.mjs index 72cfded6d2..3420dde0ed 100644 --- a/scripts/check-build-prereqs.test.mjs +++ b/scripts/check-build-prereqs.test.mjs @@ -15,6 +15,7 @@ const scriptPath = path.join(repoRoot, 'scripts/check-build-prereqs.mjs'); function createTestRoot({ nodeModules = false, mobileWebDist = false, + pluginHostDist = false, sherpaOnnx = null, } = {}) { const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-prereqs-')); @@ -29,6 +30,18 @@ function createTestRoot({ writeFileSync(path.join(distDir, 'index.html'), ''); } + if (pluginHostDist) { + const distDir = path.join( + root, + 'src', + 'apps', + 'extension-host', + 'dist', + ); + mkdirSync(distDir, { recursive: true }); + writeFileSync(path.join(distDir, 'extension-host.js'), ''); + } + if (sherpaOnnx) { for (const version of sherpaOnnx) { const libDir = path.join( @@ -48,10 +61,10 @@ function createTestRoot({ function createFakePnpm() { const binDir = mkdtempSync(path.join(tmpdir(), 'bitfun-fake-pnpm-')); - const pnpmPath = path.join(binDir, 'pnpm'); + const fakePnpmPath = path.join(binDir, 'fake-pnpm.cjs'); writeFileSync( - pnpmPath, - `#!/usr/bin/env node + fakePnpmPath, + ` const { mkdirSync, writeFileSync } = require('fs'); const args = process.argv.slice(2); if (args[0] === 'install') { @@ -59,10 +72,25 @@ if (args[0] === 'install') { } else if (args[0] === 'run' && args[1] === 'prepare:mobile-web') { mkdirSync('src/mobile-web/dist', { recursive: true }); writeFileSync('src/mobile-web/dist/index.html', ''); +} else if (args[0] === 'run' && args[1] === 'plugin-host:prepare') { + mkdirSync('src/apps/extension-host/dist', { recursive: true }); + writeFileSync('src/apps/extension-host/dist/extension-host.js', ''); } `, ); - chmodSync(pnpmPath, 0o755); + if (process.platform === 'win32') { + writeFileSync( + path.join(binDir, 'pnpm.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0fake-pnpm.cjs" %*\r\n`, + ); + } else { + const pnpmPath = path.join(binDir, 'pnpm'); + writeFileSync( + pnpmPath, + `#!/usr/bin/env node\nrequire('./fake-pnpm.cjs');\n`, + ); + chmodSync(pnpmPath, 0o755); + } return binDir; } @@ -94,13 +122,14 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) const root = createTestRoot({ nodeModules: true, mobileWebDist: true, + pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); const result = runCheck(root, { sherpaEnv: '' }); - assert.equal(result.status, 0); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stdout, /Build prerequisite check passed/); assert.doesNotMatch(result.stderr, /\[WARN\]/); }); @@ -108,6 +137,7 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) test('fails when root node_modules is missing', (t) => { const root = createTestRoot({ mobileWebDist: true, + pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -122,6 +152,7 @@ test('fails when root node_modules is missing', (t) => { test('fails when mobile-web dist is missing', (t) => { const root = createTestRoot({ nodeModules: true, + pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -137,6 +168,7 @@ test('does not require the DeepSeek profile for cargo check', (t) => { const root = createTestRoot({ nodeModules: true, mobileWebDist: true, + pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -148,8 +180,27 @@ test('does not require the DeepSeek profile for cargo check', (t) => { assert.doesNotMatch(result.stderr, /prepare:dsh-profile/); }); +test('fails when OpenCode extension Host dist is missing', (t) => { + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\[FAIL\] OpenCode extension Host dist/); + assert.match(result.stderr, /Fix: pnpm run plugin-host:prepare/); +}); + test('warns when sherpa-onnx prebuilt dir does not exist (first build)', (t) => { - const root = createTestRoot({ nodeModules: true, mobileWebDist: true }); + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + pluginHostDist: true, + }); t.after(() => rmSync(root, { recursive: true, force: true })); const result = runCheck(root, { sherpaEnv: '' }); @@ -180,10 +231,11 @@ test('--fix runs fix commands, re-verifies, and exits 0 when errors resolved', ( const result = runCheck(root, { fix: true, extraPath: binDir, sherpaEnv: '' }); - assert.equal(result.status, 0); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stdout, /Attempting fixes/); assert.match(result.stdout, /\$ pnpm install/); assert.match(result.stdout, /\$ pnpm run prepare:mobile-web/); + assert.match(result.stdout, /\$ pnpm run plugin-host:prepare/); assert.doesNotMatch(result.stdout, /prepare:dsh-profile/); assert.match(result.stdout, /Re-checking prerequisites/); assert.match(result.stdout, /All errors resolved/); diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 7f95baa339..a8dc76b6c3 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -14,10 +14,13 @@ import { findFeatureGatedTestTargetViolations, findProductEntrypointCoreFeatureViolations, findReqwestDependencyFeatureViolations, + findResolvedThirdPartyCapabilityFeatureViolations, findRuntimeServicesTestSupportFeatureViolations, findResolvedReqwestNativeTlsViolations, + findServicesIntegrationsPlatformDependencyFeatureViolations, findServicesIntegrationsReqwestFeatureViolations, findServicesIntegrationsTokioFeatureViolations, + findThirdPartyCapabilityFeatureViolations, findTokioDependencyFeatureViolations, } from './core-boundaries/cargo-dependency-boundaries.mjs'; import { @@ -33,6 +36,7 @@ import { validateExplicitIntegrationTestTopology, } from './core-boundaries/explicit-test-topology.mjs'; import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs'; +import { findForbiddenContentMatches } from './core-boundaries/source-content-checks.mjs'; import { capabilityContractDependencyRules, coreClosedFeatureProfileRules, @@ -43,6 +47,7 @@ import { import { agentRuntimeRootPublicModules, forbiddenContentRules, + forbiddenContentUnderRules, publicApiAllowlistRules, requiredContentRules, } from './core-boundaries/rules/source-rules.mjs'; @@ -53,6 +58,7 @@ const MODULES = [ './core-boundaries/cargo-dependency-boundaries.mjs', './core-boundaries/explicit-test-topology.mjs', './core-boundaries/manifest-feature-helpers.mjs', + './core-boundaries/source-content-checks.mjs', './core-boundaries/self-test.mjs', './core-boundaries/tui-boundary-ratchet.mjs', './core-boundaries/rules/crate-rules.mjs', @@ -86,7 +92,7 @@ test('Cargo manifest discovery ignores nested local-agent worktrees', async (t) assert.deepEqual(manifests, ['Cargo.toml', 'src/crate/Cargo.toml']); }); -test('App Server TypeScript capability is owned by the protocol crate', () => { +test('App Server TypeScript capability is owned by the protocol crate and independent from RPC', () => { const appServerTs = coreClosedFeatureProfileRules.find( (rule) => rule.manifestPath === 'src/crates/interfaces/app-server/Cargo.toml' && rule.featureName === 'ts', @@ -96,10 +102,31 @@ test('App Server TypeScript capability is owned by the protocol crate', () => { ]); assert.equal(appServerTs?.exact, true); - const protocolTs = coreClosedFeatureProfileRules.find( - (rule) => rule.manifestPath === 'src/crates/interfaces/app-server-protocol/Cargo.toml' - && rule.featureName === 'ts', + const protocolProfiles = new Map( + coreClosedFeatureProfileRules + .filter( + (rule) => rule.manifestPath + === 'src/crates/interfaces/app-server-protocol/Cargo.toml', + ) + .map((rule) => [rule.featureName, rule]), ); + + const protocolDefault = protocolProfiles.get('default'); + assert.deepEqual(protocolDefault?.requiredFeatureRefs, ['rpc']); + assert.equal(protocolDefault?.exact, true); + + const protocolRpc = protocolProfiles.get('rpc'); + assert.deepEqual(protocolRpc?.requiredFeatureRefs, ['dep:agent-client-protocol']); + assert.equal(protocolRpc?.exact, true); + + const protocolOptionalOwners = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'app-server-protocol', + ); + assert.deepEqual(protocolOptionalOwners?.dependencies, [ + { depName: 'agent-client-protocol', ownerFeatures: ['rpc'] }, + ]); + + const protocolTs = protocolProfiles.get('ts'); assert.deepEqual(protocolTs?.requiredFeatureRefs, [ 'bitfun-core-types/ts', 'bitfun-product-domains/ts', @@ -116,7 +143,6 @@ test('Agent Runtime leaf capabilities have one managed feature and source contra assert.ok(rule, 'bitfun-agent-runtime must be a managed capability target'); assert.deepEqual(Object.keys(rule.featureProfiles).sort(), [ 'agent-runtime', - 'deep-research', 'default', 'native-hook-runtime', 'native-hook-settings', @@ -671,11 +697,12 @@ test('runtime-services feature aliases cannot hide test support from default bui assert.match(messages, /bitfun-runtime-services:testing/); }); -test('CLI integration tests keep the reviewed three-target topology', () => { +test('CLI integration tests keep the reviewed four-target topology', () => { const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); assert.deepEqual(cliIntegrationTestTargets, [ { name: 'acp_stdio_cli', path: 'tests/acp_stdio_cli.rs' }, + { name: 'app_server_stdio_cli', path: 'tests/app_server_stdio_cli.rs' }, { name: 'cli_command_contracts', path: 'tests/cli_command_contracts.rs' }, { name: 'terminal_process_contracts', path: 'tests/terminal_process_contracts.rs' }, ]); @@ -800,10 +827,18 @@ test('contract and AI adapter tests keep reviewed feature and failure-domain top 'tests/product_capability_contracts/plugin_product_shape.rs', 'tests/product_capability_contracts/product_capabilities.rs', 'tests/product_capability_contracts/product_sdk_assembly.rs', + 'tests/product_capability_contracts/runtime_boundary.rs', ], forbidRequiredFeatures: true, }, ]); + assert.deepEqual(topology.agentWorkflowsIntegrationTestTargets, [ + { + name: 'deep_research_contracts', + path: 'tests/deep_research_contracts.rs', + forbidRequiredFeatures: true, + }, + ]); assert.deepEqual(topology.checkBuildGraphContractIntegrationTestTopologies(repositoryRoot), []); const widenedOwnerErrors = validateExplicitIntegrationTestTopology({ @@ -898,6 +933,71 @@ test('external source integration tests keep reviewed owner and process boundari ); }); +test('Web UI command contracts do not become Rust compilation inputs', async () => { + const webApiPath = 'src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts'; + const webCommandRule = requiredContentRules.find( + (rule) => rule.path === webApiPath + && rule.reason.includes('stable Desktop command'), + ); + assert.ok(webCommandRule, 'Web API must retain a boundary-owned stable command contract'); + + const webCommandPattern = webCommandRule.patterns.find( + (pattern) => pattern.message.includes('external-source control snapshot'), + )?.regex; + assert.ok(webCommandPattern, 'Web API command contract must name the control snapshot'); + + const webApi = await readFile(new URL(`../${webApiPath}`, import.meta.url), 'utf8'); + assert.equal(webCommandPattern.test(webApi), true); + assert.equal( + webCommandPattern.test( + webApi.replace('get_external_source_control_snapshot', 'get_renamed_snapshot'), + ), + false, + 'renaming the invoked command must break the cross-surface contract', + ); + + const rustSourceRule = forbiddenContentUnderRules.find( + (rule) => rule.path === '.' + && rule.reason.includes('Rust source must not reference the Web UI source tree'), + ); + assert.ok(rustSourceRule, 'all tracked Rust sources must reject Web UI file inputs'); + for (const mutation of [ + 'let web = include_str!(\n "../../web-ui/src/infrastructure/api.ts"\n);', + 'let web = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../web-ui/src/api.ts"));', + 'let web = include!("../../web-ui/public/generated.rs");', + 'let web = include_dir!("../../web-ui/src");', + 'let path = PathBuf::from("../../web-ui/src/api.ts");', + 'let path = PathBuf::from("../../").join("web-ui").join("src");', + 'const WEB_UI_DIR: &str = "web-ui"; let body = fs::read_to_string(WEB_UI_DIR);', + 'let example = r#"include_str!(\"../../web-ui/src/api.ts\")"#;', + ]) { + assert.equal( + findForbiddenContentMatches(mutation, rustSourceRule.patterns, 'src/example.rs').length, + 1, + `Rust/Web input guard must reject: ${mutation}`, + ); + } + for (const allowed of [ + '// include_str!("../../web-ui/src/comment-only.ts")', + '/* PathBuf::from("../../web-ui/public/comment-only.json") */', + 'const REVIEW_SCOPE: &str = "src/frontend/src/**/*.ts";', + ]) { + assert.deepEqual( + findForbiddenContentMatches(allowed, rustSourceRule.patterns, 'src/example.rs'), + [], + `Rust/Web input guard must ignore non-input text: ${allowed}`, + ); + } + assert.deepEqual( + findForbiddenContentMatches( + 'allowed\nforbidden', + [{ regex: /forbidden/, message: 'line-based guard' }], + 'src/example.rs', + ), + [{ line: 2, message: 'line-based guard' }], + ); +}); + test('runtime-services test support is absent from ordinary library builds', async () => { const [manifest, library] = await Promise.all([ readFile( @@ -1468,8 +1568,6 @@ const SDK_HOST_REVIEWED_CORE_FEATURES = [ 'tools-browser-web', 'tools-computer-use', 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', 'tools-agent-control', ]; @@ -1483,6 +1581,7 @@ const CLI_REVIEWED_CORE_FEATURES = [ 'product-search', 'remote-connect', 'plugin-runtime', + 'opencode-plugin-host', ]; const APP_SERVER_REVIEWED_CORE_FEATURES = [ @@ -1492,6 +1591,16 @@ const APP_SERVER_REVIEWED_CORE_FEATURES = [ 'remote-connect', ]; +test('OpenCode Plugin Host keeps retired LSP outside its feature closure', () => { + const rule = coreClosedFeatureProfileRules.find( + (candidate) => candidate.manifestPath === 'src/crates/assembly/core/Cargo.toml' + && candidate.featureName === 'opencode-plugin-host', + ); + + assert.ok(rule, 'opencode-plugin-host feature closure rule'); + assert.equal(rule.requiredFeatureRefs.includes('lsp'), false); +}); + test('SDK Host Core capability closure keeps every reviewed owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const sdkHost = packageAt( @@ -1678,13 +1787,13 @@ test('App Server reviewed Core capability closure remains independently valid', ); }); -test('ACP Core capability closure must retain its Canvas tool owner', () => { +test('ACP Core capability closure rejects the Canvas product tool owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const acp = packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml', [ pathDependency('src/crates/assembly/core', { name: 'bitfun-core', usesDefaultFeatures: false, - features: ACP_REVIEWED_CORE_FEATURES.filter((feature) => feature !== 'tools-canvas'), + features: [...ACP_REVIEWED_CORE_FEATURES, 'tools-canvas'], }), ]); @@ -1694,7 +1803,7 @@ test('ACP Core capability closure must retain its Canvas tool owner', () => { ); assert.equal(violations.length, 1); - assert.match(violations[0].message, /must include tools-canvas/); + assert.match(violations[0].message, /must not include unreviewed feature tools-canvas/); }); test('ACP Core capability closure validation cannot be disabled by removing an owner', () => { @@ -2366,7 +2475,7 @@ test('ACP active closure cannot be expanded by a reviewed owner definition', () ...packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'), features: { ...Object.fromEntries(reviewedFeatures.map((feature) => [feature, []])), - 'tools-canvas': ['plugin-runtime'], + 'tools-git': ['plugin-runtime'], 'plugin-runtime': [], }, }; @@ -2680,6 +2789,182 @@ test('Reqwest consumers inherit the workspace version without duplicating featur } }); +test('third-party capability profiles reject ambient feature unions and unreviewed owners', () => { + const validPackages = [ + packageAt('bitfun-cli', 'src/apps/cli/Cargo.toml', [{ + name: 'image', + kind: null, + optional: false, + uses_default_features: false, + features: ['gif', 'jpeg', 'png', 'webp'], + }]), + packageAt('bitfun-server', 'src/apps/server/Cargo.toml', [{ + name: 'axum', + kind: null, + optional: false, + uses_default_features: true, + features: ['json', 'ws'], + }]), + packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml', [{ + name: 'tokio-tungstenite', + kind: null, + optional: true, + uses_default_features: true, + features: [], + }]), + packageAt('bitfun-services-core', 'src/crates/services/services-core/Cargo.toml', [{ + name: 'git2', + kind: null, + optional: true, + uses_default_features: false, + features: ['vendored-libgit2'], + }]), + ]; + + assert.deepEqual(findThirdPartyCapabilityFeatureViolations(validPackages), []); + + const mutatedPackages = structuredClone(validPackages); + mutatedPackages[0].dependencies[0].features.push('bmp'); + mutatedPackages[1].dependencies[0].features = ['json']; + mutatedPackages[2].dependencies[0].features.push('rustls-tls-native-roots'); + mutatedPackages[3].dependencies[0].features.push('https'); + mutatedPackages[3].dependencies[0].rename = 'private-git2'; + mutatedPackages.push(packageAt('future-image-owner', 'src/apps/future/Cargo.toml', [{ + name: 'image', + kind: null, + optional: false, + uses_default_features: false, + features: ['png'], + }])); + + const messages = findThirdPartyCapabilityFeatureViolations(mutatedPackages) + .map((violation) => violation.message) + .join('\n'); + assert.match(messages, /bitfun-cli Image dependency has unexpected features: bmp/); + assert.match(messages, /bitfun-server Axum dependency missing features: ws/); + assert.match(messages, /bitfun-core Tokio Tungstenite dependency has unexpected features: rustls-tls-native-roots/); + assert.match(messages, /bitfun-services-core Git2 dependency has unexpected features: https/); + assert.match(messages, /bitfun-services-core Git2 dependency does not match its reviewed owner shape/); + assert.match(messages, /future-image-owner Image dependency is missing a reviewed owner profile/); +}); + +test('services integrations image codecs stay attached to exact product owners', () => { + const pkg = { + ...packageAt('bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', [{ + name: 'image', + kind: null, + optional: true, + uses_default_features: false, + features: [], + }]), + features: { + image: ['dep:image'], + 'miniapp-market': ['image', 'image/gif', 'image/jpeg', 'image/png', 'image/webp'], + 'remote-connect': [ + 'image', + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', + ], + }, + }; + + assert.deepEqual(findThirdPartyCapabilityFeatureViolations([pkg]), []); + + const mutated = structuredClone(pkg); + mutated.features.image.push('image/png'); + mutated.features['miniapp-market'].push('image/bmp'); + mutated.features['remote-connect'] = mutated.features['remote-connect'] + .filter((reference) => reference !== 'image/gif'); + mutated.features.default = ['image/png']; + const messages = findThirdPartyCapabilityFeatureViolations([mutated]) + .map((violation) => violation.message) + .join('\n'); + assert.match(messages, /miniapp-market.*unexpected Image capabilities: bmp/); + assert.match(messages, /remote-connect.*missing Image capabilities: gif/); + assert.match(messages, /default enables Image outside its reviewed owner features/); + assert.match(messages, /image shared Image activation alias must not select capabilities: png/); +}); + +test('services integrations WebSocket TLS stays attached to remote-connect', () => { + const pkg = { + ...packageAt('bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', [{ + name: 'tokio-tungstenite', + kind: null, + optional: true, + uses_default_features: true, + features: [], + }]), + features: { + 'remote-connect': [ + 'dep:tokio-tungstenite', + 'tokio-tungstenite?/rustls-tls-native-roots', + ], + }, + }; + + assert.deepEqual(findThirdPartyCapabilityFeatureViolations([pkg]), []); + + const mutated = structuredClone(pkg); + mutated.features['tokio-tungstenite'] = ['dep:tokio-tungstenite']; + mutated.features['future-non-remote-owner'] = ['dep:tokio-tungstenite']; + const messages = findThirdPartyCapabilityFeatureViolations([mutated]) + .map((violation) => violation.message) + .join('\n'); + assert.match(messages, /future-non-remote-owner enables Tokio Tungstenite outside its reviewed owner features/); + assert.match(messages, /tokio-tungstenite enables Tokio Tungstenite outside its reviewed owner features/); +}); + +test('resolved third-party feature unions reject global capability regressions', () => { + const validRecords = [ + { + name: 'git2', + version: '0.21.0', + features: ['vendored-libgit2'], + }, + { + name: 'image', + version: '0.24.9', + features: ['default', 'exr', 'tiff'], + }, + { + name: 'image', + version: '0.25.10', + features: ['bmp', 'gif', 'jpeg', 'png', 'tiff', 'webp'], + }, + { + name: 'libgit2-sys', + version: '0.18.7+1.9.6', + features: ['vendored'], + }, + ]; + + assert.deepEqual( + findResolvedThirdPartyCapabilityFeatureViolations(validRecords, { root: TEST_ROOT }), + [], + ); + + const mutated = structuredClone(validRecords); + mutated[0].features.push('https', 'vendored-openssl'); + mutated[2].features.push('exr'); + mutated[3].features.push('https', 'openssl-sys', 'vendored-openssl'); + mutated.push({ + name: 'image', + version: '0.26.0', + features: ['avif', 'default', 'exr'], + }); + const messages = findResolvedThirdPartyCapabilityFeatureViolations( + mutated, + { root: TEST_ROOT }, + ).map((violation) => violation.message).join('\n'); + assert.match(messages, /resolved git2.*https, vendored-openssl/); + assert.match(messages, /resolved image 0\.25\.10.*exr/); + assert.match(messages, /resolved libgit2-sys.*https, openssl-sys, vendored-openssl/); + assert.match(messages, /resolved image 0\.26\.0 uses an unreviewed version family/); +}); + test('resolved Reqwest feature union rejects every native TLS backend alias', () => { const violations = findResolvedReqwestNativeTlsViolations( [ @@ -2981,6 +3266,11 @@ test('core boundary check is split into focused modules', async () => { checker.split(/\r?\n/).length <= 1200, 'checker should stay focused on orchestration and shared check helpers', ); + assert.match( + checker, + /listTrackedRustRepoPaths/, + 'recursive source boundary checks must inspect tracked Rust files only', + ); const sourceRuleEntry = await readFile( new URL('./core-boundaries/rules/source-rules.mjs', import.meta.url), @@ -3112,7 +3402,7 @@ test('desktop preview rebuild inputs use the current crate layout', async () => ); }); -test('split core boundary check keeps self-test and default execution behavior', () => { +test('split core boundary check keeps self-test execution behavior', () => { const selfTest = spawnSync( process.execPath, ['scripts/check-core-boundaries.mjs'], @@ -3124,13 +3414,6 @@ test('split core boundary check keeps self-test and default execution behavior', ); assert.equal(selfTest.status, 0, selfTest.stderr || selfTest.stdout); assert.match(selfTest.stdout, /Core boundary check self-test passed\./); - - const defaultRun = spawnSync(process.execPath, ['scripts/check-core-boundaries.mjs'], { - cwd: new URL('..', import.meta.url), - encoding: 'utf8', - }); - assert.equal(defaultRun.status, 0, defaultRun.stderr || defaultRun.stdout); - assert.match(defaultRun.stdout, /Core boundary check passed\./); }); test('Task execution boundary requires policy-aware child delegation', () => { @@ -3613,6 +3896,53 @@ test('services-core Windows API capabilities stay feature-owned', async () => { ); }); +test('services-integrations Windows dependency keeps only APIs used by its owners', () => { + const pkg = packageAt( + 'bitfun-services-integrations', + 'src/crates/services/services-integrations/Cargo.toml', + [{ + name: 'windows', + kind: null, + optional: true, + target: 'cfg(windows)', + features: [ + 'Win32_Foundation', + 'Win32_Storage_FileSystem', + 'Win32_System_Diagnostics_ToolHelp', + ], + }], + ); + + const violations = findServicesIntegrationsPlatformDependencyFeatureViolations([pkg]); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /unexpected Windows API capabilities: Win32_System_Diagnostics_ToolHelp/); + + pkg.dependencies[0].features = ['Win32_Foundation', 'Win32_Storage_FileSystem']; + assert.deepEqual(findServicesIntegrationsPlatformDependencyFeatureViolations([pkg]), []); + + pkg.dependencies[0].target = 'cfg(all(windows, target_arch = "x86_64"))'; + const targetViolations = findServicesIntegrationsPlatformDependencyFeatureViolations([pkg]); + assert.equal(targetViolations.length, 1); + assert.match(targetViolations[0].message, /must declare exactly one reviewed Windows dependency/); + + pkg.dependencies[0].target = 'cfg(windows)'; + pkg.dependencies.push({ + name: 'windows', + kind: null, + optional: false, + target: null, + features: ['Win32_System_Threading'], + }); + const duplicateViolations = findServicesIntegrationsPlatformDependencyFeatureViolations([pkg]); + assert.equal(duplicateViolations.length, 1); + assert.match(duplicateViolations[0].message, /must declare exactly one reviewed Windows dependency/); + + pkg.dependencies = []; + const missingViolations = findServicesIntegrationsPlatformDependencyFeatureViolations([pkg]); + assert.equal(missingViolations.length, 1); + assert.match(missingViolations[0].message, /must declare exactly one reviewed Windows dependency/); +}); + test('closed feature profiles reject product-full hidden behind a child feature', async () => { const { unexpectedReachableLocalFeatures } = await import( './core-boundaries/manifest-feature-helpers.mjs' diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index ed152d3d6e..4ead104878 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -290,6 +290,7 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => } const cliJob = workflow.jobs['cli-test']; + assert.equal(cliJob['timeout-minutes'], 30); assert.ok( cliJob.strategy.matrix.include.some((entry) => entry.os === 'windows-latest'), 'Windows ConPTY contracts must run before Nightly', @@ -452,6 +453,14 @@ test('gates fast checks and PR packaging behind one fail-closed build decision', const resultJob = workflow.jobs['rust-validation-result']; const frontendJob = workflow.jobs['frontend-build']; + assert.equal( + frontendJob.steps.find((step) => step.name === 'Test core boundary contracts')?.run, + 'pnpm run check:core-boundaries:test', + ); + assert.equal( + frontendJob.steps.find((step) => step.name === 'Check core boundaries')?.run, + 'pnpm run check:core-boundaries', + ); assert.equal(frontendJob.needs, 'build-impact'); const frontendNode = frontendJob.steps.find((step) => step.uses?.startsWith('actions/setup-node@')); @@ -460,6 +469,7 @@ test('gates fast checks and PR packaging behind one fail-closed build decision', const frontendGate = "needs.build-impact.outputs.frontend_required != 'false'"; for (const stepName of [ 'Verify committed release metadata', + 'Build plugin Host resources', 'Generate web API bindings', 'Build web UI', 'Build mobile web', @@ -559,6 +569,7 @@ test('gates fast checks and PR packaging behind one fail-closed build decision', upload_artifacts: false, cache_write: false, }); + assert.equal(resultJob.name, 'Rust / CLI Validation'); assert.equal(resultJob.if, '${{ always() }}'); assert.deepEqual( @@ -645,9 +656,7 @@ test('gates fast checks and PR packaging behind one fail-closed build decision', '-NoProfile', '-NonInteractive', '-Command', - `$cases = ConvertFrom-Json @' -${JSON.stringify(cases)} -'@ + `$cases = ConvertFrom-Json ([Console]::In.ReadToEnd()) $verify = { ${verify.run} } @@ -670,6 +679,7 @@ foreach ($case in $cases) { cwd: repoRoot, env: process.env, encoding: 'utf8', + input: JSON.stringify(cases), }, ); if (truthTable.error?.code === 'ENOENT') { @@ -842,6 +852,9 @@ test('Linux binary packaging uses the shared locked version projection contract' ); const inputs = workflow.on.workflow_call.inputs; const steps = workflow.jobs.build.steps; + const nodeSteps = steps.filter( + (step) => step.name === 'Setup Node.js', + ); const nodeIndex = steps.findIndex( (step) => step.name === 'Setup Node.js', ); @@ -862,6 +875,7 @@ test('Linux binary packaging uses the shared locked version projection contract' assert.equal(inputs.upload_artifacts.default, true); assert.equal(inputs.cache_write.default, false); assert.equal(inputs.validate_relay_image.default, true); + assert.equal(nodeSteps.length, 1); assert.equal(steps[nodeIndex].uses, 'actions/setup-node@v5'); assert.equal(steps[nodeIndex].with['node-version-file'], 'package.json'); assert.ok( @@ -918,6 +932,15 @@ test('PR-capable release builds cannot save repository caches or upload CI packa } } + for (const workflow of [ci, artifacts, linux]) { + for (const job of Object.values(workflow.jobs)) { + for (const bun of (job.steps ?? []).filter((step) => + step.uses?.startsWith('oven-sh/setup-bun@'))) { + assert.equal(bun.with?.['no-cache'], true); + } + } + } + const packageCaller = ci.jobs['package-impact-contract']; assert.equal(packageCaller.with.cache_write, false); assert.equal(packageCaller.with.upload_artifacts, false); @@ -1174,3 +1197,19 @@ test('nightly and beta use the shared build-version projection', () => { ); assert.match(signingStep.run, /write-minisign-public-key\.mjs/); }); + + +test('Linux Rust workflows do not install an unused native OpenSSL toolchain', () => { + for (const workflowPath of [ + '.github/workflows/ci.yml', + '.github/workflows/cli-package-manual.yml', + '.github/workflows/linux-binaries.yml', + ]) { + const workflow = readFileSync(path.join(repoRoot, workflowPath), 'utf8'); + assert.doesNotMatch( + workflow, + /\blibssl-dev\b/, + `${workflowPath} must rely on the reviewed Cargo-owned Git2 build profile`, + ); + } +}); diff --git a/scripts/check-repo-hygiene.mjs b/scripts/check-repo-hygiene.mjs index 725b3b962d..72678d98ed 100644 --- a/scripts/check-repo-hygiene.mjs +++ b/scripts/check-repo-hygiene.mjs @@ -15,7 +15,7 @@ * comment-only lines and Rust inline test blocks inside non-test source files. */ import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; function runGit(args) { @@ -40,6 +40,7 @@ function hasCommit(ref) { } const trackedFiles = runGit(['ls-files']); +const trackedFileSet = new Set(trackedFiles.map(normalizePath)); const untrackedFiles = runGit(['ls-files', '--others', '--exclude-standard']); const repositoryFiles = uniqueFiles([...trackedFiles, ...untrackedFiles]); const localChangedFiles = uniqueFiles([ @@ -212,6 +213,14 @@ for (const file of repositoryFiles) { const normalized = normalizePath(file); const basename = path.posix.basename(normalized).toLowerCase(); + if ( + trackedFileSet.has(normalized) + && existsSync(file) + && normalized === 'BitFun-Installer/src-tauri/Cargo.lock' + ) { + addViolation(file, null, 'is a generated Installer lockfile and must not be tracked.'); + } + if ( temporaryPromptNames.has(basename) || /(^|[-_])review[-_]?prompt\.(txt|md)$/i.test(basename) diff --git a/scripts/ci/classify-build-impact.mjs b/scripts/ci/classify-build-impact.mjs index cd8ba1837e..b17567d5fb 100644 --- a/scripts/ci/classify-build-impact.mjs +++ b/scripts/ci/classify-build-impact.mjs @@ -2,6 +2,8 @@ import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { dirname, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { rustWebUiSourceBoundaryRule } from '../core-boundaries/rules/source-rules.mjs'; +import { scanForbiddenContentUnder } from '../core-boundaries/source-content-checks.mjs'; function readArg(args, name) { const index = args.indexOf(name); @@ -368,6 +370,18 @@ export function run(args = process.argv.slice(2), env = process.env) { ); } + const boundaryFindings = scanForbiddenContentUnder( + process.cwd(), + rustWebUiSourceBoundaryRule, + ); + if (boundaryFindings.length > 0) { + const details = boundaryFindings + .slice(0, 20) + .map((finding) => `${finding.repoPath}:${finding.line}: ${finding.message}`) + .join('\n'); + throw new Error(`${rustWebUiSourceBoundaryRule.reason}\n${details}`); + } + let paths = []; let result; if ( diff --git a/scripts/ci/classify-build-impact.test.mjs b/scripts/ci/classify-build-impact.test.mjs index db47a5d21b..274cdc384f 100644 --- a/scripts/ci/classify-build-impact.test.mjs +++ b/scripts/ci/classify-build-impact.test.mjs @@ -390,3 +390,19 @@ test('fails closed when paths or event ranges are invalid or unavailable', (t) = assert.deepEqual(JSON.parse(result.outputs.desktop_platforms), allPlatforms); } }); + +test('rejects tracked Rust sources that reference the Web UI source tree', (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-impact-boundary-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + git(root, ['init', '--initial-branch=main']); + writeFileSync(path.join(root, 'README.md'), 'baseline\n'); + const base = commit(root, 'baseline'); + const rustFile = path.join(root, 'src/lib.rs'); + mkdirSync(path.dirname(rustFile), { recursive: true }); + writeFileSync(rustFile, 'const WEB: &str = include_dir!("../web-ui/src");\n'); + const head = commit(root, 'forbidden Rust input'); + + const result = runClassifier(root, base, head); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Rust source must not reference the Web UI source tree/); +}); diff --git a/scripts/cli-product.mjs b/scripts/cli-product.mjs index 999ddf4c92..000e5c9f9b 100644 --- a/scripts/cli-product.mjs +++ b/scripts/cli-product.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -9,6 +9,24 @@ import { ensureProductOutputDirectory, productBuildEnvironment } from './product import { ProductDefinitionError, resolveProductDefinition } from './product-customization/resolver.mjs'; const ROOT = resolve(import.meta.dirname, '..'); +const PLUGIN_HOST_DIST = join(ROOT, 'src', 'apps', 'extension-host', 'dist'); +const PLUGIN_HOST_ENTRIES = ['extension-host.js']; + +export function stagePluginHostResources(destination, sourceDirectory = PLUGIN_HOST_DIST) { + for (const entry of PLUGIN_HOST_ENTRIES) { + const source = join(sourceDirectory, entry); + if (!existsSync(source)) { + throw new Error( + `CLI plugin Host resource was not produced: ${source}. Run pnpm run plugin-host:prepare.`, + ); + } + } + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + for (const entry of PLUGIN_HOST_ENTRIES) { + copyFileSync(join(sourceDirectory, entry), join(destination, entry)); + } +} function stripDelimiter(args) { const result = [...args]; @@ -76,6 +94,12 @@ export function cliBuildPlan(resolution, mode, forwardArgs = [], platform = proc cargoArgs, internalBinaryPath: join(cargoTargetDir, ...(target ? [target] : []), profileDir, `bitfun${suffix}`), stagedBinaryPath: join(resolution.outputDir, 'package', `${resolution.assembly.binaryName}${suffix}`), + stagedPluginHostPath: join( + resolution.outputDir, + 'package', + 'resources', + 'ext-host', + ), }; } @@ -93,7 +117,9 @@ function run(plan) { ensureProductOutputDirectory(plan.resolution); mkdirSync(join(plan.stagedBinaryPath, '..'), { recursive: true }); copyFileSync(plan.internalBinaryPath, plan.stagedBinaryPath); + stagePluginHostResources(plan.stagedPluginHostPath); console.log(`[product] staged CLI: ${plan.stagedBinaryPath}`); + console.log(`[product] staged plugin Host: ${plan.stagedPluginHostPath}`); } } diff --git a/scripts/cli-product.test.mjs b/scripts/cli-product.test.mjs index d9d0391646..19e2d97c3a 100644 --- a/scripts/cli-product.test.mjs +++ b/scripts/cli-product.test.mjs @@ -1,13 +1,29 @@ import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import test from 'node:test'; -import { cliBuildPlan } from './cli-product.mjs'; +import { cliBuildPlan, stagePluginHostResources } from './cli-product.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; const ROOT = resolve(import.meta.dirname, '..'); const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); +test('CLI stages only the supported plugin Host entry', (t) => { + const root = mkdtempSync(join(tmpdir(), 'bitfun-cli-plugin-host-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const source = join(root, 'source'); + const destination = join(root, 'destination'); + mkdirSync(source); + writeFileSync(join(source, 'extension-host.js'), 'current'); + writeFileSync(join(source, 'stale-runtime.js'), 'stale'); + + stagePluginHostResources(destination, source); + + assert.deepEqual(readdirSync(destination), ['extension-host.js']); +}); + test('CLI uses the shared resolver and stages the internal binary under the member name', () => { const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); const plan = cliBuildPlan(resolution, 'build', ['--locked'], 'win32'); @@ -16,6 +32,7 @@ test('CLI uses the shared resolver and stages the internal binary under the memb assert.ok(plan.cargoArgs.includes('--locked')); assert.ok(plan.internalBinaryPath.endsWith('bitfun.exe')); assert.ok(plan.stagedBinaryPath.endsWith('acme.exe')); + assert.ok(plan.stagedPluginHostPath.endsWith(join('resources', 'ext-host'))); assert.equal(plan.environment.BITFUN_PRODUCT_DISPLAY_NAME, 'Acme CLI'); }); diff --git a/scripts/cli/package-contract.test.mjs b/scripts/cli/package-contract.test.mjs index f66046bb79..d3663c4f14 100644 --- a/scripts/cli/package-contract.test.mjs +++ b/scripts/cli/package-contract.test.mjs @@ -17,6 +17,21 @@ for (const packageScript of [ assert.match(content, /THIRD_PARTY_NOTICES\.md/); assert.match(content, /models-dev\.LICENSE\.txt/); assert.match(content, /models-dev\.provenance\.json/); + assert.match(content, /extension-host/); + assert.match(content, /resources[\\/]ext-host/); + assert.match(content, /extension-host\.js/); +} + +for (const workflow of [ + '.github/workflows/cli-package.yml', + '.github/workflows/cli-package-manual.yml', + '.github/workflows/linux-binaries.yml', + '.github/workflows/nightly.yml', +]) { + const content = read(workflow); + assert.match(content, /oven-sh\/setup-bun@v2/); + assert.match(content, /plugin Host resources/); + assert.match(content, /plugin-host:prepare|extension-host/); } for (const workflow of [ diff --git a/scripts/cli/package-unix.sh b/scripts/cli/package-unix.sh index 53601a4636..ae1543a9ba 100644 --- a/scripts/cli/package-unix.sh +++ b/scripts/cli/package-unix.sh @@ -16,6 +16,18 @@ OUTPUT_DIR="${4:-${REPO_ROOT}}" PRIMARY="${RELEASE_DIR}/bitfun" LEGACY="${RELEASE_DIR}/bitfun-cli" DEPRECATION='Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' +PLUGIN_HOST_DIST="${REPO_ROOT}/src/apps/extension-host/dist" +PLUGIN_HOST_RESOURCE_DIR="resources/ext-host" + +assert_plugin_host_resources() { + local directory="$1" + for entry in extension-host.js; do + if [ ! -f "${directory}/${entry}" ]; then + echo "Error: plugin Host resource is missing: ${directory}/${entry}" >&2 + return 1 + fi + done +} assert_legacy_entrypoint() { local executable="$1" @@ -37,6 +49,7 @@ assert_legacy_entrypoint() { "$PRIMARY" --version "$PRIMARY" --help >/dev/null assert_legacy_entrypoint "$LEGACY" +assert_plugin_host_resources "$PLUGIN_HOST_DIST" STAGE_NAME="bitfun-cli-${VERSION}-${TARGET}" STAGE_DIR="${OUTPUT_DIR}/dist-cli/${STAGE_NAME}" @@ -59,6 +72,9 @@ fi if [ -d "${REPO_ROOT}/src/apps/cli/prompts" ]; then cp -R "${REPO_ROOT}/src/apps/cli/prompts" "$STAGE_DIR/prompts" fi +mkdir -p "$STAGE_DIR/$PLUGIN_HOST_RESOURCE_DIR" +cp "$PLUGIN_HOST_DIST/extension-host.js" "$STAGE_DIR/$PLUGIN_HOST_RESOURCE_DIR/" +assert_plugin_host_resources "$STAGE_DIR/$PLUGIN_HOST_RESOURCE_DIR" ARCHIVE="${OUTPUT_DIR}/${STAGE_NAME}.tar.gz" tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" @@ -87,6 +103,7 @@ LEGACY_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) [ -f "$EXTRACT_DIR/$STAGE_NAME/THIRD_PARTY_NOTICES.md" ] [ -f "$EXTRACT_DIR/$STAGE_NAME/third-party/models.dev/LICENSE.txt" ] [ -f "$EXTRACT_DIR/$STAGE_NAME/third-party/models.dev/provenance.json" ] +assert_plugin_host_resources "$EXTRACT_DIR/$STAGE_NAME/$PLUGIN_HOST_RESOURCE_DIR" "${PRIMARY_CANDIDATES[0]}" --version "${PRIMARY_CANDIDATES[0]}" --help >/dev/null assert_legacy_entrypoint "${LEGACY_CANDIDATES[0]}" diff --git a/scripts/cli/package-windows.ps1 b/scripts/cli/package-windows.ps1 index b29aa94d25..ff2bbbb824 100644 --- a/scripts/cli/package-windows.ps1 +++ b/scripts/cli/package-windows.ps1 @@ -27,6 +27,17 @@ $OutputDir = [IO.Path]::GetFullPath($OutputDir) $primary = Join-Path $ReleaseDir 'bitfun.exe' $legacy = Join-Path $ReleaseDir 'bitfun-cli.exe' $deprecation = 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' +$pluginHostDist = Join-Path $repoRoot 'src\apps\extension-host\dist' +$pluginHostResourceRelative = 'resources\ext-host' + +function Assert-PluginHostResources([string]$Directory) { + foreach ($entry in @('extension-host.js')) { + $path = Join-Path $Directory $entry + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Plugin Host resource is missing: $path" + } + } +} function Assert-LastExitCode([string]$Description) { if ($LASTEXITCODE -ne 0) { @@ -83,6 +94,7 @@ Assert-LastExitCode 'bitfun --help' Assert-LegacyEntrypoint $legacy Assert-NoRedistributableRuntime $primary Assert-NoRedistributableRuntime $legacy +Assert-PluginHostResources $pluginHostDist $stageName = "bitfun-cli-$Version-$Target" $stageDir = Join-Path (Join-Path $OutputDir 'dist-cli') $stageName @@ -111,6 +123,10 @@ if (Test-Path -LiteralPath $themes -PathType Container) { if (Test-Path -LiteralPath $prompts -PathType Container) { Copy-Item -LiteralPath $prompts -Destination (Join-Path $stageDir 'prompts') -Recurse -Force } +$pluginHostResources = Join-Path $stageDir $pluginHostResourceRelative +New-Item -ItemType Directory -Path $pluginHostResources -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $pluginHostDist 'extension-host.js') -Destination $pluginHostResources -Force +Assert-PluginHostResources $pluginHostResources $archive = Join-Path $OutputDir "$stageName.zip" Compress-Archive -Path $stageDir -DestinationPath $archive -CompressionLevel Optimal -Force @@ -136,7 +152,8 @@ try { 'PROJECT-README.md', 'THIRD_PARTY_NOTICES.md', 'third-party\models.dev\LICENSE.txt', - 'third-party\models.dev\provenance.json' + 'third-party\models.dev\provenance.json', + 'resources\ext-host\extension-host.js' )) { if (-not (Test-Path -LiteralPath (Join-Path $primaryCandidates[0].DirectoryName $requiredFile) -PathType Leaf)) { throw "Packaged archive is missing $requiredFile" diff --git a/scripts/cli/test-install-unix.sh b/scripts/cli/test-install-unix.sh index 51cc1b2e97..ae0c6a3301 100644 --- a/scripts/cli/test-install-unix.sh +++ b/scripts/cli/test-install-unix.sh @@ -33,6 +33,7 @@ bash "${REPO_ROOT}/src/apps/cli/install.sh" bash "${REPO_ROOT}/src/apps/cli/install.sh" "${BITFUN_CLI_BIN_DIR}/bitfun" --version >/dev/null +[ -f "${BITFUN_CLI_BIN_DIR}/resources/ext-host/extension-host.js" ] LEGACY_STDERR="${TEST_ROOT}/legacy.err" "${BITFUN_CLI_BIN_DIR}/bitfun-cli" --version >/dev/null 2>"$LEGACY_STDERR" grep -Fxq 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' "$LEGACY_STDERR" diff --git a/scripts/cli/test-install-windows.ps1 b/scripts/cli/test-install-windows.ps1 index 1a7957aaba..e0d5cdc7ea 100644 --- a/scripts/cli/test-install-windows.ps1 +++ b/scripts/cli/test-install-windows.ps1 @@ -21,6 +21,11 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Installed bitfun smoke check failed' } + foreach ($entry in @('extension-host.js')) { + if (-not (Test-Path -LiteralPath (Join-Path $binDir "resources\ext-host\$entry") -PathType Leaf)) { + throw "Installed CLI is missing plugin Host resource: $entry" + } + } $primary = Join-Path $binDir 'bitfun.exe' $legacy = Join-Path $binDir 'bitfun-cli.exe' diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index f1468a6ad2..29d8ae51e4 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -178,6 +178,7 @@ const CORE_TOKIO_AGGREGATES = new Set([ 'external-sources', 'plugin-runtime', 'product-search', + 'opencode-plugin-host', 'product-full', 'remote-connect', 'tools-browser-web', @@ -451,6 +452,386 @@ export function findReqwestDependencyFeatureViolations(packages) { }); } +function dependencyProfile(features, options = {}) { + return { + features, + kind: options.kind ?? null, + optional: options.optional ?? false, + target: options.target ?? null, + useDefaultFeatures: options.useDefaultFeatures ?? true, + allowDependencyFeatureAlias: options.allowDependencyFeatureAlias ?? false, + ownerFeatureCapabilities: options.ownerFeatureCapabilities, + }; +} + +const THIRD_PARTY_CAPABILITY_PROFILES = new Map([ + ['axum', { + label: 'Axum', + packages: new Map([ + ['bitfun-ai-adapters', dependencyProfile(['json'], { kind: 'dev' })], + ['bitfun-core', dependencyProfile(['json'], { optional: true })], + ['bitfun-desktop', dependencyProfile(['json'])], + ['bitfun-miniapp-market-server', dependencyProfile(['json'])], + ['bitfun-miniapp-market-service', dependencyProfile(['json'])], + ['bitfun-relay-server', dependencyProfile([])], + ['bitfun-relay-service', dependencyProfile(['json', 'ws'])], + ['bitfun-server', dependencyProfile(['json', 'ws'])], + ['bitfun-skin-market-server', dependencyProfile(['json'])], + ['bitfun-skin-market-service', dependencyProfile(['json'])], + ['bitfun-webdriver', dependencyProfile(['json'])], + ]), + }], + ['git2', { + label: 'Git2', + packages: new Map([ + ['bitfun-services-core', dependencyProfile(['vendored-libgit2'], { + optional: true, + useDefaultFeatures: false, + })], + ['bitfun-services-integrations', dependencyProfile(['vendored-libgit2'], { + optional: true, + useDefaultFeatures: false, + })], + ]), + }], + ['image', { + label: 'Image', + packages: new Map([ + ['bitfun-cli', dependencyProfile(['gif', 'jpeg', 'png', 'webp'], { + useDefaultFeatures: false, + })], + ['bitfun-core', dependencyProfile(['bmp', 'gif', 'jpeg', 'png', 'webp'], { + optional: true, + useDefaultFeatures: false, + })], + ['bitfun-desktop', dependencyProfile(['jpeg', 'png'], { + useDefaultFeatures: false, + })], + ['bitfun-miniapp-market-service', dependencyProfile(['jpeg', 'png', 'webp'], { + useDefaultFeatures: false, + })], + ['bitfun-services-integrations', dependencyProfile([], { + allowDependencyFeatureAlias: true, + optional: true, + useDefaultFeatures: false, + ownerFeatureCapabilities: new Map([ + ['miniapp-market', ['gif', 'jpeg', 'png', 'webp']], + ['remote-connect', ['bmp', 'gif', 'jpeg', 'png', 'webp']], + ]), + })], + ['bitfun-skin-market-service', dependencyProfile(['gif', 'jpeg', 'png', 'webp'], { + useDefaultFeatures: false, + })], + ['bitfun-webdriver', dependencyProfile(['png'], { + useDefaultFeatures: false, + })], + ]), + }], + ['tokio-tungstenite', { + label: 'Tokio Tungstenite', + packages: new Map([ + ['bitfun-core', dependencyProfile([], { optional: true })], + ['bitfun-services-integrations', dependencyProfile([], { + optional: true, + ownerFeatureCapabilities: new Map([ + ['remote-connect', ['rustls-tls-native-roots']], + ]), + })], + ]), + }], + ['tower-http', { + label: 'Tower HTTP', + packages: new Map([ + ['bitfun-core', dependencyProfile(['cors'], { optional: true })], + ['bitfun-desktop', dependencyProfile(['fs'])], + ['bitfun-miniapp-market-service', dependencyProfile(['fs', 'set-header', 'trace'])], + ['bitfun-relay-server', dependencyProfile(['fs'])], + ['bitfun-relay-service', dependencyProfile(['cors'])], + ['bitfun-server', dependencyProfile(['cors'])], + ['bitfun-skin-market-service', dependencyProfile(['fs'])], + ]), + }], +]); + +function externalDependencyReference(reference, dependencyName) { + if (reference === dependencyName || reference === `dep:${dependencyName}`) { + return { activates: true, capability: null }; + } + const match = reference.match(/^([^/?]+)(\?)?\/(.+)$/); + if (match?.[1] !== dependencyName) { + return null; + } + return { activates: !match[2], capability: match[3] }; +} + +function effectiveExternalDependencyState( + feature, + featureGraph, + dependencyName, + visiting = new Set(), +) { + if (visiting.has(feature)) { + return { activates: false, capabilities: new Set() }; + } + visiting.add(feature); + let activates = false; + const capabilities = new Set(); + for (const reference of featureGraph[feature] ?? []) { + const external = externalDependencyReference(reference, dependencyName); + if (external) { + activates ||= external.activates; + if (external.capability) { + capabilities.add(external.capability); + } + continue; + } + if (Object.hasOwn(featureGraph, reference)) { + const nested = effectiveExternalDependencyState( + reference, + featureGraph, + dependencyName, + visiting, + ); + activates ||= nested.activates; + for (const capability of nested.capabilities) { + capabilities.add(capability); + } + } + } + visiting.delete(feature); + return { activates, capabilities }; +} + +function featureOwnedDependencyViolations(pkg, dependencyName, label, profile) { + const ownerProfiles = profile.ownerFeatureCapabilities; + if (!ownerProfiles) { + return []; + } + const violations = []; + const ownerFeatures = new Set(ownerProfiles.keys()); + const featureGraph = pkg.features ?? {}; + + for (const [feature, expectedCapabilities] of ownerProfiles) { + const state = effectiveExternalDependencyState(feature, featureGraph, dependencyName); + if (!state.activates) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${feature} must explicitly enable ${label}`, + }); + } + const actual = [...state.capabilities].sort(); + const expected = [...expectedCapabilities].sort(); + const missing = expected.filter((capability) => !actual.includes(capability)); + const unexpected = actual.filter((capability) => !expected.includes(capability)); + if (missing.length > 0) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${feature} missing ${label} capabilities: ${missing.join(', ')}`, + }); + } + if (unexpected.length > 0) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${feature} has unexpected ${label} capabilities: ${unexpected.join(', ')}`, + }); + } + } + + for (const [feature, references] of Object.entries(featureGraph)) { + if (ownerFeatures.has(feature)) { + continue; + } + if (feature === dependencyName && profile.allowDependencyFeatureAlias) { + const state = effectiveExternalDependencyState(feature, featureGraph, dependencyName); + if (!state.activates) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${feature} shared ${label} activation alias must activate the dependency`, + }); + } + if (state.capabilities.size > 0) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: + `${pkg.name}:${feature} shared ${label} activation alias must not select capabilities: ` + + [...state.capabilities].sort().join(', '), + }); + } + continue; + } + if (references.some((reference) => externalDependencyReference(reference, dependencyName))) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${feature} enables ${label} outside its reviewed owner features`, + }); + } + } + + return violations; +} + +function thirdPartyDependencyProfileViolations(pkg, dependencyName, policy, profile) { + const violations = []; + const dependencies = (pkg.dependencies ?? []).filter( + (dependency) => dependency.name === dependencyName, + ); + if (dependencies.length !== 1) { + return [{ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} must declare exactly one reviewed ${policy.label} dependency`, + }]; + } + const dependency = dependencies[0]; + if ( + (dependency.kind ?? null) !== profile.kind + || (dependency.rename ?? null) !== null + || (dependency.target ?? null) !== profile.target + || dependency.optional !== profile.optional + ) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} ${policy.label} dependency does not match its reviewed owner shape`, + }); + } + if ((dependency.uses_default_features !== false) !== profile.useDefaultFeatures) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} ${policy.label} dependency default-feature policy does not match its owner profile`, + }); + } + const actual = new Set(dependency.features ?? []); + const expected = new Set(profile.features); + const missing = [...expected].filter((feature) => !actual.has(feature)); + const unexpected = [...actual].filter((feature) => !expected.has(feature)); + if (missing.length > 0) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} ${policy.label} dependency missing features: ${missing.join(', ')}`, + }); + } + if (unexpected.length > 0) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} ${policy.label} dependency has unexpected features: ${unexpected.join(', ')}`, + }); + } + violations.push(...featureOwnedDependencyViolations( + pkg, + dependencyName, + policy.label, + profile, + )); + return violations; +} + +export function findThirdPartyCapabilityFeatureViolations(packages) { + const violations = []; + for (const pkg of packages) { + for (const [dependencyName, policy] of THIRD_PARTY_CAPABILITY_PROFILES) { + if (!(pkg.dependencies ?? []).some((dependency) => dependency.name === dependencyName)) { + continue; + } + const profile = policy.packages.get(pkg.name); + if (!profile) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} ${policy.label} dependency is missing a reviewed owner profile`, + }); + continue; + } + violations.push(...thirdPartyDependencyProfileViolations( + pkg, + dependencyName, + policy, + profile, + )); + } + } + return violations; +} + +const RESOLVED_THIRD_PARTY_CAPABILITY_POLICIES = new Map([ + ['git2', { + forbiddenFeatures: new Set(['https', 'vendored-openssl']), + }], + ['libgit2-sys', { + forbiddenFeatures: new Set(['https', 'openssl-sys', 'vendored-openssl']), + }], + ['image', { + versionPrefix: '0.25.', + ignoredVersionPrefixes: ['0.24.'], + // macOS clipboard support currently adds TIFF through arboard. The other + // formats are the codecs selected by reviewed BitFun owners. + allowedFeatures: new Set(['bmp', 'gif', 'jpeg', 'png', 'tiff', 'webp']), + }], +]); + +export function findResolvedThirdPartyCapabilityFeatureViolations(records, { root }) { + const violations = []; + + for (const [dependencyName, policy] of RESOLVED_THIRD_PARTY_CAPABILITY_POLICIES) { + const namedRecords = records.filter((record) => record.name === dependencyName); + const governedRecords = policy.versionPrefix + ? namedRecords.filter((record) => record.version.startsWith(policy.versionPrefix)) + : namedRecords; + for (const record of namedRecords) { + if ( + !policy.versionPrefix + || record.version.startsWith(policy.versionPrefix) + || policy.ignoredVersionPrefixes?.some((prefix) => record.version.startsWith(prefix)) + ) { + continue; + } + violations.push({ + path: join(root, 'Cargo.toml'), + line: 1, + message: `resolved ${dependencyName} ${record.version} uses an unreviewed version family`, + }); + } + if (policy.versionPrefix && namedRecords.length > 0 && governedRecords.length === 0) { + violations.push({ + path: join(root, 'Cargo.toml'), + line: 1, + message: + `resolved ${dependencyName} graph has no evidence for reviewed version family ` + + policy.versionPrefix, + }); + continue; + } + + for (const record of governedRecords) { + const features = record.features ?? []; + const unexpected = policy.forbiddenFeatures + ? features.filter((feature) => policy.forbiddenFeatures.has(feature)) + : features.filter((feature) => !policy.allowedFeatures.has(feature)); + if (unexpected.length === 0) { + continue; + } + violations.push({ + path: join(root, 'Cargo.toml'), + line: 1, + message: + `resolved ${dependencyName} ${record.version} feature union enables unreviewed capabilities: ` + + unexpected.join(', '), + }); + } + } + + return violations; +} + export function findRuntimeServicesTestSupportFeatureViolations(packages) { const violations = []; @@ -697,6 +1078,57 @@ export function findServicesCorePlatformDependencyFeatureViolations(packages) { return violations; } +export function findServicesIntegrationsPlatformDependencyFeatureViolations(packages) { + const expectedFeatures = new Set([ + 'Win32_Foundation', + 'Win32_Storage_FileSystem', + ]); + const violations = []; + + for (const pkg of packages) { + if (pkg.name !== 'bitfun-services-integrations') { + continue; + } + const dependencies = (pkg.dependencies ?? []).filter( + (dependency) => dependency.name === 'windows', + ); + const dependency = dependencies[0]; + if ( + dependencies.length !== 1 + || (dependency.kind ?? null) !== null + || (dependency.rename ?? null) !== null + || dependency.optional !== true + || dependency.target !== 'cfg(windows)' + ) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} must declare exactly one reviewed Windows dependency`, + }); + continue; + } + const actual = new Set(dependency.features ?? []); + const missing = [...expectedFeatures].filter((feature) => !actual.has(feature)); + const unexpected = [...actual].filter((feature) => !expectedFeatures.has(feature)); + if (missing.length > 0) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} Windows dependency missing API capabilities: ${missing.join(', ')}`, + }); + } + if (unexpected.length > 0) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name} Windows dependency has unexpected Windows API capabilities: ${unexpected.join(', ')}`, + }); + } + } + + return violations; +} + export function findTokioDependencyFeatureViolations(packages) { const violations = []; @@ -901,8 +1333,6 @@ export function findProductEntrypointCoreFeatureViolations( 'tools-browser-web', 'tools-computer-use', 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', 'tools-agent-control', ]; const reviewedCoreFeatureClosures = new Map([ @@ -911,6 +1341,7 @@ export function findProductEntrypointCoreFeatureViolations( 'product-search', 'remote-connect', 'plugin-runtime', + 'opencode-plugin-host', 'ssh-remote', ]], ['bitfun-acp', [...new Set([...acpClientCoreFeatures, ...acpServerCoreFeatures])]], @@ -973,6 +1404,7 @@ export function findProductEntrypointCoreFeatureViolations( 'i18n-runtime', 'plugin-runtime', 'product-search', + 'opencode-plugin-host', 'remote-connect', ]], ['bitfun-acp', acpActiveCoreFeatures], @@ -2181,8 +2613,11 @@ export function checkCargoDependencyBoundaries({ root, crateLayoutRules }) { ...findRuntimeServicesTestSupportFeatureViolations(packages), ...findTokioDependencyFeatureViolations(packages), ...findReqwestDependencyFeatureViolations(packages), + ...findThirdPartyCapabilityFeatureViolations(packages), + ...findResolvedThirdPartyCapabilityFeatureViolations(resolvedPackageFeatures, { root }), ...findResolvedReqwestNativeTlsViolations(resolvedPackageFeatures, { root }), ...findServicesCorePlatformDependencyFeatureViolations(packages), + ...findServicesIntegrationsPlatformDependencyFeatureViolations(packages), ]; } diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index d70a510bc9..bb28520c2f 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -40,6 +40,10 @@ import { } from './manifest-feature-helpers.mjs'; import { checkCargoDependencyBoundariesSafely } from './cargo-dependency-boundaries.mjs'; import { checkPeerCommandPolicySync } from './peer-command-policy.mjs'; +import { + listTrackedRustRepoPaths, + scanForbiddenContentUnder, +} from './source-content-checks.mjs'; import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, @@ -1055,29 +1059,15 @@ function checkPublicApiAllowlist(rule) { } } -function checkForbiddenContentUnder(repoDir, patterns, reason) { - const dir = repoPathToFsPath(repoDir); - walkFiles(dir, (path) => { - if (!path.endsWith('.rs')) { - return; - } - const repoPath = toRepoPath(path); - const lines = readText(path).split(/\r?\n/); - lines.forEach((line, index) => { - for (const pattern of patterns) { - if (pattern.allowPaths?.includes(repoPath)) { - continue; - } - if (pattern.regex.test(line)) { - failures.push({ - path, - line: index + 1, - message: `${reason}; ${pattern.message}`, - }); - } - } +function checkForbiddenContentUnder(repoDir, patterns, reason, trackedRustRepoPaths) { + const rule = { path: repoDir, patterns }; + for (const finding of scanForbiddenContentUnder(ROOT, rule, trackedRustRepoPaths)) { + failures.push({ + path: finding.path, + line: finding.line, + message: `${reason}; ${finding.message}`, }); - }); + } } export function runCoreBoundaryCheck() { @@ -1127,6 +1117,7 @@ export function runCoreBoundaryCheck() { failures.push(...checkCliIntegrationTestTopology(ROOT)); failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkReviewedIntegrationTestTopologies(ROOT)); failures.push(...checkPeerCommandPolicySync(ROOT)); + const trackedRustRepoPaths = listTrackedRustRepoPaths(ROOT); for (const rule of forbiddenManifestDependencyRules) { checkForbiddenManifestDependencyRule(rule); @@ -1177,7 +1168,7 @@ export function runCoreBoundaryCheck() { } for (const rule of forbiddenContentUnderRules) { - checkForbiddenContentUnder(rule.path, rule.patterns, rule.reason); + checkForbiddenContentUnder(rule.path, rule.patterns, rule.reason, trackedRustRepoPaths); } for (const rule of requiredContentRules) { diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 6ffe315f7e..2096888ac5 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -6,11 +6,6 @@ export const agentRuntimeIntegrationTestTargets = [ { name: 'agent_interaction_contracts', path: 'tests/agent_interaction_contracts.rs', requiredFeatures: ['agent-runtime'] }, { name: 'agent_long_horizon_contracts', path: 'tests/agent_long_horizon_contracts.rs', requiredFeatures: ['agent-runtime'] }, { name: 'agent_session_contracts', path: 'tests/agent_session_contracts.rs', requiredFeatures: ['agent-runtime'] }, - { - name: 'deep_research_contracts', - path: 'tests/deep_research_contracts.rs', - requiredFeatures: ['deep-research'], - }, { name: 'native_hook_execution_contracts', path: 'tests/native_hook_execution_contracts.rs', @@ -23,8 +18,17 @@ export const agentRuntimeIntegrationTestTargets = [ }, ]; +export const agentWorkflowsIntegrationTestTargets = [ + { + name: 'deep_research_contracts', + path: 'tests/deep_research_contracts.rs', + forbidRequiredFeatures: true, + }, +]; + export const cliIntegrationTestTargets = [ { name: 'acp_stdio_cli', path: 'tests/acp_stdio_cli.rs' }, + { name: 'app_server_stdio_cli', path: 'tests/app_server_stdio_cli.rs' }, { name: 'cli_command_contracts', path: 'tests/cli_command_contracts.rs' }, { name: 'terminal_process_contracts', path: 'tests/terminal_process_contracts.rs' }, ]; @@ -231,6 +235,7 @@ export const productCapabilitiesIntegrationTestTargets = [ 'tests/product_capability_contracts/plugin_product_shape.rs', 'tests/product_capability_contracts/product_capabilities.rs', 'tests/product_capability_contracts/product_sdk_assembly.rs', + 'tests/product_capability_contracts/runtime_boundary.rs', ], forbidRequiredFeatures: true, }, @@ -898,6 +903,7 @@ export function checkBuildGraphContractIntegrationTestTopologies(root) { ['src/crates/contracts/core-types', coreTypesIntegrationTestTargets], ['src/crates/contracts/runtime-ports', runtimePortsIntegrationTestTargets], ['src/crates/contracts/product-domains', productDomainsIntegrationTestTargets], + ['src/crates/execution/agent-workflows', agentWorkflowsIntegrationTestTargets], [ 'src/crates/adapters/ai-adapters', aiAdaptersIntegrationTestTargets, diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 54d9d872a2..c755b4f7aa 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -8,6 +8,7 @@ export const crateLayoutRules = [ { crateName: 'runtime-ports', layer: 'contracts', path: 'src/crates/contracts/runtime-ports' }, { crateName: 'agent-runtime', layer: 'execution', path: 'src/crates/execution/agent-runtime' }, + { crateName: 'agent-workflows', layer: 'execution', path: 'src/crates/execution/agent-workflows' }, { crateName: 'agent-stream', layer: 'execution', path: 'src/crates/execution/agent-stream' }, { crateName: 'tool-call-jsonrepair', layer: 'execution', path: 'src/crates/execution/tool-call-jsonrepair' }, { crateName: 'agent-tools', layer: 'execution', path: 'src/crates/execution/tool-contracts' }, @@ -40,6 +41,7 @@ export const crateLayoutRules = [ { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, { crateName: 'opencode-adapter', layer: 'adapters', path: 'src/crates/adapters/opencode-adapter' }, { crateName: 'dsh-adapter', layer: 'adapters', path: 'src/crates/adapters/dsh-adapter' }, + { crateName: 'opencode-plugin-host', layer: 'adapters', path: 'src/crates/adapters/opencode-plugin-host' }, { crateName: 'static-hook-support', layer: 'adapters', path: 'src/crates/adapters/static-hook-support' }, { crateName: 'transport', layer: 'adapters', path: 'src/crates/adapters/transport' }, { crateName: 'webdriver', layer: 'adapters', path: 'src/crates/adapters/webdriver' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 01af08b730..15539ed934 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -3,6 +3,7 @@ const agentRuntimeIpcForbiddenDeps = [ 'bitfun-acp', 'bitfun-agent-runtime', + 'bitfun-agent-workflows', 'bitfun-agent-stream', 'bitfun-agent-tools', 'bitfun-ai-adapters', @@ -46,6 +47,7 @@ export const noCoreDependencyCrates = [ 'agent-stream', 'tool-call-jsonrepair', 'agent-runtime', + 'agent-workflows', 'agent-runtime-ipc', 'app-server-client', 'app-server-protocol', @@ -64,6 +66,7 @@ export const noCoreDependencyCrates = [ 'codex-adapter', 'dsh-adapter', 'opencode-adapter', + 'opencode-plugin-host', 'static-hook-support', 'external-sources', 'terminal', @@ -131,6 +134,19 @@ export const forbiddenManifestDependencyRules = [ message: 'only bitfun-core product-full assembly may register bitfun-dsh-adapter through reviewed capability composition roots', }, + { + dependencyNames: ['bitfun-opencode-plugin-host'], + scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], + workspaceManifestPath: 'Cargo.toml', + allowManifestPaths: [ + 'src/crates/adapters/opencode-plugin-host/Cargo.toml', + 'src/crates/assembly/core/Cargo.toml', + ], + reason: + 'OpenCode plugin host process dependencies are limited to the reviewed product composition root', + message: + 'only bitfun-core product-full assembly may register bitfun-opencode-plugin-host', + }, ...[ ['bitfun-claude-code-adapter', 'claude-code-adapter'], ['bitfun-codex-adapter', 'codex-adapter'], @@ -171,6 +187,7 @@ export const lightweightBoundaryRules = [ 'bitfun-agent-runtime-ipc', 'bitfun-services-core', 'bitfun-services-integrations', + 'bitfun-agent-workflows', 'bitfun-runtime-services', 'bitfun-product-capabilities', 'bitfun-external-sources', @@ -279,6 +296,7 @@ export const lightweightBoundaryRules = [ reason: 'runtime-services must stay a typed service assembly contract without concrete runtime implementations', forbiddenDeps: [ 'bitfun-core', + 'bitfun-agent-workflows', 'bitfun-ai-adapters', 'bitfun-agent-stream', 'bitfun-services-core', @@ -307,6 +325,7 @@ export const lightweightBoundaryRules = [ reason: 'agent-runtime must own portable runtime decisions without concrete service or product implementations', forbiddenDeps: [ 'bitfun-core', + 'bitfun-agent-workflows', 'bitfun-ai-adapters', 'bitfun-services-core', 'bitfun-services-integrations', @@ -387,6 +406,34 @@ export const lightweightBoundaryRules = [ 'syntect-tui', ], }, + { + crateName: 'agent-workflows', + reason: + 'agent-workflows must own named workflow policy without concrete service, assembly, or platform implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-tool-packs', + 'bitfun-product-capabilities', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, { crateName: 'plugin-runtime-client', reason: diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 2113dc6947..294e908cd4 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -28,6 +28,14 @@ export const guardedEmptyInternalDefaultManifestPaths = [ ]; export const optionalDependencyFeatureOwnerRules = [ + { + crateName: 'app-server-protocol', + reason: + 'App Server Protocol must keep the ACP runtime dependency behind its RPC integration', + dependencies: [ + { depName: 'agent-client-protocol', ownerFeatures: ['rpc'] }, + ], + }, { crateName: 'services-core', reason: @@ -108,7 +116,7 @@ export const optionalDependencyFeatureOwnerRules = [ crateName: 'agent-runtime', reviewedAggregateFeatures: ['native-hook-runtime'], reason: - 'agent-runtime optional dependencies must stay behind the full runtime, DeepResearch, or native-hook owner slice', + 'agent-runtime optional dependencies must stay behind the full runtime or native-hook owner slice', dependencies: [ { depName: 'async-trait', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, @@ -121,7 +129,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, { depName: 'hex', ownerFeatures: ['agent-runtime'] }, { depName: 'log', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, - { depName: 'regex', ownerFeatures: ['agent-runtime', 'deep-research', 'native-hook-settings'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime', 'native-hook-settings'] }, { depName: 'serde', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'serde_json', ownerFeatures: ['agent-runtime', 'native-hook-runtime', 'native-hook-settings'] }, { depName: 'serde_yaml', ownerFeatures: ['agent-runtime'] }, @@ -143,7 +151,8 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], }, - { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime', 'deep-research'] }, + { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-workflows', ownerFeatures: ['deep-research'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-agent-tools', ownerFeatures: ['agent-runtime', 'local-storage', 'mcp-runtime'] }, { depName: 'bitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, @@ -252,7 +261,8 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'base64', ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'], }, - { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research', 'hook-import'] }, + { depName: 'bitfun-agent-runtime', ownerFeatures: ['hook-import'] }, + { depName: 'bitfun-agent-workflows', ownerFeatures: ['deep-research'] }, { depName: 'bitfun-core-types', ownerFeatures: ['remote-connect', 'speech'] }, { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, { depName: 'bitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, @@ -410,7 +420,7 @@ export const capabilityContractDependencyRules = [ capabilityForwarder('ts', 'ts'), ], [], - ['external-sources', 'mcp-runtime', 'product-full', 'remote-connect', 'tools-mcp'], + ['external-sources', 'mcp-runtime', 'opencode-plugin-host', 'product-full', 'remote-connect', 'tools-mcp'], )], ['bitfun-desktop', capabilityConsumer([ capabilityEdge(['agent-api', 'permission', 'workspace-ports']), @@ -509,6 +519,7 @@ export const capabilityContractDependencyRules = [ [ 'dispatch-store', 'external-sources', + 'opencode-plugin-host', 'plugin-runtime', 'product-search', 'product-full', @@ -539,7 +550,6 @@ export const capabilityContractDependencyRules = [ manifestPath: 'src/crates/execution/agent-runtime/Cargo.toml', featureProfiles: { default: [], - 'deep-research': ['dep:regex'], 'native-hook-settings': ['dep:regex', 'dep:serde_json'], 'native-hook-runtime': [ 'native-hook-settings', @@ -597,10 +607,9 @@ export const capabilityContractDependencyRules = [ [capabilityEdge([], { optional: true })], [ capabilityForwarder('agent-runtime', 'agent-runtime'), - capabilityForwarder('deep-research', 'deep-research', true), ], ['agent-runtime'], - ['external-sources', 'mcp-runtime', 'plugin-runtime', 'product-search', 'product-full', 'remote-connect', 'tools-mcp'], + ['external-sources', 'mcp-runtime', 'opencode-plugin-host', 'plugin-runtime', 'product-search', 'product-full', 'remote-connect', 'tools-mcp'], )], ['bitfun-desktop', capabilityConsumer([ capabilityEdge(['agent-runtime']), @@ -620,10 +629,9 @@ export const capabilityContractDependencyRules = [ ['bitfun-services-integrations', capabilityConsumer( [capabilityEdge([], { optional: true })], [ - capabilityForwarder('deep-research', 'deep-research'), capabilityForwarder('hook-import', 'native-hook-settings'), ], - ['deep-research', 'hook-import'], + ['hook-import'], ['product-full'], )], ]), @@ -668,6 +676,7 @@ export const coreProductFullFeatureAssemblyRule = { 'process-runtime', 'external-sources', 'plugin-runtime', + 'opencode-plugin-host', 'remote-workspace', 'review-platform', 'ssh-remote', @@ -698,8 +707,6 @@ export const acpServerCoreFeatures = [ 'tools-browser-web', 'tools-computer-use', 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', 'tools-agent-control', ]; @@ -757,6 +764,20 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'App Server must delegate TypeScript wire export to the protocol owner', }, + { + manifestPath: 'src/crates/interfaces/app-server-protocol/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: ['rpc'], + exact: true, + reason: 'App Server Protocol must preserve RPC compatibility for default consumers', + }, + { + manifestPath: 'src/crates/interfaces/app-server-protocol/Cargo.toml', + featureName: 'rpc', + requiredFeatureRefs: ['dep:agent-client-protocol'], + exact: true, + reason: 'App Server Protocol RPC bindings must own the ACP runtime dependency', + }, { manifestPath: 'src/crates/interfaces/app-server-protocol/Cargo.toml', featureName: 'ts', @@ -786,6 +807,7 @@ export const coreClosedFeatureProfileRules = [ 'dep:bitfun-agent-content', 'dep:bitfun-agent-stream', 'dep:bitfun-agent-tools', + 'dep:bitfun-harness', 'bitfun-agent-tools/computer-use-contract', 'bitfun-runtime-ports/agent-api', 'bitfun-runtime-ports/git-port', @@ -796,7 +818,6 @@ export const coreClosedFeatureProfileRules = [ 'bitfun-runtime-ports/tool-runtime-handles', 'bitfun-runtime-ports/workspace-ports', 'dep:base64', - 'dep:bitfun-harness', 'dep:dashmap', 'dep:filetime', 'dep:flate2', @@ -969,6 +990,42 @@ export const coreClosedFeatureProfileRules = [ reason: 'bitfun-core plugin-runtime must add only the executable client boundary to external source composition', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'opencode-plugin-host', + requiredFeatureRefs: [ + 'plugin-runtime', + 'remote-connect', + 'git', + 'dep:bitfun-opencode-plugin-host', + ], + allowedTransitiveFeatureRefs: [ + 'agent-runtime', + 'external-sources', + 'model-catalog', + 'mcp-runtime', + 'script-tool-runtime', + 'plugin-source', + 'file-watch', + 'workspace-watch', + 'ai-adapter-runtime', + 'filesystem', + 'local-storage', + 'process-runtime', + 'terminal', + 'workspace-runtime', + 'product-capabilities', + 'runtime-services', + 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'workspace-search', + 'scheduled-jobs', + ], + exact: true, + reason: + 'the managed OpenCode Host must keep its product-shaped route dependencies separate from the portable plugin runtime client boundary without restoring the retired LSP runtime', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'model-catalog', @@ -1071,7 +1128,7 @@ export const coreClosedFeatureProfileRules = [ manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'deep-research', requiredFeatureRefs: [ - 'bitfun-agent-runtime?/deep-research', + 'dep:bitfun-agent-workflows', 'bitfun-services-integrations/deep-research', ], exact: true, diff --git a/scripts/core-boundaries/rules/source-rules.mjs b/scripts/core-boundaries/rules/source-rules.mjs index edbc0afd5e..bed362d981 100644 --- a/scripts/core-boundaries/rules/source-rules.mjs +++ b/scripts/core-boundaries/rules/source-rules.mjs @@ -4,6 +4,7 @@ export { facadeOnlyFiles } from './source/facade-rules.mjs'; export { forbiddenContentRules, forbiddenContentUnderRules, + rustWebUiSourceBoundaryRule, } from './source/forbidden-rules.mjs'; export { agentRuntimeRootPublicModules, diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index d770787408..8fb33b5c7c 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -22,13 +22,13 @@ export const forbiddenContentRules = [ { path: 'src/apps/cli/Cargo.toml', reason: - 'CLI/TUI consumes stable contracts and must not depend on App Server implementation, client transport, wire DTOs, or a shared TUI management crate', + 'CLI/TUI consumes stable contracts; the `server` command may host the App Server stdio surface, but the CLI must not depend on the typed App Server client transport, wire DTOs, or a shared TUI management crate', patterns: [ { regex: - /^\s*bitfun-(?:app-server|app-server-client|app-server-protocol|tui-management)\s*=/m, + /^\s*bitfun-(?:app-server-client|app-server-protocol|tui-management)\s*=/m, message: - 'bitfun-cli must not depend on App Server implementation, client transport, wire DTOs, or a shared TUI management crate', + 'bitfun-cli must not depend on the typed App Server client transport, wire DTOs, or a shared TUI management crate', }, ], }, @@ -3994,7 +3994,53 @@ export const forbiddenContentRules = [ }, ]; +export const rustWebUiSourceBoundaryRule = { + path: '.', + reason: + 'Rust source must not reference the Web UI source tree; cross-surface contracts belong in surface tests and repository boundary checks', + patterns: [ + { + regex: /\bweb-ui\b/g, + wholeFile: true, + ignoreRustComments: true, + allowLines: [ + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs', + text: 'touched_files: vec!["src/web-ui/src/flow_chat/utils/codeReviewReport.ts".to_string()],', + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs', + text: 'target: "pnpm --dir src/web-ui run test:run".to_string(),', + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_review/manifest.rs', + text: '"changed_files": ["src/web-ui/src/locales/en-US/flow-chat.json"],', + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_review/manifest.rs', + text: '"file_path": "src/web-ui/src/locales/en-US/flow-chat.json",', + }, + { + path: 'src/crates/execution/agent-runtime/src/deep_review/manifest.rs', + text: '"src/web-ui/src/locales/en-US/flow-chat.json"', + }, + { + path: 'src/crates/services/services-integrations/src/canvas/compiler/tests.rs', + text: "nodes: [{ id: 'web-ui' }, { id: 'core' }],", + }, + { + path: 'src/crates/services/services-integrations/src/canvas/compiler/tests.rs', + text: "edges: [{ from: 'web-ui', to: 'core' }],", + }, + ], + message: + 'non-comment Rust source must not spell the web-ui path token outside reviewed fixture lines', + }, + ], +}; + export const forbiddenContentUnderRules = [ + rustWebUiSourceBoundaryRule, { path: 'src/crates/adapters/agent-runtime-ipc/src', reason: 'agent-runtime-ipc transport is restricted to Named Pipe and Unix Domain Socket', @@ -4025,6 +4071,19 @@ export const forbiddenContentUnderRules = [ }, ], }, + { + path: 'src/apps/cli/src', + reason: + 'only the reviewed stdio Server Host assembly point may import the App Server implementation; TUI, controller, and headless CLI must stay on stable contracts', + patterns: [ + { + regex: /\bbitfun_app_server\b/, + message: + 'bitfun-app-server implementation imports belong only in src/apps/cli/src/server_host.rs, the reviewed stdio Server Host assembly point', + allowPaths: ['src/apps/cli/src/server_host.rs'], + }, + ], + }, { path: 'src/crates/interfaces', reason: diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index be62bd2fb3..1723204045 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -22,7 +22,6 @@ export const agentRuntimeRootPublicModules = [ 'context_profile', 'custom_agent', 'custom_subagent', - 'deep_research', 'deep_review', 'dialog_turn', 'event_bus', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 987c4b006b..b11b1f2fbc 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3,6 +3,18 @@ import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; export const requiredContentRules = [ + { + path: 'src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts', + reason: + 'the Web API must continue invoking the stable Desktop command without making Web source a Rust compilation input', + patterns: [ + { + regex: + /\binvokeSurfaceSnapshot\(\s*['"]get_external_source_control_snapshot['"]/, + message: 'missing stable external-source control snapshot invocation', + }, + ], + }, { path: 'src/crates/adapters/agent-runtime-ipc/Cargo.toml', reason: @@ -1334,6 +1346,10 @@ export const requiredContentRules = [ regex: /\bProductCapabilityAssembly\b/, message: 'missing product capability assembly owner', }, + { + regex: /\bpub fn agent_ids\b/, + message: 'missing profile-scoped built-in Agent selection', + }, { regex: /\bProductFeatureGroup\b/, message: 'missing product feature group fact owner', @@ -1407,6 +1423,25 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs', + reason: + 'product-capabilities tests must keep headless Agent hosts free of named product workflows and monolithic tool groups', + patterns: [ + { + regex: /\bheadless_agent_hosts_select_only_the_code_agent_product_capability\b/, + message: 'missing headless capability boundary regression', + }, + { + regex: /\bheadless_agent_hosts_keep_explore_code_agents_without_product_workflow_agents\b/, + message: 'missing headless Agent catalog boundary regression', + }, + { + regex: /\bcode_agent_tools_are_selected_from_atomic_provider_groups\b/, + message: 'missing atomic tool group boundary regression', + }, + ], + }, { path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', reason: @@ -2583,9 +2618,9 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/execution/agent-runtime/src/deep_research.rs', + path: 'src/crates/execution/agent-workflows/src/deep_research.rs', reason: - 'agent-runtime must own provider-neutral DeepResearch citation renumbering without core session or filesystem IO dependencies', + 'agent-workflows must own provider-neutral DeepResearch citation policy without session or filesystem IO dependencies', patterns: [ { regex: /\bpub fn renumber_research_report\b/, @@ -2610,9 +2645,9 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/execution/agent-runtime/tests/deep_research_contracts.rs', + path: 'src/crates/execution/agent-workflows/tests/deep_research_contracts.rs', reason: - 'agent-runtime must keep behavior-equivalence contracts for DeepResearch citation renumbering', + 'agent-workflows must keep behavior-equivalence contracts for DeepResearch citation renumbering', patterns: [ { regex: /\bdeep_research_citation_renumber_owner_preserves_report_and_display_map_contracts\b/, @@ -5251,16 +5286,12 @@ export const requiredContentRules = [ path: 'src/crates/execution/agent-runtime/src/lib.rs', reason: 'Agent Runtime leaf capability modules must stay behind their exact owner features', patterns: [ - { - regex: /#\[cfg\(feature = "deep-research"\)\]\r?\npub mod deep_research;/, - message: 'deep-research must gate its pure report capability', - }, { regex: /#\[cfg\(feature = "native-hook-settings"\)\]\r?\npub mod native_hooks;/, message: 'native-hook-settings must gate the portable hook facade', }, ...agentRuntimeRootPublicModules - .filter((moduleName) => !['deep_research', 'native_hooks'].includes(moduleName)) + .filter((moduleName) => moduleName !== 'native_hooks') .map((moduleName) => ({ regex: new RegExp(`#\\[cfg\\(feature = "agent-runtime"\\)\\]\\r?\\npub mod ${moduleName};`), message: `${moduleName} must stay behind the full agent-runtime owner`, @@ -7172,8 +7203,9 @@ export const requiredContentRules = [ message: 'missing product runtime owner registry equivalence regression', }, { - regex: /\bproduct_tool_runtime_registry_preserves_provider_plan_order\b/, - message: 'missing product tool provider plan-to-registry order regression', + regex: + /\bproduct_tool_runtime_provider_plan_covers_registry_without_owning_order\b/, + message: 'missing product tool ownership and registry-order separation regression', }, { regex: /\bproduct_tool_runtime_keeps_no_direct_core_profiles_empty\b/, @@ -7206,6 +7238,14 @@ export const requiredContentRules = [ regex: /\bcreate_product_tool_registry_from_plan\b/, message: 'missing product registry creation adapter', }, + { + regex: /\bPRODUCT_TOOL_REGISTRATION_ORDER\b/, + message: 'missing product registry order compatibility contract', + }, + { + regex: /\bMissingRegistrationOrder\b/, + message: 'product registry materialization must reject unordered planned tools', + }, { regex: /\bunavailable_feature_groups\b/, message: 'product registry materialization must fail closed when a planned group was not compiled', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index aa30e50e12..7af6a8c477 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -629,8 +629,6 @@ export function runManifestParserSelfTest({ 'bitfun-core/tools-browser-web', 'bitfun-core/tools-computer-use', 'bitfun-core/tools-image-analysis', - 'bitfun-core/tools-miniapp', - 'bitfun-core/tools-canvas', 'bitfun-core/tools-agent-control', ], ], @@ -3949,7 +3947,7 @@ export function runManifestParserSelfTest({ 'create_product_tool_registry_from_plan', 'product_assembly_plan_for_profile', 'product_tool_runtime_owner_preserves_registry_contract', - 'product_tool_runtime_registry_preserves_provider_plan_order', + 'product_tool_runtime_provider_plan_covers_registry_without_owning_order', 'product_tool_runtime_keeps_no_direct_core_profiles_empty', 'DeliveryProfile::Sdk', ], @@ -3990,6 +3988,8 @@ export function runManifestParserSelfTest({ 'StaticToolProviderFactory', 'create_registry_from_static_provider_entries', 'create_product_tool_registry_from_plan', + 'PRODUCT_TOOL_REGISTRATION_ORDER', + 'MissingRegistrationOrder', 'unavailable_feature_groups', 'materialize_tool', 'GetToolSpecTool', @@ -4349,11 +4349,11 @@ export function runManifestParserSelfTest({ contracts: ['run_for_session_workspace', 'try_renumber_research_report', 'renumber_research_report', 'report.md', 'citations.md', 'display_map', 'REJECTED'], }, { - path: 'src/crates/execution/agent-runtime/src/deep_research.rs', + path: 'src/crates/execution/agent-workflows/src/deep_research.rs', contracts: ['renumber_research_report', 'ResearchCitationRenumberOutput', 'ResearchCitationDisplayMapEntry', 'rejected_index_rows_dropped', 'should_post_process_research_report'], }, { - path: 'src/crates/execution/agent-runtime/tests/deep_research_contracts.rs', + path: 'src/crates/execution/agent-workflows/tests/deep_research_contracts.rs', contracts: ['deep_research_citation_renumber_owner_preserves_report_and_display_map_contracts', 'deep_research_citation_renumber_owner_is_idempotent_without_citations'], }, { @@ -5575,14 +5575,32 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis const cliManifestPattern = cliManifestRule?.patterns[0]?.regex; if ( !cliManifestPattern || - !cliManifestPattern.test('bitfun-app-server = { path = "..." }') || + cliManifestPattern.test('bitfun-app-server = { path = "..." }') || !cliManifestPattern.test('bitfun-app-server-client = { path = "..." }') || !cliManifestPattern.test('bitfun-tui-management = { path = "..." }') || !cliManifestPattern.test('bitfun-app-server-protocol = { path = "..." }') || cliManifestPattern.test('bitfun-agent-runtime-ipc = { path = "..." }') ) { - throw new Error('CLI manifest guard must forbid App Server, wire DTOs, and shared TUI management implementations while allowing contracts and Runtime IPC'); + throw new Error('CLI manifest guard must allow the App Server stdio host while forbidding the typed client transport, wire DTOs, and shared TUI management implementations, and allowing contracts and Runtime IPC'); } + const cliServerHostRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/apps/cli/src', + ); + const cliServerHostPattern = cliServerHostRule?.patterns[0]; + if (!cliServerHostPattern) { + throw new Error('CLI source must carry a bitfun_app_server import guard'); + } + if (!cliServerHostPattern.regex.test('use bitfun_app_server::BitfunAppServer;')) { + throw new Error('CLI app-server import guard must match implementation imports'); + } + if ( + !cliServerHostPattern.allowPaths || + cliServerHostPattern.allowPaths.length !== 1 || + cliServerHostPattern.allowPaths[0] !== 'src/apps/cli/src/server_host.rs' + ) { + throw new Error('CLI app-server import guard must allow only the reviewed stdio Server Host assembly point'); + } + const runtimeIpcOperationPattern = runtimeIpcOperationRule?.patterns[0]?.regex; if ( !runtimeIpcOperationPattern || diff --git a/scripts/core-boundaries/source-content-checks.mjs b/scripts/core-boundaries/source-content-checks.mjs new file mode 100644 index 0000000000..68558930c6 --- /dev/null +++ b/scripts/core-boundaries/source-content-checks.mjs @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export function listTrackedRustRepoPaths(root) { + const result = spawnSync( + 'git', + ['-C', root, 'ls-files', '-z', '--', '*.rs'], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || 'git ls-files failed'); + } + return result.stdout.split('\0').filter(Boolean); +} + +function stripRustComments(text) { + const chars = text.split(''); + let state = 'code'; + let blockDepth = 0; + let rawTerminator = ''; + + const blank = (index) => { + if (chars[index] !== '\r' && chars[index] !== '\n') { + chars[index] = ' '; + } + }; + + for (let index = 0; index < chars.length; index += 1) { + const current = chars[index]; + const next = chars[index + 1]; + + if (state === 'line-comment') { + if (current === '\n') { + state = 'code'; + } else { + blank(index); + } + continue; + } + if (state === 'block-comment') { + if (current === '/' && next === '*') { + blank(index); + blank(index + 1); + blockDepth += 1; + index += 1; + } else if (current === '*' && next === '/') { + blank(index); + blank(index + 1); + blockDepth -= 1; + index += 1; + if (blockDepth === 0) { + state = 'code'; + } + } else { + blank(index); + } + continue; + } + if (state === 'string') { + if (current === '\\') { + index += 1; + } else if (current === '"') { + state = 'code'; + } + continue; + } + if (state === 'raw-string') { + if (text.startsWith(rawTerminator, index)) { + index += rawTerminator.length - 1; + state = 'code'; + } + continue; + } + + if (current === '/' && next === '/') { + blank(index); + blank(index + 1); + state = 'line-comment'; + index += 1; + } else if (current === '/' && next === '*') { + blank(index); + blank(index + 1); + state = 'block-comment'; + blockDepth = 1; + index += 1; + } else if (current === '"') { + state = 'string'; + } else if (current === 'r' || (current === 'b' && next === 'r')) { + let cursor = current === 'r' ? index + 1 : index + 2; + while (chars[cursor] === '#') { + cursor += 1; + } + if (chars[cursor] === '"') { + rawTerminator = `"${'#'.repeat(cursor - index - (current === 'r' ? 1 : 2))}`; + state = 'raw-string'; + index = cursor; + } + } + } + return chars.join(''); +} + +function isAllowedWholeFileMatch(text, match, pattern, repoPath) { + const lineStart = text.lastIndexOf('\n', match.index - 1) + 1; + const nextNewline = text.indexOf('\n', match.index); + const lineEnd = nextNewline === -1 ? text.length : nextNewline; + const lineText = text.slice(lineStart, lineEnd).trim(); + return pattern.allowLines?.some( + (allowed) => allowed.path === repoPath && allowed.text === lineText, + ) ?? false; +} + +export function findForbiddenContentMatches(text, patterns, repoPath) { + const matches = []; + const lines = text.split(/\r?\n/); + for (const pattern of patterns) { + if (pattern.allowPaths?.includes(repoPath)) { + continue; + } + if (pattern.wholeFile) { + const searchableText = pattern.ignoreRustComments ? stripRustComments(text) : text; + pattern.regex.lastIndex = 0; + let match = pattern.regex.exec(searchableText); + while (match) { + if (!isAllowedWholeFileMatch(text, match, pattern, repoPath)) { + matches.push({ + line: text.slice(0, match.index).split(/\r?\n/).length, + message: pattern.message, + }); + } + if (!pattern.regex.global) { + break; + } + if (match[0].length === 0) { + pattern.regex.lastIndex += 1; + } + match = pattern.regex.exec(searchableText); + } + continue; + } + lines.forEach((line, index) => { + pattern.regex.lastIndex = 0; + if (pattern.regex.test(line)) { + matches.push({ line: index + 1, message: pattern.message }); + } + }); + } + return matches; +} + +export function scanForbiddenContentUnder(root, rule, trackedRustRepoPaths) { + const trackedPaths = trackedRustRepoPaths ?? listTrackedRustRepoPaths(root); + const prefix = rule.path === '.' ? '' : `${rule.path.replace(/\/$/, '')}/`; + const findings = []; + for (const repoPath of trackedPaths) { + if (prefix && !repoPath.startsWith(prefix)) { + continue; + } + const path = join(root, ...repoPath.split('/')); + const text = readFileSync(path, 'utf8'); + for (const match of findForbiddenContentMatches(text, rule.patterns, repoPath)) { + findings.push({ repoPath, path, ...match }); + } + } + return findings; +} diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 95e3f5e159..44b49ac9db 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -9,10 +9,18 @@ The slice validates the intended public object model: - one application-level `AgentClient` owns one managed native `bitfun-sdk-host` process and one Host connection; - `client.query()` uses a Host-managed transient Session; -- `client.sessions.create()` creates an explicit Session whose Turns reuse the - same connection and existing Agent Runtime owner; +- `client.sessions.create()` creates a durable Session whose Turns reuse the + same connection, while `client.sessions.resume(id)` attaches it to a later + Host process; - `Query` is an ordered async stream with idempotent cancellation, cached final `Result`, and explicit close semantics; +- a prompt can be a string or ordered `text` / `local_image` parts; local image + paths are resolved against the Session workspace and reuse Runtime image + attachments; +- a terminal `Result` reports aggregate token usage when the provider supplied + usage for that Turn; +- the same stream reports safe Tool lifecycle facts and permission requests; + `Query.respondPermission()` supports allow once, allow always, or reject; - protocol and process failures use `SdkError`, including outcome certainty. Lifecycle cleanup is bounded. The Windows Host contains descendants in a @@ -20,41 +28,116 @@ kill-on-close Job Object, while Unix managed Hosts run in an isolated process group. A cleanup result whose outcome is unknown makes the connection unusable and triggers Host reclamation. +Durable Sessions are persisted by the existing Agent Runtime. Closing a +Session or its client unloads it without deleting its history, so a later +client using the same workspace can resume it by ID. Existing OS-level Session +locks reject a second writer while another Host process owns that same Session; +different Sessions remain independent. + It does not start the CLI or the Node/Bun Plugin Host, and it does not implement another Agent Runtime. The managed native Host adapts this package to the existing `agent-runtime::sdk` API. ## Repository usage -Build `bitfun-sdk-host`, then pass its absolute path while the platform-native -package layout is still pending: +Build the private SDK and `bitfun-sdk-host`, then stage that already-built Host +into the local package. This does not install BitFun or publish anything: + +```bash +cargo build -p bitfun-sdk-host-app +pnpm --dir sdk/typescript build +pnpm --dir sdk/typescript stage:host -- ../../target/debug/bitfun-sdk-host.exe +``` + +Use `bitfun-sdk-host` without `.exe` on macOS and Linux. The staging command +copies only the current platform's executable into the package build under +`dist/sdk/typescript/native/-/`. + +The trusted application then supplies one process-lifetime model configuration; +the SDK finds and manages the staged native Host automatically: ```typescript import { AgentClient } from "@bitfun/agent-sdk"; +const apiKey = await trustedSecretStore.read("openai"); await using client = await AgentClient.start({ cwd: process.cwd(), - hostPath: process.env.BITFUN_SDK_HOST_PATH, + model: { + provider: "openai", + model: "gpt-5.4", + apiKey, + baseUrl: "https://api.openai.com/v1", + }, }); -await using query = await client.query({ prompt: "Summarize this repository" }); +await using query = await client.query({ + prompt: [ + { type: "text", text: "Explain this screenshot in repository context" }, + { type: "local_image", path: "screenshots/failure.png" }, + ], +}); for await (const item of query) { - if (item.type === "assistant_text_delta") { - process.stdout.write(item.text); + switch (item.type) { + case "assistant_text_delta": + process.stdout.write(item.text); + break; + case "tool_event": + console.log(item.toolName, item.status); + break; + case "permission_request": + await query.respondPermission(item.requestId, { decision: "allow_once" }); + break; } } const result = await query.result(); +console.log(result.usage?.totalTokens); ``` -`BITFUN_SDK_HOST_PATH` is also read directly when `hostPath` is omitted. The -eventual installable package must bundle or resolve a matching signed Host; it -must not require a separately installed BitFun CLI. +`local_image` accepts local PNG, JPEG, GIF, and WebP paths. Image bytes and +remote URLs are intentionally outside this local Host protocol. + +Use an explicit Session when the application needs continuity across client or +Host restarts. Here `options` is the same trusted `AgentClientOptions` value +shown above: + +```typescript +const firstClient = await AgentClient.start(options); +const session = await firstClient.sessions.create({ sessionName: "review" }); +const sessionId = session.id; +await (await session.startTurn({ prompt: "Inspect the current changes" })).result(); +await firstClient.close(); // unloads the Session and exits its managed Host + +const nextClient = await AgentClient.start(options); +const resumed = await nextClient.sessions.resume(sessionId); +await (await resumed.startTurn({ prompt: "Now summarize the risks" })).result(); +await nextClient.close(); +``` + +An explicit absolute `hostPath` remains available as a development override. +The SDK never searches `PATH` or an environment variable for the Host. + +This repository-local package is private and unpublished. Node 24.14.1 is +locally verified for this slice. Bun uses the same ESM build but remains a +release-verification target when a Bun runner is available; neither runtime is +a bundled executable or a final minimum-version policy. `pnpm --dir sdk/typescript pack` +can produce a local tarball containing the staged Host. An application installs +that tarball as an ordinary dependency; it does not install BitFun or a CLI +separately. This PR does not publish the package. A future registry release +still needs platform packages, signing, and release verification. + +Browser and mobile runtimes cannot launch the local native Host. Custom +functions, general user-input callbacks, structured output, Python support, +platform package publication, signing, and downloads +remain deferred. ## Development ```bash pnpm --dir sdk/typescript test pnpm --dir sdk/typescript type-check +pnpm --dir sdk/typescript smoke:node +pnpm --dir sdk/typescript smoke:bun +pnpm --dir sdk/typescript smoke:consumer ``` The internal TypeScript wire bindings are generated from the Rust SDK Host diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 735786447e..c32c7ab0b5 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -4,9 +4,6 @@ "private": true, "description": "Internal TypeScript vertical slice for the BitFun Agent SDK", "type": "module", - "engines": { - "node": ">=22.12.0" - }, "exports": { ".": { "types": "./dist/sdk/typescript/src/index.d.ts", @@ -17,12 +14,17 @@ "dist/sdk/typescript/src/*.d.ts", "dist/sdk/typescript/src/*.js", "dist/sdk/typescript/src/internal/*.js", + "dist/sdk/typescript/native/**", "dist/src/crates/adapters/transport/typescript/src/*.js", "README.md" ], "scripts": { "build": "pnpm run generate:wire && tsc -p tsconfig.json", "generate:wire": "node scripts/generate-wire.mjs", + "stage:host": "node scripts/stage-host.mjs", + "smoke:bun": "bun test/real-host-smoke.mjs", + "smoke:consumer": "node test/local-package-consumer.mjs", + "smoke:node": "node test/real-host-smoke.mjs", "test": "pnpm run build && node --test scripts/*.test.mjs dist/src/crates/adapters/transport/typescript/test/**/*.test.js dist/sdk/typescript/test/**/*.test.js", "type-check": "pnpm run generate:wire && tsc -p tsconfig.json --noEmit" }, diff --git a/sdk/typescript/scripts/generate-wire.mjs b/sdk/typescript/scripts/generate-wire.mjs index bd94f6b863..ebf76c4b75 100644 --- a/sdk/typescript/scripts/generate-wire.mjs +++ b/sdk/typescript/scripts/generate-wire.mjs @@ -49,17 +49,23 @@ const requiredTypes = [ "HostCapabilities", "InitializeParams", "InitializeResult", + "PermissionRespondParams", + "PermissionRespondResult", "QueryCancelParams", "QueryCancelResult", "QueryEventParams", "QueryResultParams", "QueryStartParams", "QueryStartResult", + "QueryUsage", "SessionCloseParams", "SessionCloseResult", "SessionCreateParams", "SessionCreateResult", + "SessionResumeParams", "ShutdownResult", + "TemporaryModelConfig", + "TemporaryModelProvider", ]; const missing = requiredTypes.filter((type) => !files.includes(type)); if (missing.length > 0) { diff --git a/sdk/typescript/scripts/generated-wire-runtime.test.mjs b/sdk/typescript/scripts/generated-wire-runtime.test.mjs index fa8306cf86..8979af53c7 100644 --- a/sdk/typescript/scripts/generated-wire-runtime.test.mjs +++ b/sdk/typescript/scripts/generated-wire-runtime.test.mjs @@ -43,26 +43,32 @@ test("Rust wire export produces executable validators for every type", async () } const initializeResult = { - protocolVersion: 1, + protocolVersion: 5, runtimeVersion: "0.1.0", stability: "not_delivered", capabilities: { sessionCreate: true, - sessionCreateLifetime: "connection", + sessionCreateLifetime: "durable", + sessionResume: true, query: true, queryCancel: true, sessionClose: true, eventStream: true, + toolEvents: true, + imageInput: true, structuredOutput: false, - usage: false, + usage: true, customTools: false, - permissionCallbacks: false, + permissionResponses: true, hooks: false, mcpConfiguration: false, prestartedTransport: false, }, + modelId: "sdk:openai:resolved", }; assert.equal(validators.isInitializeResult(initializeResult), true); + const { modelId: _modelId, ...initializeResultWithoutModel } = initializeResult; + assert.equal(validators.isInitializeResult(initializeResultWithoutModel), false); assert.equal( validators.isInitializeResult({ ...initializeResult, unexpected: true }), false, diff --git a/sdk/typescript/scripts/stage-host.mjs b/sdk/typescript/scripts/stage-host.mjs new file mode 100644 index 0000000000..8e0914cb9f --- /dev/null +++ b/sdk/typescript/scripts/stage-host.mjs @@ -0,0 +1,47 @@ +import { chmod, copyFile, mkdir, stat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export async function stageHost(source, destination) { + let sourceMetadata; + try { + sourceMetadata = await stat(source); + } catch (cause) { + throw new Error(`Host source was not found: ${source}`, { cause }); + } + if (!sourceMetadata.isFile()) { + throw new Error(`Host source must be a file: ${source}`); + } + + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + if (process.platform !== "win32") { + await chmod(destination, 0o755); + } +} + +async function main() { + const [source, ...extra] = process.argv.slice(2); + if (source === undefined || extra.length > 0) { + throw new Error("Usage: pnpm stage:host -- "); + } + + const { packageHostPath } = await import( + "../dist/sdk/typescript/src/internal/host-path.js" + ); + const destination = packageHostPath(process.platform, process.arch); + await stageHost(resolve(source), destination); + process.stdout.write(`Staged BitFun SDK Host at ${destination}\n`); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + await main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/sdk/typescript/scripts/stage-host.test.mjs b/sdk/typescript/scripts/stage-host.test.mjs new file mode 100644 index 0000000000..e69c407f49 --- /dev/null +++ b/sdk/typescript/scripts/stage-host.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +test("stageHost copies an already-built Host into its package destination", async () => { + const stageHost = await loadStageHost(); + const root = await mkdtemp(join(tmpdir(), "bitfun-sdk-stage-host-")); + const source = join(root, "source-host"); + const destination = join(root, "package", "native", "host"); + const contents = Buffer.from("local-host-fixture\n", "utf8"); + try { + await writeFile(source, contents, { mode: 0o600 }); + + await stageHost(source, destination); + + assert.deepEqual(await readFile(destination), contents); + if (process.platform !== "win32") { + assert.notEqual((await stat(destination)).mode & 0o111, 0); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("stageHost rejects a directory source", async () => { + const stageHost = await loadStageHost(); + const root = await mkdtemp(join(tmpdir(), "bitfun-sdk-stage-host-invalid-")); + try { + const source = join(root, "source-directory"); + await mkdir(source); + await assert.rejects( + stageHost(source, join(root, "destination")), + /Host source must be a file/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function loadStageHost() { + try { + const module = await import("./stage-host.mjs"); + assert.equal(typeof module.stageHost, "function"); + return module.stageHost; + } catch (error) { + if (error?.code === "ERR_MODULE_NOT_FOUND") { + assert.fail("stage-host.mjs must export stageHost"); + } + throw error; + } +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index e73ef997e5..d889471951 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -1,12 +1,28 @@ import type { InitializeResult, QueryStartParams, QueryStartResult } from "./internal/wire/index.js"; import type { JsonRpcConnection } from "./internal/json-rpc.js"; +import { resolveHostPath } from "./internal/host-path.js"; +import { normalizeInput } from "./internal/input.js"; +import { SdkError } from "./errors.js"; import { Query } from "./query.js"; import { Session, Sessions } from "./session.js"; -import type { AgentCapabilities, AgentClientOptions, QueryInput } from "./types.js"; +import type { + AgentCapabilities, + AgentClientOptions, + AgentModelOptions, + QueryInput, +} from "./types.js"; + +const SUPPORTED_MODEL_PROVIDERS = new Set([ + "openai", + "responses", + "anthropic", + "gemini", +]); export class AgentClient { readonly #connection: JsonRpcConnection; - readonly #options: AgentClientOptions; + readonly #cwd: string; + readonly #modelId: string; readonly capabilities: AgentCapabilities; readonly sessions: Sessions; readonly #queries = new Set(); @@ -15,17 +31,8 @@ export class AgentClient { #closePromise?: Promise; static async start(options: AgentClientOptions): Promise { - const hostPath = options.hostPath ?? process.env.BITFUN_SDK_HOST_PATH; - if (hostPath === undefined || hostPath.length === 0) { - const { SdkError } = await import("./errors.js"); - throw new SdkError("SDK Host executable is unavailable", { - code: "not_found", - stage: "initialize", - retryable: false, - correlationId: "local:host_start", - outcomeCertainty: "not_started", - }); - } + validateModelOptions(options.model as unknown); + const hostPath = resolveHostPath(options.hostPath); const [{ createAgentClient }, { startManagedHost }] = await Promise.all([ import("./internal/client.js"), import("./internal/managed-host.js"), @@ -44,19 +51,30 @@ export class AgentClient { private constructor( connection: JsonRpcConnection, - options: AgentClientOptions, + options: Pick, initialized: InitializeResult, ) { this.#connection = connection; - this.#options = options; + this.#cwd = options.cwd; + this.#modelId = initialized.modelId; this.capabilities = Object.freeze({ query: initialized.capabilities.query, sessions: initialized.capabilities.sessionCreate, cancellation: initialized.capabilities.queryCancel, + eventStream: initialized.capabilities.eventStream, + toolEvents: initialized.capabilities.toolEvents, + imageInput: initialized.capabilities.imageInput, + permissionResponses: initialized.capabilities.permissionResponses, + structuredOutput: initialized.capabilities.structuredOutput, + usage: initialized.capabilities.usage, + customTools: initialized.capabilities.customTools, + hooks: initialized.capabilities.hooks, + mcpConfiguration: initialized.capabilities.mcpConfiguration, }); this.sessions = Sessions.forClient( connection, - options.cwd, + this.#cwd, + this.#modelId, (query) => this.#trackQuery(query), (session) => this.#trackSession(session), () => this.#ensureOpen(), @@ -66,7 +84,7 @@ export class AgentClient { /** @internal */ static create( connection: JsonRpcConnection, - options: AgentClientOptions, + options: Pick, initialized: InitializeResult, ): AgentClient { return new AgentClient(connection, options, initialized); @@ -74,13 +92,15 @@ export class AgentClient { async query(input: QueryInput): Promise { this.#ensureOpen(); + const normalized = normalizeInput(input.prompt); const params: QueryStartParams = { - prompt: input.prompt, + prompt: normalized.prompt, + images: normalized.images, sessionId: null, sessionName: null, agent: input.agent ?? null, - cwd: this.#options.cwd, - model: input.model ?? null, + cwd: this.#cwd, + model: this.#modelId, }; const started = await this.#connection.request( "query/start", @@ -152,3 +172,56 @@ export class AgentClient { session.onClosed(() => this.#ownedSessions.delete(session)); } } + +function validateModelOptions(value: unknown): asserts value is AgentModelOptions { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + invalidModel("model is required"); + } + const model = value as Record; + if ( + typeof model.provider !== "string" || + !SUPPORTED_MODEL_PROVIDERS.has(model.provider) + ) { + invalidModel("model.provider is unsupported"); + } + if (typeof model.model !== "string" || model.model.trim().length === 0) { + invalidModel("model.model is required"); + } + if (typeof model.apiKey !== "string" || model.apiKey.trim().length === 0) { + invalidModel("model.apiKey is required"); + } + if (model.baseUrl === undefined) { + return; + } + const invalidBaseUrl = + "model.baseUrl must be an absolute http or https URL without credentials, query, or fragment"; + if (typeof model.baseUrl !== "string") { + invalidModel(invalidBaseUrl); + } + let url: URL; + try { + url = new URL(model.baseUrl); + } catch { + invalidModel(invalidBaseUrl); + } + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.hostname.length === 0 || + url.username.length > 0 || + url.password.length > 0 || + url.search.length > 0 || + url.hash.length > 0 + ) { + invalidModel(invalidBaseUrl); + } +} + +function invalidModel(message: string): never { + throw new SdkError(message, { + code: "invalid_request", + stage: "initialize", + retryable: false, + correlationId: "local:model_validation", + outcomeCertainty: "not_started", + }); +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index c3d6901c6b..036659b49c 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -5,8 +5,15 @@ export { Session, Sessions } from "./session.js"; export type { AgentCapabilities, AgentClientOptions, + AgentModelOptions, + AgentModelProvider, + Input, AssistantTextDelta, OutcomeCertainty, + PermissionDecision, + PermissionRequestEvent, + PermissionResponse, + PermissionSource, QueryInput, QueryStreamItem, RecoveryAction, @@ -20,4 +27,7 @@ export type { SessionLifetime, Turn, TurnInput, + ToolEvent, + Usage, + UserInput, } from "./types.js"; diff --git a/sdk/typescript/src/internal/client.ts b/sdk/typescript/src/internal/client.ts index 4199e3c539..b9191cf2b4 100644 --- a/sdk/typescript/src/internal/client.ts +++ b/sdk/typescript/src/internal/client.ts @@ -4,18 +4,27 @@ import { JsonRpcConnection } from "./json-rpc.js"; import type { HostTransport } from "./transport.js"; import type { InitializeParams, InitializeResult } from "./wire/index.js"; -const PROTOCOL_VERSION = 1; +const PROTOCOL_VERSION = 5; const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000; export async function createAgentClient( transport: HostTransport, - options: AgentClientOptions, + options: Pick, ): Promise { const connection = new JsonRpcConnection(transport); const params: InitializeParams = { protocolVersion: PROTOCOL_VERSION, clientInfo: { name: "@bitfun/agent-sdk", version: "0.0.0" }, - capabilities: { serverNotifications: true }, + capabilities: { + serverNotifications: true, + permissionResponses: true, + }, + model: { + provider: options.model.provider, + model: options.model.model, + apiKey: options.model.apiKey, + baseUrl: options.model.baseUrl, + }, }; const initialized = await connection.request( "initialize", diff --git a/sdk/typescript/src/internal/host-path.ts b/sdk/typescript/src/internal/host-path.ts new file mode 100644 index 0000000000..431ee1f1ee --- /dev/null +++ b/sdk/typescript/src/internal/host-path.ts @@ -0,0 +1,30 @@ +import { isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SdkError } from "../errors.js"; + +export function resolveHostPath(explicitPath?: string): string { + if (explicitPath === undefined) { + return packageHostPath(process.platform, process.arch); + } + if (typeof explicitPath !== "string" || !isAbsolute(explicitPath)) { + throw new SdkError("SDK Host path must be an explicit absolute path", { + code: "invalid_request", + stage: "initialize", + retryable: false, + correlationId: "local:host_validation", + outcomeCertainty: "not_started", + }); + } + return explicitPath; +} + +export function packageHostPath( + platform: NodeJS.Platform, + arch: NodeJS.Architecture, +): string { + const executable = platform === "win32" ? "bitfun-sdk-host.exe" : "bitfun-sdk-host"; + return fileURLToPath( + new URL(`../../native/${platform}-${arch}/${executable}`, import.meta.url), + ); +} diff --git a/sdk/typescript/src/internal/input.ts b/sdk/typescript/src/internal/input.ts new file mode 100644 index 0000000000..7466f23a75 --- /dev/null +++ b/sdk/typescript/src/internal/input.ts @@ -0,0 +1,21 @@ +import type { Input } from "../types.js"; + +export function normalizeInput(input: Input): { + prompt: string; + images: string[]; +} { + if (typeof input === "string") { + return { prompt: input, images: [] }; + } + + const promptParts: string[] = []; + const images: string[] = []; + for (const item of input) { + if (item.type === "text") { + promptParts.push(item.text); + } else if (item.type === "local_image") { + images.push(item.path); + } + } + return { prompt: promptParts.join("\n\n"), images }; +} diff --git a/sdk/typescript/src/internal/wire-validation.ts b/sdk/typescript/src/internal/wire-validation.ts index 7b390f41dc..024aa20858 100644 --- a/sdk/typescript/src/internal/wire-validation.ts +++ b/sdk/typescript/src/internal/wire-validation.ts @@ -1,6 +1,7 @@ import type { ErrorData, InitializeResult, + PermissionRespondResult, QueryCancelResult, QueryEventParams, QueryResultParams, @@ -12,6 +13,7 @@ import type { import { isErrorData as isGeneratedErrorData, isInitializeResult, + isPermissionRespondResult, isQueryCancelResult, isQueryEventParams, isQueryResultParams, @@ -34,11 +36,14 @@ export function validateResponseResult(method: string, value: unknown): T { case "initialize": return validateInitializeResult(value) as T; case "session/create": + case "session/resume": return validateSessionCreateResult(value) as T; case "query/start": return validateQueryStartResult(value) as T; case "query/cancel": return validateQueryCancelResult(value) as T; + case "permission/respond": + return validatePermissionRespondResult(value) as T; case "session/close": return validateSessionCloseResult(value) as T; case "shutdown": @@ -93,8 +98,11 @@ function validateInitializeResult(value: unknown): InitializeResult { value, "initialize result", ); - if (!Number.isSafeInteger(result.protocolVersion)) { - throw new Error("SDK Host initialize protocol version is invalid"); + if ( + !Number.isSafeInteger(result.protocolVersion) || + !isNonEmptyString(result.modelId) + ) { + throw new Error("SDK Host initialize protocol version or model id is invalid"); } return result; } @@ -131,6 +139,18 @@ function validateQueryCancelResult(value: unknown): QueryCancelResult { return result; } +function validatePermissionRespondResult(value: unknown): PermissionRespondResult { + const result = validateWireValue( + isPermissionRespondResult, + value, + "permission response result", + ); + if (!isNonEmptyString(result.requestId) || !result.accepted) { + throw new Error("SDK Host permission response result is invalid"); + } + return result; +} + function validateSessionCloseResult(value: unknown): SessionCloseResult { const result = validateWireValue( isSessionCloseResult, diff --git a/sdk/typescript/src/query.ts b/sdk/typescript/src/query.ts index 2fbe466c51..540502e461 100644 --- a/sdk/typescript/src/query.ts +++ b/sdk/typescript/src/query.ts @@ -1,6 +1,8 @@ import type { JsonRpcConnection } from "./internal/json-rpc.js"; import { withTimeout } from "./internal/deadline.js"; import type { + PermissionRespondParams, + PermissionRespondResult, QueryCancelParams, QueryCancelResult, QueryEventParams, @@ -9,7 +11,14 @@ import type { SessionCloseParams, SessionCloseResult, } from "./internal/wire/index.js"; -import type { QueryStreamItem, Result, ResultError, Turn } from "./types.js"; +import type { + PermissionResponse, + QueryStreamItem, + Result, + ResultError, + Turn, + Usage, +} from "./types.js"; import { isConnectionUnusableError, SdkError } from "./errors.js"; interface QueueWaiter { @@ -33,6 +42,7 @@ export class Query implements AsyncIterable { readonly #ownsSession: boolean; readonly #closeTimeoutMs: number; readonly #closedHandlers = new Set<() => void>(); + readonly #pendingPermissionIds = new Set(); #lastSequence = 0; #terminal = false; #unsubscribe: () => void = () => {}; @@ -101,6 +111,34 @@ export class Query implements AsyncIterable { return this.#cancelPromise; } + async respondPermission( + requestId: string, + response: PermissionResponse, + ): Promise { + if (!this.#pendingPermissionIds.delete(requestId)) { + throw new Error("Permission request is unknown, expired, or already answered"); + } + if (response.decision !== "reject" && response.feedback !== undefined) { + throw new Error("Permission feedback is only valid when rejecting a request"); + } + const params: PermissionRespondParams = { + queryId: this.id, + sessionId: this.turn.sessionId, + turnId: this.turn.id, + operationId: this.operationId, + requestId, + decision: response.decision, + feedback: response.feedback, + }; + const result = await this.#connection.request( + "permission/respond", + params, + ); + if (result.requestId !== requestId || !result.accepted) { + throw new Error("SDK Host answered a different permission request"); + } + } + close(): Promise { this.#closePromise ??= this.#closeQuery(); return this.#closePromise; @@ -151,6 +189,43 @@ export class Query implements AsyncIterable { sequence: params.sequence, text: params.event.text, }); + } else if (params.event.type === "tool_event") { + this.#push({ + type: "tool_event", + queryId: params.queryId, + sessionId: params.sessionId, + turnId: params.turnId, + operationId: params.operationId, + sequence: params.sequence, + toolCallId: params.event.toolCallId, + toolName: params.event.toolName, + status: params.event.status, + ...(params.event.progress === undefined + ? {} + : { progress: params.event.progress }), + ...(params.event.durationMs === undefined + ? {} + : { durationMs: params.event.durationMs }), + }); + } else { + if (this.#pendingPermissionIds.has(params.event.requestId)) { + throw new Error("SDK Host repeated a pending permission request"); + } + this.#pendingPermissionIds.add(params.event.requestId); + this.#push({ + type: "permission_request", + queryId: params.queryId, + sessionId: params.sessionId, + turnId: params.turnId, + operationId: params.operationId, + sequence: params.sequence, + requestId: params.event.requestId, + action: params.event.action, + resources: params.event.resources, + source: params.event.source, + toolCallId: params.event.toolCallId ?? undefined, + responseTimeoutMs: params.event.responseTimeoutMs, + }); } } @@ -238,12 +313,14 @@ export class Query implements AsyncIterable { operationId: params.operationId, status: params.status, outputText: params.output.text, + ...(params.usage === undefined ? {} : { usage: mapUsage(params.usage) }), ...(params.error === undefined ? {} : { error: mapResultError(params.error) }), }; if (!this.#push(result)) { return; } this.#terminal = true; + this.#pendingPermissionIds.clear(); this.#resolveResult(result); this.#unsubscribe(); while (this.#waiters.length > 0) { @@ -317,6 +394,7 @@ export class Query implements AsyncIterable { return; } this.#terminal = true; + this.#pendingPermissionIds.clear(); this.#failure = error; this.#rejectResult(error); this.#unsubscribe(); @@ -337,13 +415,26 @@ export class Query implements AsyncIterable { } } +function mapUsage(usage: NonNullable): Usage { + return { + inputTokens: usage.inputTokens, + ...(usage.outputTokens === undefined + ? {} + : { outputTokens: usage.outputTokens }), + totalTokens: usage.totalTokens, + ...(usage.cachedTokens === undefined + ? {} + : { cachedTokens: usage.cachedTokens }), + }; +} + function bufferedItemBytes(item: QueryStreamItem): number { // Result frames have their own connection-level frame bound. Counting their // aggregate output again against the event backlog would reject a valid // Query that has not consumed the same text deltas yet. - return item.type === "assistant_text_delta" - ? Buffer.byteLength(item.text, "utf8") + 256 - : 0; + return item.type === "result" + ? 0 + : Buffer.byteLength(JSON.stringify(item), "utf8") + 128; } function mapResultError(error: QueryResultParams["error"]): ResultError { diff --git a/sdk/typescript/src/session.ts b/sdk/typescript/src/session.ts index c6b865e9b7..b4dac64dd1 100644 --- a/sdk/typescript/src/session.ts +++ b/sdk/typescript/src/session.ts @@ -4,8 +4,10 @@ import type { SessionCloseParams, SessionCreateParams, SessionCreateResult, + SessionResumeParams, } from "./internal/wire/index.js"; import type { JsonRpcConnection } from "./internal/json-rpc.js"; +import { normalizeInput } from "./internal/input.js"; import { withTimeout } from "./internal/deadline.js"; import { isConnectionUnusableError, SdkError } from "./errors.js"; import { Query } from "./query.js"; @@ -14,6 +16,7 @@ import type { SessionCreateInput, SessionLifetime, TurnInput } from "./types.js" export class Sessions { readonly #connection: JsonRpcConnection; readonly #cwd: string; + readonly #modelId: string; readonly #onQuery: (query: Query) => Query; readonly #onSession: (session: Session) => void; readonly #ensureClientOpen: () => void; @@ -22,6 +25,7 @@ export class Sessions { static forClient( connection: JsonRpcConnection, cwd: string, + modelId: string, onQuery: (query: Query) => Query, onSession: (session: Session) => void, ensureClientOpen: () => void, @@ -29,6 +33,7 @@ export class Sessions { return new Sessions( connection, cwd, + modelId, onQuery, onSession, ensureClientOpen, @@ -38,12 +43,14 @@ export class Sessions { private constructor( connection: JsonRpcConnection, cwd: string, + modelId: string, onQuery: (query: Query) => Query, onSession: (session: Session) => void, ensureClientOpen: () => void, ) { this.#connection = connection; this.#cwd = cwd; + this.#modelId = modelId; this.#onQuery = onQuery; this.#onSession = onSession; this.#ensureClientOpen = ensureClientOpen; @@ -55,7 +62,7 @@ export class Sessions { sessionName: input.sessionName ?? null, agent: input.agent ?? null, cwd: this.#cwd, - model: input.model ?? null, + model: this.#modelId, }; const created = await this.#connection.request( "session/create", @@ -65,6 +72,21 @@ export class Sessions { this.#onSession(session); return session; } + + async resume(sessionId: string): Promise { + this.#ensureClientOpen(); + if (sessionId.trim().length === 0) { + throw new Error("sessionId must not be empty"); + } + const params: SessionResumeParams = { sessionId }; + const resumed = await this.#connection.request( + "session/resume", + params, + ); + const session = Session.create(this.#connection, resumed, this.#onQuery); + this.#onSession(session); + return session; + } } export class Session { @@ -108,8 +130,10 @@ export class Session { async startTurn(input: TurnInput): Promise { this.#ensureOpen(); + const normalized = normalizeInput(input.prompt); const params: QueryStartParams = { - prompt: input.prompt, + prompt: normalized.prompt, + images: normalized.images, sessionId: this.id, }; const started = await this.#connection.request( diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index cd460482c4..edb633e987 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -1,15 +1,39 @@ +export type AgentModelProvider = + | "openai" + | "responses" + | "anthropic" + | "gemini"; + +export interface AgentModelOptions { + provider: AgentModelProvider; + model: string; + apiKey: string; + baseUrl?: string; +} + export interface AgentClientOptions { cwd: string; - /** Native `bitfun-sdk-host` path. Platform packages will provide this later. */ + /** Advanced absolute-path override for the package-local native Host. */ hostPath?: string; /** Deadline for the SDK Host initialize handshake. */ initializeTimeoutMs?: number; + /** Process-lifetime model credentials installed into this Host connection. */ + model: AgentModelOptions; } export interface AgentCapabilities { query: boolean; sessions: boolean; cancellation: boolean; + eventStream: boolean; + toolEvents: boolean; + imageInput: boolean; + permissionResponses: boolean; + structuredOutput: boolean; + usage: boolean; + customTools: boolean; + hooks: boolean; + mcpConfiguration: boolean; } export type SdkErrorCode = @@ -61,22 +85,26 @@ export interface SdkErrorDetails { recovery?: RecoveryAction; } -export type SessionLifetime = "connection"; +export type SessionLifetime = "connection" | "durable"; + +export type UserInput = + | { type: "text"; text: string } + | { type: "local_image"; path: string }; + +export type Input = string | readonly UserInput[]; export interface QueryInput { - prompt: string; + prompt: Input; agent?: string; - model?: string; } export interface SessionCreateInput { sessionName?: string; agent?: string; - model?: string; } export interface TurnInput { - prompt: string; + prompt: Input; } export interface Turn { @@ -94,12 +122,60 @@ export interface AssistantTextDelta { text: string; } +export interface ToolEvent { + type: "tool_event"; + queryId: string; + sessionId: string; + turnId: string; + operationId: string; + sequence: number; + toolCallId: string; + toolName: string; + status: "started" | "progress" | "completed" | "failed" | "cancelled"; + progress?: number; + durationMs?: number; +} + +export interface PermissionSource { + kind: "tool_call" | "provider" | "extension"; + identity: string; +} + +export interface PermissionRequestEvent { + type: "permission_request"; + queryId: string; + sessionId: string; + turnId: string; + operationId: string; + sequence: number; + requestId: string; + action: string; + resources: readonly string[]; + source: PermissionSource; + toolCallId?: string; + responseTimeoutMs: number; +} + +export type PermissionDecision = "allow_once" | "allow_always" | "reject"; + +export interface PermissionResponse { + decision: PermissionDecision; + feedback?: string; +} + export type ResultStatus = "completed" | "failed" | "cancelled"; export interface ResultError extends SdkErrorDetails { message: string; } +export interface Usage { + inputTokens: number; + outputTokens?: number; + totalTokens: number; + cachedTokens?: number; +} + export interface Result { type: "result"; queryId: string; @@ -108,7 +184,12 @@ export interface Result { operationId: string; status: ResultStatus; outputText: string; + usage?: Usage; error?: ResultError; } -export type QueryStreamItem = AssistantTextDelta | Result; +export type QueryStreamItem = + | AssistantTextDelta + | ToolEvent + | PermissionRequestEvent + | Result; diff --git a/sdk/typescript/test/client.test.ts b/sdk/typescript/test/client.test.ts index b9b9106aac..3a8eb823ef 100644 --- a/sdk/typescript/test/client.test.ts +++ b/sdk/typescript/test/client.test.ts @@ -5,11 +5,49 @@ import test from "node:test"; import { AgentClient, SdkError } from "../src/index.js"; import { createAgentClient } from "../src/internal/client.js"; +import type { + AgentClientOptions, + Input, + QueryInput, + SessionCreateInput, + UserInput, +} from "../src/types.js"; -test("a Query streams ordered events and returns the Host terminal Result", async () => { +const clientOptions = { + cwd: "D:/workspace/project", + hostPath: process.execPath, + model: { + provider: "openai" as const, + model: "fixture-model", + apiKey: "fixture-secret", + baseUrl: "http://127.0.0.1:43123/v1", + }, +} satisfies AgentClientOptions; + +const packageHostOptions = { + cwd: "D:/workspace/project", + model: clientOptions.model, +} satisfies AgentClientOptions; + +// @ts-expect-error Query model selection is bound at AgentClient.start. +const queryModelOverride: QueryInput = { prompt: "hello", model: "attempted-override" }; +// @ts-expect-error Session model selection is bound at AgentClient.start. +const sessionModelOverride: SessionCreateInput = { model: "attempted-override" }; +void queryModelOverride; +void sessionModelOverride; +void packageHostOptions; + +const multimodalInput: Input = [ + { type: "text", text: "hello" }, + { type: "local_image", path: "screenshots/fixture.png" }, +] satisfies UserInput[]; +void multimodalInput; + +test("a Query streams tool and permission events before the terminal Result", async () => { const clientToHost = new PassThrough(); const hostToClient = new PassThrough(); - const host = runFixtureHost(clientToHost, hostToClient); + const initializeRequests: unknown[] = []; + const host = runFixtureHost(clientToHost, hostToClient, initializeRequests); const client = await createAgentClient( { readable: hostToClient, @@ -19,27 +57,107 @@ test("a Query streams ordered events and returns the Host terminal Result", asyn await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); assert.ok(client instanceof AgentClient); - const query = await client.query({ prompt: "hello" }); + assert.equal(initializeRequests.length, 1); + assert.deepEqual(initializeRequests[0], { + protocolVersion: 5, + clientInfo: { name: "@bitfun/agent-sdk", version: "0.0.0" }, + capabilities: { + serverNotifications: true, + permissionResponses: true, + }, + model: { + provider: "openai", + model: "fixture-model", + apiKey: "fixture-secret", + baseUrl: "http://127.0.0.1:43123/v1", + }, + }); + const query = await client.query({ + prompt: [ + { type: "text", text: "hello" }, + { type: "local_image", path: "screenshots/fixture.png" }, + { type: "text", text: "focus on the layout" }, + ], + model: "attempted-override", + } as QueryInput); assert.equal(query.id, "query-1"); assert.equal(query.operationId, "operation-1"); assert.deepEqual(query.turn, { id: "turn-1", sessionId: "session-1" }); + assert.deepEqual(client.capabilities, { + query: true, + sessions: true, + cancellation: true, + eventStream: true, + toolEvents: true, + imageInput: true, + permissionResponses: true, + structuredOutput: false, + usage: true, + customTools: false, + hooks: false, + mcpConfiguration: false, + }); const items = []; for await (const item of query) { items.push(item); + if (item.type === "permission_request") { + await query.respondPermission(item.requestId, { decision: "allow_once" }); + await assert.rejects( + query.respondPermission(item.requestId, { decision: "allow_once" }), + /unknown, expired, or already answered/, + ); + } } assert.deepEqual(items, [ { - type: "assistant_text_delta", + type: "tool_event", queryId: "query-1", sessionId: "session-1", turnId: "turn-1", operationId: "operation-1", sequence: 1, + toolCallId: "tool-1", + toolName: "Read", + status: "started", + }, + { + type: "permission_request", + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 2, + requestId: "permission-1", + action: "read", + resources: ["README.md"], + source: { kind: "tool_call", identity: "Read" }, + toolCallId: "tool-1", + responseTimeoutMs: 120_000, + }, + { + type: "tool_event", + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 3, + toolCallId: "tool-1", + toolName: "Read", + status: "completed", + durationMs: 12, + }, + { + type: "assistant_text_delta", + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 4, text: "fixture result", }, { @@ -50,6 +168,12 @@ test("a Query streams ordered events and returns the Host terminal Result", asyn operationId: "operation-1", status: "completed", outputText: "fixture result", + usage: { + inputTokens: 100, + outputTokens: 25, + totalTokens: 125, + cachedTokens: 40, + }, }, ]); assert.equal((await query.result()).outputText, "fixture result"); @@ -73,14 +197,20 @@ test("an explicit Session starts Turns on the existing client connection", async await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); - const session = await client.sessions.create({ agent: "agentic" }); + const session = await client.sessions.create({ + agent: "agentic", + model: "attempted-override", + } as SessionCreateInput); assert.equal(session.id, "session-explicit"); assert.equal(session.agent, "agentic"); + assert.equal(session.lifetime, "durable"); - const query = await session.startTurn({ prompt: "continue" }); + const query = await session.startTurn({ + prompt: [{ type: "local_image", path: "screenshots/continued.webp" }], + }); assert.equal((await query.result()).outputText, "continued"); await session.close(); assert.equal(typeof session[Symbol.asyncDispose], "function"); @@ -95,6 +225,37 @@ test("an explicit Session starts Turns on the existing client connection", async ]); }); +test("a durable Session resumes on a new client connection", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + const methods: string[] = []; + const host = runResumeFixtureHost(clientToHost, hostToClient, methods); + const client = await createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + clientToHost.end(); + await host; + }, + }, + clientOptions, + ); + + const session = await client.sessions.resume("session-persisted"); + assert.equal(session.id, "session-persisted"); + assert.equal(session.lifetime, "durable"); + await session.close(); + await client.close(); + + assert.deepEqual(methods, [ + "initialize", + "session/resume", + "session/close", + "shutdown", + ]); +}); + test("Query cancel and close are idempotent and the Host Result remains authoritative", async () => { const clientToHost = new PassThrough(); const hostToClient = new PassThrough(); @@ -111,7 +272,7 @@ test("Query cancel and close are idempotent and the Host Result remains authorit await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); const query = await client.query({ prompt: "wait" }); @@ -146,7 +307,7 @@ test("leaving Query iteration early cancels and settles the Turn", async () => { await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); const query = await client.query({ prompt: "stream" }); @@ -175,7 +336,7 @@ test("Host loss rejects an accepted Query with unknown outcome instead of fabric await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); const query = await client.query({ prompt: "may have side effects" }); @@ -192,6 +353,36 @@ test("Host loss rejects an accepted Query with unknown outcome instead of fabric assert.equal(transportClosed, true); }); +test("an empty initialized model id fails the connection closed", async () => { + const clientToHost = new PassThrough(); + const hostToClient = new PassThrough(); + let transportClosed = false; + const host = runEmptyModelIdFixtureHost(clientToHost, hostToClient); + + await assert.rejects( + createAgentClient( + { + readable: hostToClient, + writable: clientToHost, + close: async () => { + transportClosed = true; + clientToHost.end(); + await host; + }, + }, + clientOptions, + ), + (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "process_lost"); + assert.equal(error.stage, "protocol"); + assert.equal(error.outcomeCertainty, "unknown"); + return true; + }, + ); + assert.equal(transportClosed, true); +}); + test("AgentClient.close settles owned Queries before shutting down its connection", async () => { const clientToHost = new PassThrough(); const hostToClient = new PassThrough(); @@ -206,7 +397,7 @@ test("AgentClient.close settles owned Queries before shutting down its connectio await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); const query = await client.query({ prompt: "still running" }); @@ -234,7 +425,7 @@ test("Host operation errors preserve stable SDK error facts", async () => { await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); await assert.rejects(client.query({ prompt: "requires auth" }), (error: unknown) => { @@ -264,7 +455,7 @@ test("unknown Host error facts fail the protocol closed", async () => { await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); await assert.rejects(client.query({ prompt: "invalid error" }), (error: unknown) => { @@ -289,7 +480,7 @@ test("ambiguous JSON-RPC response envelopes fail the protocol closed", async () await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); await assert.rejects(client.query({ prompt: "reject ambiguous response" }), (error: unknown) => { @@ -356,7 +547,7 @@ test("unknown Query event and Result status fail the protocol closed", async (co await host; }, }, - { cwd: "D:/workspace/project" }, + clientOptions, ); try { @@ -377,6 +568,7 @@ test("unknown Query event and Result status fail the protocol closed", async (co async function runFixtureHost( requests: PassThrough, responses: PassThrough, + initializeRequests: unknown[], ): Promise { const lines = createInterface({ input: requests, crlfDelay: Infinity }); for await (const line of lines) { @@ -386,33 +578,47 @@ async function runFixtureHost( params: Record; }; if (request.method === "initialize") { + initializeRequests.push(request.params); write(responses, { jsonrpc: "2.0", id: request.id, result: { - protocolVersion: 1, + protocolVersion: 5, runtimeVersion: "0.2.17", stability: "not_delivered", capabilities: { sessionCreate: true, - sessionCreateLifetime: "connection", + sessionCreateLifetime: "durable", + sessionResume: true, query: true, queryCancel: true, sessionClose: true, eventStream: true, + toolEvents: true, + imageInput: true, structuredOutput: false, - usage: false, + usage: true, customTools: false, - permissionCallbacks: false, + permissionResponses: true, hooks: false, mcpConfiguration: false, prestartedTransport: false, }, + modelId: "sdk:openai:resolved", }, }); continue; } if (request.method === "query/start") { + assert.deepEqual(request.params, { + prompt: "hello\n\nfocus on the layout", + images: ["screenshots/fixture.png"], + sessionId: null, + sessionName: null, + agent: null, + cwd: "D:/workspace/project", + model: "sdk:openai:resolved", + }); write(responses, { jsonrpc: "2.0", id: request.id, @@ -435,6 +641,77 @@ async function runFixtureHost( turnId: "turn-1", operationId: "operation-1", sequence: 1, + event: { + type: "tool_event", + toolCallId: "tool-1", + toolName: "Read", + status: "started", + }, + }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 2, + event: { + type: "permission_request", + requestId: "permission-1", + action: "read", + resources: ["README.md"], + source: { kind: "tool_call", identity: "Read" }, + toolCallId: "tool-1", + responseTimeoutMs: 120_000, + }, + }, + }); + continue; + } + if (request.method === "permission/respond") { + assert.deepEqual(request.params, { + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + requestId: "permission-1", + decision: "allow_once", + }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { requestId: "permission-1", accepted: true }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 3, + event: { + type: "tool_event", + toolCallId: "tool-1", + toolName: "Read", + status: "completed", + durationMs: 12, + }, + }, + }); + write(responses, { + jsonrpc: "2.0", + method: "query/event", + params: { + queryId: "query-1", + sessionId: "session-1", + turnId: "turn-1", + operationId: "operation-1", + sequence: 4, event: { type: "assistant_text_delta", text: "fixture result" }, }, }); @@ -448,6 +725,12 @@ async function runFixtureHost( operationId: "operation-1", status: "completed", output: { text: "fixture result" }, + usage: { + inputTokens: 100, + outputTokens: 25, + totalTokens: 125, + cachedTokens: 40, + }, }, }); continue; @@ -495,7 +778,7 @@ async function runSessionFixtureHost( sessionName: null, agent: "agentic", cwd: "D:/workspace/project", - model: null, + model: "sdk:openai:resolved", }); write(responses, { jsonrpc: "2.0", @@ -504,14 +787,15 @@ async function runSessionFixtureHost( sessionId: "session-explicit", sessionName: "Explicit", agent: "agentic", - lifetime: "connection", + lifetime: "durable", }, }); continue; } if (request.method === "query/start") { assert.deepEqual(request.params, { - prompt: "continue", + prompt: "", + images: ["screenshots/continued.webp"], sessionId: "session-explicit", }); write(responses, { @@ -565,6 +849,59 @@ async function runSessionFixtureHost( } } +async function runResumeFixtureHost( + requests: PassThrough, + responses: PassThrough, + methods: string[], +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { + id: number; + method: string; + params: Record; + }; + methods.push(request.method); + if (request.method === "initialize") { + write(responses, initializeResponse(request.id)); + continue; + } + if (request.method === "session/resume") { + assert.deepEqual(request.params, { sessionId: "session-persisted" }); + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { + sessionId: "session-persisted", + sessionName: "Persisted", + agent: "agentic", + lifetime: "durable", + workspacePath: "D:/workspace/project", + }, + }); + continue; + } + if (request.method === "session/close") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { sessionId: "session-persisted", unloaded: true }, + }); + continue; + } + if (request.method === "shutdown") { + write(responses, { + jsonrpc: "2.0", + id: request.id, + result: { accepted: true }, + }); + responses.end(); + return; + } + throw new Error(`Unexpected fixture method: ${request.method}`); + } +} + async function runCancelFixtureHost( requests: PassThrough, responses: PassThrough, @@ -807,6 +1144,23 @@ async function runClientCloseFixtureHost( } } +async function runEmptyModelIdFixtureHost( + requests: PassThrough, + responses: PassThrough, +): Promise { + const lines = createInterface({ input: requests, crlfDelay: Infinity }); + for await (const line of lines) { + const request = JSON.parse(line) as { id: number; method: string }; + assert.equal(request.method, "initialize"); + const response = initializeResponse(request.id) as { + result: { modelId: string }; + }; + response.result.modelId = ""; + write(responses, response); + } + responses.end(); +} + async function runOperationErrorFixtureHost( requests: PassThrough, responses: PassThrough, @@ -989,24 +1343,28 @@ function initializeResponse(id: number): unknown { jsonrpc: "2.0", id, result: { - protocolVersion: 1, + protocolVersion: 5, runtimeVersion: "0.2.17", stability: "not_delivered", capabilities: { sessionCreate: true, - sessionCreateLifetime: "connection", + sessionCreateLifetime: "durable", + sessionResume: true, query: true, queryCancel: true, sessionClose: true, eventStream: true, + toolEvents: true, + imageInput: true, structuredOutput: false, - usage: false, + usage: true, customTools: false, - permissionCallbacks: false, + permissionResponses: true, hooks: false, mcpConfiguration: false, prestartedTransport: false, }, + modelId: "sdk:openai:resolved", }, }; } diff --git a/sdk/typescript/test/fixtures/host.mjs b/sdk/typescript/test/fixtures/host.mjs index 0fea572501..17c0b11f47 100644 --- a/sdk/typescript/test/fixtures/host.mjs +++ b/sdk/typescript/test/fixtures/host.mjs @@ -4,28 +4,38 @@ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); for await (const line of lines) { const request = JSON.parse(line); if (request.method === "initialize") { + if ( + request.params?.protocolVersion !== 5 || + request.params?.model?.apiKey !== "fixture-secret" + ) { + throw new Error("Invalid initialize request"); + } write({ jsonrpc: "2.0", id: request.id, result: { - protocolVersion: 1, + protocolVersion: 5, runtimeVersion: "fixture", stability: "not_delivered", capabilities: { sessionCreate: true, - sessionCreateLifetime: "connection", + sessionCreateLifetime: "durable", + sessionResume: true, query: true, queryCancel: true, sessionClose: true, eventStream: true, + toolEvents: true, + imageInput: true, structuredOutput: false, - usage: false, + usage: true, customTools: false, - permissionCallbacks: false, + permissionResponses: true, hooks: false, mcpConfiguration: false, prestartedTransport: false, }, + modelId: "sdk:openai:resolved", }, }); continue; diff --git a/sdk/typescript/test/lifecycle-timeouts.test.ts b/sdk/typescript/test/lifecycle-timeouts.test.ts index b63903a481..a633ceef50 100644 --- a/sdk/typescript/test/lifecycle-timeouts.test.ts +++ b/sdk/typescript/test/lifecycle-timeouts.test.ts @@ -13,6 +13,13 @@ import type { import { Query } from "../src/query.js"; import { Session } from "../src/session.js"; +const model = { + provider: "openai" as const, + model: "fixture-model", + apiKey: "fixture-secret", + baseUrl: "http://127.0.0.1:43123/v1", +}; + test("client initialization aborts the Host connection after its startup deadline", async () => { const clientToHost = new PassThrough(); const hostToClient = new PassThrough(); @@ -27,7 +34,7 @@ test("client initialization aborts the Host connection after its startup deadlin hostToClient.end(); }, }, - { cwd: "D:/workspace/project", initializeTimeoutMs: 20 }, + { cwd: "D:/workspace/project", initializeTimeoutMs: 20, model }, ); await assert.rejects(withTestDeadline(starting), (error: unknown) => { @@ -123,7 +130,7 @@ test("Session.close aborts an unresponsive Host after its cleanup deadline", asy sessionId: "session-timeout", sessionName: "timeout", agent: "agentic", - lifetime: "connection", + lifetime: "durable", }; const createSession = Session.create as unknown as ( owner: JsonRpcConnection, diff --git a/sdk/typescript/test/local-package-consumer.mjs b/sdk/typescript/test/local-package-consumer.mjs new file mode 100644 index 0000000000..0f10a1374a --- /dev/null +++ b/sdk/typescript/test/local-package-consumer.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const isolatedRoot = await mkdtemp(join(tmpdir(), "bitfun-sdk-consumer-")); +const packedRoot = join(isolatedRoot, "packed"); +const consumerRoot = join(isolatedRoot, "consumer"); +const workspace = join(isolatedRoot, "workspace"); +const userRoot = join(isolatedRoot, "user-root"); +const configRoot = join(isolatedRoot, "config-root"); + +try { + await Promise.all( + [packedRoot, consumerRoot, workspace, userRoot, configRoot].map((path) => + mkdir(path, { recursive: true }), + ), + ); + const packed = await runNpm([ + "pack", + packageRoot, + "--pack-destination", + packedRoot, + "--json", + ], isolatedRoot); + const packResult = JSON.parse(packed.stdout); + assert.equal(Array.isArray(packResult), true); + assert.equal(packResult.length, 1); + const tarball = join(packedRoot, packResult[0].filename); + + await writeFile( + join(consumerRoot, "package.json"), + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + ); + await runNpm( + ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], + consumerRoot, + ); + await writeFile( + join(consumerRoot, "run.mjs"), + ` +import assert from "node:assert/strict"; +import { AgentClient } from "@bitfun/agent-sdk"; + +const client = await AgentClient.start({ + cwd: process.cwd(), + model: { + provider: "openai", + model: "fixture-model", + apiKey: "local-consumer-fixture", + baseUrl: "http://127.0.0.1:9/v1", + }, +}); +try { + assert.equal(client.capabilities.query, true); + assert.equal(client.capabilities.toolEvents, true); + assert.equal(client.capabilities.imageInput, true); + assert.equal(client.capabilities.usage, true); + assert.equal(client.capabilities.permissionResponses, true); +} finally { + await client.close(); +} +process.stdout.write("local-package-consumer: PASS\\n"); +`, + ); + + const result = await run(process.execPath, [join(consumerRoot, "run.mjs")], consumerRoot, { + ...process.env, + BITFUN_E2E_STORAGE_GUARD: "1", + BITFUN_E2E_USER_ROOT: userRoot, + BITFUN_E2E_HOME: userRoot, + APPDATA: configRoot, + XDG_CONFIG_HOME: configRoot, + HOME: userRoot, + USERPROFILE: userRoot, + }); + assert.match(result.stdout, /local-package-consumer: PASS/); + process.stdout.write("local-package-consumer-smoke: PASS\n"); +} finally { + await rm(isolatedRoot, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }); +} + +function runNpm(args, cwd) { + if (process.platform !== "win32") { + return run("npm", args, cwd); + } + const npmCli = join( + dirname(process.execPath), + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + return run(process.execPath, [npmCli, ...args], cwd); +} + +async function run(command, args, cwd, env = process.env) { + const child = spawn(command, args, { + cwd, + env, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout = appendBounded(stdout, chunk); + }); + child.stderr.on("data", (chunk) => { + stderr = appendBounded(stderr, chunk); + }); + const exit = await new Promise((resolveExit, rejectExit) => { + child.once("error", rejectExit); + child.once("exit", (code, signal) => resolveExit({ code, signal })); + }); + if (exit.code !== 0) { + throw new Error( + `${command} failed with ${exit.signal ?? `exit ${String(exit.code)}`}: ${stderr}`, + ); + } + return { stdout, stderr }; +} + +function appendBounded(current, chunk) { + const next = current + chunk; + if (Buffer.byteLength(next, "utf8") > 1024 * 1024) { + throw new Error("local package command output exceeded its size limit"); + } + return next; +} diff --git a/sdk/typescript/test/managed-host.test.ts b/sdk/typescript/test/managed-host.test.ts index 9667f012ba..9def63ca73 100644 --- a/sdk/typescript/test/managed-host.test.ts +++ b/sdk/typescript/test/managed-host.test.ts @@ -1,11 +1,21 @@ import assert from "node:assert/strict"; import { once } from "node:events"; +import { basename, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { AgentClient, SdkError } from "../src/index.js"; import { createAgentClient } from "../src/internal/client.js"; +import { resolveHostPath } from "../src/internal/host-path.js"; import { forceKillTree, startManagedHost } from "../src/internal/managed-host.js"; +import type { AgentClientOptions } from "../src/types.js"; + +const model = { + provider: "openai" as const, + model: "fixture-model", + apiKey: "fixture-secret", + baseUrl: "http://127.0.0.1:43123/v1", +}; test("the managed transport owns one child Host process", async () => { const fixture = fileURLToPath( @@ -16,7 +26,7 @@ test("the managed transport owns one child Host process", async () => { args: [fixture], cwd: process.cwd(), }); - const client = await createAgentClient(transport, { cwd: process.cwd() }); + const client = await createAgentClient(transport, { cwd: process.cwd(), model }); assert.ok(client instanceof AgentClient); await client.close(); @@ -30,6 +40,7 @@ test("AgentClient.start reports a missing Host before an operation begins", asyn hostPath: fileURLToPath( new URL("../../../../test/fixtures/missing-host", import.meta.url), ), + model, }), (error: unknown) => { assert.ok(error instanceof SdkError); @@ -41,6 +52,100 @@ test("AgentClient.start reports a missing Host before an operation begins", asyn ); }); +test("the Host resolver uses the package-local executable without environment fallback", () => { + const previousHostPath = process.env.BITFUN_SDK_HOST_PATH; + process.env.BITFUN_SDK_HOST_PATH = process.execPath; + try { + const executableName = process.platform === "win32" ? "bitfun-sdk-host.exe" : "bitfun-sdk-host"; + const expectedHost = fileURLToPath( + new URL( + `../native/${process.platform}-${process.arch}/${executableName}`, + import.meta.url, + ), + ); + assert.equal(resolveHostPath(), expectedHost); + assert.notEqual(resolveHostPath(), process.execPath); + } finally { + if (previousHostPath === undefined) { + delete process.env.BITFUN_SDK_HOST_PATH; + } else { + process.env.BITFUN_SDK_HOST_PATH = previousHostPath; + } + } +}); + +test("AgentClient.start rejects a relative Host override before spawning", async () => { + await assert.rejects( + AgentClient.start({ + cwd: dirname(process.execPath), + hostPath: basename(process.execPath), + initializeTimeoutMs: 100, + model, + }), + (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "invalid_request"); + assert.equal(error.stage, "initialize"); + assert.equal(error.outcomeCertainty, "not_started"); + assert.doesNotMatch(String(error.stack), /fixture-secret/); + return true; + }, + ); +}); + +test("AgentClient.start rejects invalid model options before spawning a Host", async (context) => { + const missingHost = fileURLToPath( + new URL("../../../../test/fixtures/missing-host", import.meta.url), + ); + const cases: Array<{ name: string; options: unknown }> = [ + { + name: "missing model", + options: { cwd: process.cwd(), hostPath: missingHost }, + }, + { + name: "blank model", + options: { cwd: process.cwd(), hostPath: missingHost, model: { ...model, model: " " } }, + }, + { + name: "blank API key", + options: { cwd: process.cwd(), hostPath: missingHost, model: { ...model, apiKey: " " } }, + }, + { + name: "unsupported provider", + options: { + cwd: process.cwd(), + hostPath: missingHost, + model: { ...model, provider: "unsupported" }, + }, + }, + { + name: "invalid base URL", + options: { + cwd: process.cwd(), + hostPath: missingHost, + model: { ...model, baseUrl: "not an absolute URL" }, + }, + }, + ]; + + for (const fixture of cases) { + await context.test(fixture.name, async () => { + await assert.rejects( + AgentClient.start(fixture.options as AgentClientOptions), + (error: unknown) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "invalid_request"); + assert.equal(error.stage, "initialize"); + assert.equal(error.retryable, false); + assert.equal(error.outcomeCertainty, "not_started"); + assert.doesNotMatch(String(error.stack), /fixture-secret/); + return true; + }, + ); + }); + } +}); + test("forced managed Host cleanup reclaims its descendant process tree", async () => { const fixture = fileURLToPath( new URL("../../../../test/fixtures/unresponsive-host.mjs", import.meta.url), diff --git a/sdk/typescript/test/public-contract.test.ts b/sdk/typescript/test/public-contract.test.ts index 531b636ddf..a544bb5abd 100644 --- a/sdk/typescript/test/public-contract.test.ts +++ b/sdk/typescript/test/public-contract.test.ts @@ -41,6 +41,7 @@ test("package files keep internal declarations and wire DTOs private", async () "dist/sdk/typescript/src/*.d.ts", "dist/sdk/typescript/src/*.js", "dist/sdk/typescript/src/internal/*.js", + "dist/sdk/typescript/native/**", "dist/src/crates/adapters/transport/typescript/src/*.js", "README.md", ]); diff --git a/sdk/typescript/test/real-host-smoke.mjs b/sdk/typescript/test/real-host-smoke.mjs new file mode 100644 index 0000000000..c3bc8b4232 --- /dev/null +++ b/sdk/typescript/test/real-host-smoke.mjs @@ -0,0 +1,449 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { AgentClient, SdkError } from "../dist/sdk/typescript/src/index.js"; + +const scriptPath = resolve(process.argv[1]); +const packageRoot = resolve(dirname(scriptPath), ".."); +const MAX_CAPTURED_OUTPUT_BYTES = 1024 * 1024; +const WORKER_TIMEOUT_MS = 120_000; + +if (process.argv.includes("--worker")) { + await runWorker(); +} else { + await runParent(); +} + +async function runParent() { + const isolatedRoot = await mkdtemp(join(tmpdir(), "bitfun-sdk-real-host-")); + const workspace = join(isolatedRoot, "workspace"); + const userRoot = join(isolatedRoot, "user-root"); + const home = join(isolatedRoot, "home"); + const configRoot = join(isolatedRoot, "config-root"); + await Promise.all( + [workspace, userRoot, home, configRoot].map((directory) => + mkdir(directory, { recursive: true }), + ), + ); + + const apiKey = `bitfun-sdk-${randomBytes(24).toString("hex")}`; + let requestCount = 0; + const requestTraces = []; + let fixtureFailure; + const server = createServer(async (request, response) => { + requestCount += 1; + try { + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + throw new Error("SSE fixture received an unexpected request target"); + } + if (request.headers.authorization !== `Bearer ${apiKey}`) { + throw new Error("SSE fixture received invalid authorization"); + } + const body = await readRequestJson(request); + if (body.model !== "fixture-model" || body.stream !== true) { + throw new Error("SSE fixture received an invalid model request"); + } + requestTraces.push(summarizeModelRequest(requestCount, request.url, body)); + + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + response.end( + [ + 'data: {"id":"fixture","object":"chat.completion.chunk","model":"fixture-model","choices":[{"index":0,"delta":{"role":"assistant","content":"BitFun SDK "},"finish_reason":null}]}', + 'data: {"id":"fixture","object":"chat.completion.chunk","model":"fixture-model","choices":[{"index":0,"delta":{"content":"fixture response"},"finish_reason":null}]}', + 'data: {"id":"fixture","object":"chat.completion.chunk","model":"fixture-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + "data: [DONE]", + "", + ].join("\n\n"), + ); + } catch (error) { + fixtureFailure ??= error; + response.writeHead(400, { "content-type": "text/plain" }); + response.end("SSE fixture rejected the request"); + } + }); + + let worker; + try { + const address = await listenLocalhost(server); + const workerEnvironment = { + BITFUN_SDK_SMOKE_BASE_URL: `http://127.0.0.1:${String(address.port)}/v1`, + BITFUN_SDK_SMOKE_WORKSPACE: workspace, + BITFUN_E2E_STORAGE_GUARD: "1", + BITFUN_E2E_USER_ROOT: userRoot, + BITFUN_E2E_HOME: home, + APPDATA: configRoot, + XDG_CONFIG_HOME: configRoot, + HOME: home, + USERPROFILE: home, + }; + assert.equal(Object.values(workerEnvironment).includes(apiKey), false); + + worker = spawn(process.execPath, [scriptPath, "--worker"], { + cwd: packageRoot, + env: workerEnvironment, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const output = captureWorkerOutput(worker); + worker.stdin.end(`${apiKey}\n`); + let exit; + try { + exit = await waitForWorker(worker, WORKER_TIMEOUT_MS); + } catch (error) { + const captured = output(); + const phase = lastWorkerPhase(captured.stdout); + throw new Error( + [ + `real Host smoke worker failed at ${phase} after ${String(requestCount)} model requests`, + formatRequestTraces(requestTraces, apiKey), + ].join("\n"), + { cause: error }, + ); + } + const captured = output(); + + assert.equal(exit.signal, null, "real Host smoke worker was terminated"); + assert.equal(captured.stdout.includes(apiKey), false, "API key leaked to worker stdout"); + assert.equal(captured.stderr.includes(apiKey), false, "API key leaked to worker stderr"); + assert.equal( + exit.code, + 0, + `real Host smoke worker failed: ${captured.stderr}`, + ); + if (fixtureFailure !== undefined) { + throw fixtureFailure; + } + assert.equal(requestCount, 1, "real Host smoke must issue exactly one model request"); + await assertTreeDoesNotContain(isolatedRoot, apiKey); + process.stdout.write("real-host-smoke: PASS\n"); + } finally { + if (worker !== undefined && worker.exitCode === null) { + worker.kill(); + } + await closeServer(server); + await rm(isolatedRoot, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }); + } +} + +async function runWorker() { + const apiKey = await readApiKeyFromStdin(); + const workspace = requiredEnvironment("BITFUN_SDK_SMOKE_WORKSPACE"); + const baseUrl = requiredEnvironment("BITFUN_SDK_SMOKE_BASE_URL"); + const missingHost = join(workspace, "missing-bitfun-sdk-host"); + + const validModel = { + provider: "openai", + model: "fixture-model", + apiKey, + baseUrl, + }; + const invalidOptions = [ + { cwd: workspace, hostPath: missingHost }, + { cwd: workspace, hostPath: missingHost, model: { ...validModel, model: " " } }, + { cwd: workspace, hostPath: missingHost, model: { ...validModel, apiKey: " " } }, + { + cwd: workspace, + hostPath: missingHost, + model: { ...validModel, provider: "unsupported" }, + }, + { + cwd: workspace, + hostPath: missingHost, + model: { ...validModel, baseUrl: "not an absolute URL" }, + }, + ]; + for (const options of invalidOptions) { + await assert.rejects(AgentClient.start(options), (error) => { + assert.ok(error instanceof SdkError); + assert.equal(error.code, "invalid_request"); + assert.equal(error.stage, "initialize"); + assert.equal(error.outcomeCertainty, "not_started"); + assert.equal(renderError(error).includes(apiKey), false); + return true; + }); + } + process.stdout.write("phase:validation_complete\n"); + + const client = await AgentClient.start({ + cwd: workspace, + model: validModel, + }); + process.stdout.write("phase:client_started\n"); + let query; + try { + query = await client.query({ prompt: "Return the fixture response" }); + process.stdout.write("phase:query_started\n"); + const items = []; + for await (const item of query) { + items.push(item); + } + const result = await query.result(); + const deltas = items.filter((item) => item.type === "assistant_text_delta"); + const terminalResults = items.filter((item) => item.type === "result"); + + assert.equal(deltas.map((item) => item.text).join(""), "BitFun SDK fixture response"); + assert.equal(terminalResults.length, 1); + assert.deepEqual(terminalResults[0], result); + assert.equal(result.status, "completed"); + assert.equal(result.outputText, "BitFun SDK fixture response"); + assert.equal(JSON.stringify(items).includes(apiKey), false); + process.stdout.write("phase:result_received\n"); + } finally { + if (query !== undefined) { + await query.close(); + process.stdout.write("phase:query_closed\n"); + } + await client.close(); + process.stdout.write("phase:client_closed\n"); + } +} + +async function readRequestJson(request) { + const chunks = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > 4 * 1024 * 1024) { + throw new Error("SSE fixture request exceeded its size limit"); + } + chunks.push(buffer); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function listenLocalhost(server) { + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectPromise); + resolvePromise(); + }); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("SSE fixture did not bind a TCP address"); + } + return address; +} + +function captureWorkerOutput(worker) { + let stdout = ""; + let stderr = ""; + let captureFailure; + worker.stdout.setEncoding("utf8"); + worker.stderr.setEncoding("utf8"); + worker.stdout.on("data", (chunk) => { + try { + stdout = appendBounded(stdout, chunk); + } catch (error) { + captureFailure ??= error; + worker.kill(); + } + }); + worker.stderr.on("data", (chunk) => { + try { + stderr = appendBounded(stderr, chunk); + } catch (error) { + captureFailure ??= error; + worker.kill(); + } + }); + return () => { + if (captureFailure !== undefined) { + throw captureFailure; + } + return { stdout, stderr }; + }; +} + +function appendBounded(current, chunk) { + const next = current + chunk; + if (Buffer.byteLength(next, "utf8") > MAX_CAPTURED_OUTPUT_BYTES) { + throw new Error("real Host smoke worker output exceeded its size limit"); + } + return next; +} + +async function waitForWorker(worker, timeoutMs) { + return new Promise((resolvePromise, rejectPromise) => { + let timedOut = false; + let terminationTimeout; + const timeout = setTimeout(() => { + timedOut = true; + worker.kill(); + terminationTimeout = setTimeout(() => { + rejectPromise(new Error("real Host smoke worker did not exit after its deadline")); + }, 5_000); + }, timeoutMs); + worker.once("error", (error) => { + clearTimeout(timeout); + clearTimeout(terminationTimeout); + rejectPromise(error); + }); + worker.once("exit", (code, signal) => { + clearTimeout(timeout); + clearTimeout(terminationTimeout); + if (timedOut) { + rejectPromise(new Error("real Host smoke worker exceeded its deadline")); + } else { + resolvePromise({ code, signal }); + } + }); + }); +} + +function lastWorkerPhase(stdout) { + const phases = [...stdout.matchAll(/^phase:([a-z_]+)$/gm)]; + return phases.at(-1)?.[1] ?? "worker_start"; +} + +function summarizeModelRequest(index, path, body) { + const messages = Array.isArray(body.messages) ? body.messages : []; + const lastMessage = messages.at(-1); + const tools = Array.isArray(body.tools) ? body.tools : []; + return { + index, + path, + model: typeof body.model === "string" ? body.model : typeof body.model, + lastMessage: { + role: + typeof lastMessage?.role === "string" + ? lastMessage.role + : typeof lastMessage?.role, + text: summarizeMessageContent(lastMessage?.content), + }, + tools: { + count: tools.length, + names: tools.map((tool) => + typeof tool?.function?.name === "string" + ? tool.function.name + : typeof tool?.name === "string" + ? tool.name + : "", + ), + }, + toolChoice: summarizeToolChoice(body.tool_choice), + stream: body.stream, + }; +} + +function summarizeMessageContent(content) { + if (typeof content === "string") { + return summarizeText(content); + } + if (!Array.isArray(content)) { + return `<${typeof content}>`; + } + return summarizeText( + content + .map((part) => + typeof part?.text === "string" + ? part.text + : typeof part?.content === "string" + ? part.content + : `<${String(part?.type ?? typeof part)}>`, + ) + .join(" "), + ); +} + +function summarizeText(value) { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length <= 160 ? compact : `${compact.slice(0, 157)}...`; +} + +function summarizeToolChoice(toolChoice) { + if (toolChoice === undefined) { + return ""; + } + if (typeof toolChoice === "string") { + return toolChoice; + } + const functionName = toolChoice?.function?.name; + if (typeof functionName === "string") { + return { type: toolChoice.type ?? "function", functionName }; + } + return `<${typeof toolChoice}>`; +} + +function formatRequestTraces(traces, apiKey) { + return `secret-safe request trace:\n${JSON.stringify(traces, null, 2).replaceAll(apiKey, "[redacted]")}`; +} + +async function readApiKeyFromStdin() { + let value = ""; + for await (const chunk of process.stdin) { + value += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (Buffer.byteLength(value, "utf8") > 512) { + throw new Error("real Host smoke credential exceeded its size limit"); + } + } + const apiKey = value.trimEnd(); + if (apiKey.length === 0) { + throw new Error("real Host smoke credential is unavailable"); + } + return apiKey; +} + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`real Host smoke environment is missing ${name}`); + } + return value; +} + +function renderError(error) { + const values = []; + let current = error; + for (let depth = 0; depth < 8 && current instanceof Error; depth += 1) { + values.push(current.name, current.message, current.stack ?? ""); + current = current.cause; + } + return values.join("\n"); +} + +async function assertTreeDoesNotContain(directory, secret) { + const entries = await readdir(directory, { withFileTypes: true }); + const secretBytes = Buffer.from(secret, "utf8"); + for (const entry of entries) { + const path = join(directory, entry.name); + assert.equal(path.includes(secret), false, "API key leaked to an isolated path"); + if (entry.isDirectory()) { + await assertTreeDoesNotContain(path, secret); + } else if (entry.isFile()) { + const contents = await readFile(path); + assert.equal(contents.includes(secretBytes), false, "API key leaked to an isolated file"); + } + } +} + +async function closeServer(server) { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error === undefined) { + resolvePromise(); + } else { + rejectPromise(error); + } + }); + }); +} diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 87a4c147c9..ce75ca2965 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -53,7 +53,11 @@ reference Runtime IPC or Runtime implementation types, and they must not import `bitfun-app-server-protocol` wire DTOs. Non-Runtime projections come from the stable contracts layer (`bitfun-core-types` / `bitfun-product-domains`) or the existing owner API. Controller-local calls reject Remote workspace scope before -touching local state. App Server wiring is independent and does not constrain +touching local state. The `server` command is an independent stdio Server Host assembled in +`server_host.rs`, which is the only module allowed to import the +`bitfun-app-server` implementation; it injects an explicit method allowlist, +canonical cwd scope, transport limits, and the stdin EOF disconnect lifecycle. +App Server wiring is independent and does not constrain the TUI path. Side-effecting operations need stable identities, controller/idle rules, bounded frames, and outcome-unknown handling before a connection can retry. @@ -102,9 +106,11 @@ restrictions remain enforced. - Assemble CLI through `DeliveryProfile::Cli` and validated product Runtime parts. Hiding a command is not a backend capability restriction. - The CLI selects the reviewed `bitfun-core` owner-feature closure - (`agent-runtime`, `canvas-runtime`, `external-sources`, `plugin-runtime`, and - `ssh-remote`). Do not replace it with `product-full` or a CLI-named umbrella; - add a Core feature only when a production CLI path consumes that owner. + (`agent-runtime`, `external-sources`, `plugin-runtime`, `remote-connect`, and + `ssh-remote`) plus the Code Agent atomic tool owners. It must not register + DeepReview, DeepResearch, MiniApp, or Canvas agents/tools. Do not replace the + closure with `product-full` or a CLI-named umbrella; add a Core feature only + when a production CLI path consumes that owner. - CLI consumes typed external-source summaries and actions. It does not parse source files, import executable modules, start plugin workers, duplicate approval state, or treat static discovery as runtime availability. diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 7d9628332e..43354913ec 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -20,6 +20,10 @@ path = "src/bin/bitfun_cli_compat.rs" name = "acp_stdio_cli" path = "tests/acp_stdio_cli.rs" +[[test]] +name = "app_server_stdio_cli" +path = "tests/app_server_stdio_cli.rs" + [[test]] name = "cli_command_contracts" path = "tests/cli_command_contracts.rs" @@ -39,6 +43,7 @@ bitfun-core = { path = "../../crates/assembly/core", features = [ "deep-research", "external-sources", "plugin-runtime", + "opencode-plugin-host", "ssh-remote", "tools-basic", "tools-git", @@ -46,8 +51,6 @@ bitfun-core = { path = "../../crates/assembly/core", features = [ "tools-browser-web", "tools-computer-use", "tools-image-analysis", - "tools-miniapp", - "tools-canvas", "tools-agent-control", ] } bitfun-events = { path = "../../crates/contracts/events" } @@ -55,6 +58,7 @@ bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client", "server"] } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } +bitfun-app-server = { path = "../../crates/interfaces/app-server" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "git-port", "permission", "plugin-runtime", "workspace-ports"] } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } bitfun-services-core = { path = "../../crates/services/services-core", features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } @@ -104,7 +108,8 @@ libc = { workspace = true } arboard = { workspace = true } # Inherited from workspace -tokio = { workspace = true, features = ["macros", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { workspace = true, features = ["io-std", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-util = { workspace = true, features = ["compat"] } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } @@ -114,7 +119,7 @@ flate2 = { workspace = true } futures-util = { workspace = true } fs2 = { workspace = true } base64 = { workspace = true } -image = { workspace = true } +image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] } minisign-verify = "0.2" reqwest = { workspace = true, features = ["http2", "rustls", "stream"] } sha2 = { workspace = true } diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 97d7f7dce5..19d13cb339 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -179,6 +179,7 @@ bitfun plugins --help bitfun hooks --help bitfun config --help bitfun acp --help +bitfun server --help ``` `bitfun mcp import` is an explicit preview/apply snapshot. It does not copy @@ -194,6 +195,25 @@ protocol version before submitting or inspecting jobs. See the [detached task architecture](../../../docs/architecture/detached-task-dispatch.md) for the transport and workspace-snapshot contract. +### App server + +`bitfun server` starts the BitFun App Server surface over stdio. stdout carries +JSON-RPC traffic only, so an App Server client (for example an editor +integration) can connect by spawning this command; logs go to stderr. The +server scope is the current directory, matching the CLI's cwd-only session +scope, and reuses the CLI product runtime. Host management capabilities +(models, skills, subagents, hooks, and external sources) are served from the +local configuration; account sync, MCP management, and local worktree +management are reported as unavailable by this host. + +The server is a reviewed stdio Server Host with an explicit method allowlist +(read-only session, agent, permission, workspace, git, config, and i18n +methods plus management catalogs; state-changing config, model, MCP, account, +worktree, and hook mutations are not served). Frames are limited to 16 MiB and +the limit is enforced at the stdin reader. stdin EOF is a deterministic +disconnect: the Host cancels in-flight turns and exits. `app/initialize` +advertises only the methods this Host actually serves. + ### Always-on account device host After signing in with `/login`, a server can keep its account device route diff --git a/src/apps/cli/install.ps1 b/src/apps/cli/install.ps1 index 6f5ec3442a..4003eb5977 100644 --- a/src/apps/cli/install.ps1 +++ b/src/apps/cli/install.ps1 @@ -99,29 +99,47 @@ function Assert-EntrypointPair([string]$Primary, [string]$Legacy) { } } +function Assert-PluginHostResources([string]$Directory) { + foreach ($entry in @('extension-host.js')) { + $path = Join-Path $Directory $entry + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Plugin Host resource is missing: $path" + } + } +} + function Install-EntrypointPair( [string]$PrimarySource, [string]$LegacySource, + [string]$PluginHostSource, [string]$Destination ) { New-Item -ItemType Directory -Path $Destination -Force | Out-Null $stageDir = Join-Path $Destination ".bitfun-install-$([guid]::NewGuid().ToString('N'))" $stagedPrimary = Join-Path $stageDir 'bitfun.exe' $stagedLegacy = Join-Path $stageDir 'bitfun-cli.exe' + $stagedPluginHost = Join-Path $stageDir 'ext-host' $primaryTarget = Join-Path $Destination 'bitfun.exe' $legacyTarget = Join-Path $Destination 'bitfun-cli.exe' + $pluginHostTarget = Join-Path $Destination 'resources\ext-host' $primaryBackup = Join-Path $stageDir 'previous-bitfun.exe' $legacyBackup = Join-Path $stageDir 'previous-bitfun-cli.exe' + $pluginHostBackup = Join-Path $stageDir 'previous-ext-host' $primaryBackedUp = $false $legacyBackedUp = $false + $pluginHostBackedUp = $false $primaryCommitted = $false $legacyCommitted = $false + $pluginHostCommitted = $false New-Item -ItemType Directory -Path $stageDir | Out-Null try { Copy-Item -LiteralPath $PrimarySource -Destination $stagedPrimary Copy-Item -LiteralPath $LegacySource -Destination $stagedLegacy + New-Item -ItemType Directory -Path $stagedPluginHost | Out-Null + Copy-Item -LiteralPath (Join-Path $PluginHostSource 'extension-host.js') -Destination $stagedPluginHost Assert-EntrypointPair $stagedPrimary $stagedLegacy + Assert-PluginHostResources $stagedPluginHost if (Test-Path -LiteralPath $primaryTarget -PathType Leaf) { Move-Item -LiteralPath $primaryTarget -Destination $primaryBackup @@ -131,15 +149,26 @@ function Install-EntrypointPair( Move-Item -LiteralPath $legacyTarget -Destination $legacyBackup $legacyBackedUp = $true } + if (Test-Path -LiteralPath $pluginHostTarget -PathType Container) { + Move-Item -LiteralPath $pluginHostTarget -Destination $pluginHostBackup + $pluginHostBackedUp = $true + } Move-Item -LiteralPath $stagedPrimary -Destination $primaryTarget $primaryCommitted = $true Move-Item -LiteralPath $stagedLegacy -Destination $legacyTarget $legacyCommitted = $true + New-Item -ItemType Directory -Path (Split-Path -Parent $pluginHostTarget) -Force | Out-Null + Move-Item -LiteralPath $stagedPluginHost -Destination $pluginHostTarget + $pluginHostCommitted = $true Assert-EntrypointPair $primaryTarget $legacyTarget + Assert-PluginHostResources $pluginHostTarget } catch { $installError = $_ + if ($pluginHostCommitted) { + Remove-Item -LiteralPath $pluginHostTarget -Recurse -Force -ErrorAction SilentlyContinue + } if ($legacyCommitted) { Remove-Item -LiteralPath $legacyTarget -Force -ErrorAction SilentlyContinue } @@ -152,6 +181,10 @@ function Install-EntrypointPair( if ($primaryBackedUp) { Move-Item -LiteralPath $primaryBackup -Destination $primaryTarget -Force } + if ($pluginHostBackedUp) { + New-Item -ItemType Directory -Path (Split-Path -Parent $pluginHostTarget) -Force | Out-Null + Move-Item -LiteralPath $pluginHostBackup -Destination $pluginHostTarget -Force + } throw "CLI installation failed; the previous entrypoint pair was restored. $installError" } finally { @@ -163,6 +196,7 @@ $repoRoot = Resolve-RepoRoot $releaseDir = Resolve-ReleaseDir $repoRoot $primarySource = Join-Path $releaseDir 'bitfun.exe' $legacySource = Join-Path $releaseDir 'bitfun-cli.exe' +$pluginHostSource = Join-Path $repoRoot 'src\apps\extension-host\dist' $primaryInstalled = Join-Path $BinDir 'bitfun.exe' $legacyInstalled = Join-Path $BinDir 'bitfun-cli.exe' $deprecation = 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' @@ -193,11 +227,13 @@ foreach ($source in @($primarySource, $legacySource)) { throw "Built executable was not found at $source" } } +Assert-PluginHostResources $pluginHostSource Write-Host '[2/3] Installing executables...' -Install-EntrypointPair $primarySource $legacySource $BinDir +Install-EntrypointPair $primarySource $legacySource $pluginHostSource $BinDir Write-Host "Installed: $primaryInstalled" Write-Host "Installed deprecated compatibility entrypoint: $legacyInstalled" +Write-Host "Installed plugin Host resources: $(Join-Path $BinDir 'resources\ext-host')" if (-not $SkipPathUpdate) { Add-BinDirToUserPath $BinDir @@ -208,6 +244,7 @@ else { Write-Host '[3/3] Verifying both entrypoints...' Assert-EntrypointPair $primaryInstalled $legacyInstalled +Assert-PluginHostResources (Join-Path $BinDir 'resources\ext-host') Write-Host '=== Install complete ===' Write-Host 'Open a new terminal, then run: bitfun' diff --git a/src/apps/cli/install.sh b/src/apps/cli/install.sh index e92aa50570..58ee9d04ed 100755 --- a/src/apps/cli/install.sh +++ b/src/apps/cli/install.sh @@ -160,52 +160,91 @@ assert_entrypoint_pair() { fi } +assert_plugin_host_resources() { + local directory="$1" + local entry + for entry in extension-host.js; do + if [ ! -f "${directory}/${entry}" ]; then + echo "Error: plugin Host resource is missing: ${directory}/${entry}" >&2 + return 1 + fi + done +} + install_entrypoint_pair() { local primary_source="$1" local legacy_source="$2" - local destination="$3" - local stage_dir staged_primary staged_legacy primary_target legacy_target - local primary_backup legacy_backup - local primary_backed_up=0 legacy_backed_up=0 primary_committed=0 legacy_committed=0 + local plugin_host_source="$3" + local destination="$4" + local stage_dir staged_primary staged_legacy staged_plugin_host + local primary_target legacy_target plugin_host_target + local primary_backup legacy_backup plugin_host_backup + local primary_backed_up=0 legacy_backed_up=0 plugin_host_backed_up=0 + local primary_committed=0 legacy_committed=0 plugin_host_committed=0 local failed=0 mkdir -p "$destination" stage_dir="$(mktemp -d "${destination}/.bitfun-install.XXXXXX")" staged_primary="${stage_dir}/bitfun" staged_legacy="${stage_dir}/bitfun-cli" + staged_plugin_host="${stage_dir}/ext-host" primary_target="${destination}/bitfun" legacy_target="${destination}/bitfun-cli" + plugin_host_target="${destination}/resources/ext-host" primary_backup="${stage_dir}/previous-bitfun" legacy_backup="${stage_dir}/previous-bitfun-cli" + plugin_host_backup="${stage_dir}/previous-ext-host" install -m 755 "$primary_source" "$staged_primary" || failed=1 if [ "$failed" -eq 0 ]; then install -m 755 "$legacy_source" "$staged_legacy" || failed=1 fi + if [ "$failed" -eq 0 ]; then + mkdir -p "$staged_plugin_host" + cp "$plugin_host_source/extension-host.js" "$staged_plugin_host/" || failed=1 + fi if [ "$failed" -eq 0 ]; then assert_entrypoint_pair "$staged_primary" "$staged_legacy" || failed=1 fi + if [ "$failed" -eq 0 ]; then + assert_plugin_host_resources "$staged_plugin_host" || failed=1 + fi if [ "$failed" -eq 0 ] && { [ -e "$primary_target" ] || [ -L "$primary_target" ]; }; then if mv "$primary_target" "$primary_backup"; then primary_backed_up=1; else failed=1; fi fi if [ "$failed" -eq 0 ] && { [ -e "$legacy_target" ] || [ -L "$legacy_target" ]; }; then if mv "$legacy_target" "$legacy_backup"; then legacy_backed_up=1; else failed=1; fi fi + if [ "$failed" -eq 0 ] && [ -d "$plugin_host_target" ]; then + if mv "$plugin_host_target" "$plugin_host_backup"; then plugin_host_backed_up=1; else failed=1; fi + fi if [ "$failed" -eq 0 ]; then if mv "$staged_primary" "$primary_target"; then primary_committed=1; else failed=1; fi fi if [ "$failed" -eq 0 ]; then if mv "$staged_legacy" "$legacy_target"; then legacy_committed=1; else failed=1; fi fi + if [ "$failed" -eq 0 ]; then + mkdir -p "$(dirname "$plugin_host_target")" + if mv "$staged_plugin_host" "$plugin_host_target"; then plugin_host_committed=1; else failed=1; fi + fi if [ "$failed" -eq 0 ]; then assert_entrypoint_pair "$primary_target" "$legacy_target" || failed=1 fi + if [ "$failed" -eq 0 ]; then + assert_plugin_host_resources "$plugin_host_target" || failed=1 + fi if [ "$failed" -ne 0 ]; then + if [ "$plugin_host_committed" -eq 1 ]; then rm -rf "$plugin_host_target"; fi if [ "$legacy_committed" -eq 1 ]; then rm -f "$legacy_target"; fi if [ "$primary_committed" -eq 1 ]; then rm -f "$primary_target"; fi if [ "$legacy_backed_up" -eq 1 ]; then mv "$legacy_backup" "$legacy_target"; fi if [ "$primary_backed_up" -eq 1 ]; then mv "$primary_backup" "$primary_target"; fi + if [ "$plugin_host_backed_up" -eq 1 ]; then + mkdir -p "$(dirname "$plugin_host_target")" + mv "$plugin_host_backup" "$plugin_host_target" + fi rm -rf "$stage_dir" echo "Error: CLI installation failed; the previous entrypoint pair was restored." >&2 return 1 @@ -324,19 +363,22 @@ else fi BUILT_BIN="${RELEASE_DIR}/bitfun" BUILT_LEGACY_BIN="${RELEASE_DIR}/bitfun-cli" +PLUGIN_HOST_DIST="${REPO_ROOT}/src/apps/extension-host/dist" for binary in "$BUILT_BIN" "$BUILT_LEGACY_BIN"; do if [ ! -x "$binary" ]; then echo "Error: built binary not found at $binary" exit 1 fi done +assert_plugin_host_resources "$PLUGIN_HOST_DIST" echo "" echo "[2/4] Installing binaries..." -install_entrypoint_pair "$BUILT_BIN" "$BUILT_LEGACY_BIN" "$BIN_DIR" +install_entrypoint_pair "$BUILT_BIN" "$BUILT_LEGACY_BIN" "$PLUGIN_HOST_DIST" "$BIN_DIR" echo "Installed: ${BIN_DIR}/bitfun" echo "Installed deprecated compatibility entrypoint: ${BIN_DIR}/bitfun-cli" assert_entrypoint_pair "${BIN_DIR}/bitfun" "${BIN_DIR}/bitfun-cli" +assert_plugin_host_resources "${BIN_DIR}/resources/ext-host" echo "" echo "[3/4] Configuring shell PATH..." diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index dc8e08ee0a..03e6811a28 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -858,6 +858,8 @@ impl CliAgentRuntimeClient { self.resolve_session_workspace_binding(session_id, &project_workspace) .await? }; + self.ensure_embedded_plugin_workspace_ready(&binding) + .await?; let mut session_id_guard = self.session_id.lock().await; let mut turn_id_guard = self.current_turn_id.lock().await; *session_id_guard = Some(session_id.to_string()); @@ -949,6 +951,31 @@ impl CliAgentRuntimeClient { } } + async fn ensure_embedded_plugin_workspace_ready( + &self, + binding: &AgentSessionWorkspaceBinding, + ) -> Result<()> { + if matches!(&self.backend, CliAgentRuntimeBackend::Shared(_)) { + return Ok(()); + } + crate::plugin_host_activation::ensure_plugin_workspace_ready(binding) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + async fn ensure_embedded_plugin_session_ready(&self, session_id: &str) -> Result<()> { + if matches!(&self.backend, CliAgentRuntimeBackend::Shared(_)) { + return Ok(()); + } + // Session creation holds session_id until activation has completed. + // Resolve directly instead of re-locking that non-reentrant mutex. + let project_workspace = self.project_workspace_path_buf(); + let binding = self + .resolve_session_workspace_binding(session_id, &project_workspace) + .await?; + self.ensure_embedded_plugin_workspace_ready(&binding).await + } + pub(crate) async fn delete_session( &self, session_id: &str, @@ -1154,6 +1181,8 @@ impl CliAgentRuntimeClient { self.resolve_session_workspace_binding(&session.session_id, Path::new(&workspace_path)) .await? }; + self.ensure_embedded_plugin_workspace_ready(&binding) + .await?; *self.session_id.lock().await = Some(session.session_id.clone()); *self.current_turn_id.lock().await = None; self.shared_pending_permissions @@ -1354,7 +1383,10 @@ impl CliAgentRuntimeClient { .await { Ok(_) => { - self.resolve_session_workspace_binding(session_id, &project_workspace) + let binding = self + .resolve_session_workspace_binding(session_id, &project_workspace) + .await?; + self.ensure_embedded_plugin_workspace_ready(&binding) .await?; tracing::info!("Backend session restored: {}", session_id); Ok(()) @@ -1366,7 +1398,9 @@ impl CliAgentRuntimeClient { "Session is unavailable, recreating backend session: {}", session_id ); - self.recreate_session_with_id(session_id, agent_type).await + self.recreate_session_with_id(session_id, agent_type) + .await?; + self.ensure_embedded_plugin_session_ready(session_id).await } else { Err(with_session_conflict_help(anyhow::Error::new(error))) } @@ -1405,6 +1439,7 @@ impl CliAgentRuntimeClient { .map_err(with_session_conflict_help)?; let id = session.session_id.clone(); + self.ensure_embedded_plugin_session_ready(&id).await?; *session_id_guard = Some(id.clone()); tracing::info!("Created runtime session with fixed id: {}", id); @@ -1458,6 +1493,7 @@ impl CliAgentRuntimeClient { let id = session.session_id.clone(); + self.ensure_embedded_plugin_session_ready(&id).await?; *session_id_guard = Some(id.clone()); drop(session_id_guard); self.refresh_shared_pending_permissions().await?; @@ -1580,6 +1616,8 @@ impl CliAgentRuntimeClient { agent_type: &str, ) -> Result { tracing::info!("Sending message to session {}: {}", session_id, message); + self.ensure_embedded_plugin_session_ready(&session_id) + .await?; // Generate a turn_id let turn_id = uuid::Uuid::new_v4().to_string(); @@ -1717,6 +1755,8 @@ impl CliAgentRuntimeClient { agent_type: &str, ) -> Result { let session_id = self.ensure_session(agent_type).await?; + self.ensure_embedded_plugin_session_ready(&session_id) + .await?; let turn_id = uuid::Uuid::new_v4().to_string(); let request = AgentUserShellCommandRequest { session_id: session_id.clone(), @@ -1899,6 +1939,7 @@ impl CliAgentRuntimeClient { let id = session.session_id.clone(); + self.ensure_embedded_plugin_session_ready(&id).await?; *self.session_id.lock().await = Some(id.clone()); *self.current_turn_id.lock().await = None; self.shared_pending_permissions diff --git a/src/apps/cli/src/logging.rs b/src/apps/cli/src/logging.rs index 5d7a24f07a..c8a978b9ca 100644 --- a/src/apps/cli/src/logging.rs +++ b/src/apps/cli/src/logging.rs @@ -3,7 +3,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use chrono::Local; use tracing_subscriber::filter::filter_fn; @@ -28,8 +28,11 @@ pub(crate) struct CliLogPaths { pub app_log_path: PathBuf, pub ai_log_path: PathBuf, pub flashgrep_log_path: PathBuf, + pub plugin_host_log_path: PathBuf, } +static ACTIVE_LOG_PATHS: OnceLock = OnceLock::new(); + struct RotatingFile { dir: PathBuf, file_name: String, @@ -238,9 +241,16 @@ pub(crate) fn build_log_paths(session_log_dir: &Path) -> CliLogPaths { app_log_path: session_log_dir.join("app.log"), ai_log_path: session_log_dir.join("ai.log"), flashgrep_log_path: session_log_dir.join("flashgrep.log"), + plugin_host_log_path: session_log_dir.join("plugin-host.log"), } } +pub(crate) fn active_plugin_host_log_path() -> Option { + ACTIVE_LOG_PATHS + .get() + .map(|paths| paths.plugin_host_log_path.clone()) +} + fn create_rotating_writer( session_log_dir: &Path, file_name: &str, @@ -353,6 +363,7 @@ pub(crate) fn init_file_logging_at( ) -> CliLogPaths { fs::create_dir_all(session_log_dir).ok(); let paths = build_log_paths(session_log_dir); + let _ = ACTIVE_LOG_PATHS.set(paths.clone()); let app_writer = create_rotating_writer(session_log_dir, "app"); let ai_writer = create_rotating_writer(session_log_dir, "ai"); @@ -410,6 +421,10 @@ mod tests { assert_eq!(paths.app_log_path, temp.path().join("app.log")); assert_eq!(paths.ai_log_path, temp.path().join("ai.log")); assert_eq!(paths.flashgrep_log_path, temp.path().join("flashgrep.log")); + assert_eq!( + paths.plugin_host_log_path, + temp.path().join("plugin-host.log") + ); } #[test] diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 95fa5b2267..e9ca039a20 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -26,12 +26,14 @@ mod model_selection; mod modes; mod peer_host; mod plugin_diagnostics; +mod plugin_host_activation; mod product_assembly; mod prompt_stash; mod prompts; mod root_handlers; mod runtime; mod self_update; +mod server_host; mod shared_runtime; mod terminal_attention; mod ui; @@ -49,6 +51,9 @@ use mcp_import::{McpImportCommand, McpImportOutputFormat}; use modes::chat::ChatMode; use modes::exec::{ExecApprovalMode, ExecOutputFormat}; +pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = + bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled; + // ======================== Global MCP Service ======================== static MCP_SERVICE: OnceLock> = @@ -372,6 +377,11 @@ enum Commands { action: DispatchAction, }, + /// Start the BitFun app server over stdio + /// + /// stdout carries JSON-RPC traffic only; logs are written to stderr. + Server, + /// Start or inspect the Agent Client Protocol (ACP) server Acp { #[command(subcommand)] @@ -596,6 +606,13 @@ impl BootstrapProfile { const fn starts_mcp(self) -> bool { matches!(self, Self::Interactive | Self::Execution) } + + const fn starts_plugin_host(self) -> bool { + matches!( + PLUGIN_HOST_LAUNCH_POLICY, + bitfun_core::plugin_host::PluginHostLaunchPolicy::Enabled + ) && matches!(self, Self::Interactive | Self::Execution) + } } impl SessionAction { @@ -849,6 +866,24 @@ async fn initialize_core_services_for_deployment( .await .map_err(|error| anyhow!("Failed to initialize global config service: {error}"))?; tracing::info!("Global config service initialized"); + if matches!( + bootstrap_profile, + BootstrapProfile::Interactive | BootstrapProfile::Execution + ) { + plugin_host_activation::ensure_configured_plugin_execution_supported().await?; + } + if bootstrap_profile.starts_plugin_host() { + match bitfun_core::plugin_host::initialize_configured_plugin_host_with_log_file( + PLUGIN_HOST_LAUNCH_POLICY, + logging::active_plugin_host_log_path(), + ) + .await + { + Ok(bitfun_core::plugin_host::PluginHostStartup::Disabled) => {} + Ok(status) => tracing::info!("Plugin host initialization completed: {:?}", status), + Err(error) => tracing::error!("Failed to initialize configured plugin host: {error}"), + } + } let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() .map_err(|error| anyhow!(error.to_string()))?; let entrypoint = match (deployment, bootstrap_profile) { @@ -1494,6 +1529,10 @@ async fn run_cli() -> Result<()> { root_handlers::handle_dispatch_action(action).await?; } + Some(Commands::Server) => { + server_host::serve().await?; + } + Some(Commands::Acp { action: None | Some(AcpAction::Serve), }) => { @@ -1654,7 +1693,24 @@ fn main() { .enable_all() .build() .expect("failed to build tokio runtime"); - runtime.block_on(run_cli()) + runtime.block_on(async { + let result = run_cli().await; + match bitfun_core::plugin_host::shutdown_configured_plugin_host().await { + Ok(Some(report)) => tracing::info!( + generation = report.generation, + disposition = ?report.disposition, + rpc_completed = report.rpc_completed, + exit_code = ?report.exit_code, + duration_ms = report.duration_ms, + "CLI plugin host shutdown completed" + ), + Ok(None) => { + tracing::debug!("CLI plugin host shutdown skipped: host not started") + } + Err(error) => tracing::warn!("CLI plugin host shutdown failed: {error}"), + } + result + }) }) .expect("failed to spawn bitfun worker thread"); @@ -1674,6 +1730,18 @@ fn main() { } } +#[cfg(test)] +mod server_command_tests { + use super::{Cli, Commands}; + use clap::Parser; + + #[test] + fn server_command_parses_as_stdio_host() { + let parsed = Cli::try_parse_from(["bitfun", "server"]).expect("parse server command"); + assert!(matches!(parsed.command, Some(Commands::Server))); + } +} + #[cfg(test)] mod plugin_command_tests { use super::{Cli, Commands, PluginAction}; @@ -1845,12 +1913,12 @@ mod bootstrap_profile_tests { #[test] fn profiles_start_only_their_requested_background_services() { let cases = [ - (BootstrapProfile::Interactive, true, true), - (BootstrapProfile::Execution, false, true), - (BootstrapProfile::Management, false, false), + (BootstrapProfile::Interactive, true, true, false), + (BootstrapProfile::Execution, false, true, false), + (BootstrapProfile::Management, false, false, false), ]; - for (profile, starts_peer_host, starts_mcp) in cases { + for (profile, starts_peer_host, starts_mcp, starts_plugin_host) in cases { assert_eq!( profile.starts_peer_host( bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded, @@ -1858,6 +1926,7 @@ mod bootstrap_profile_tests { starts_peer_host ); assert_eq!(profile.starts_mcp(), starts_mcp); + assert_eq!(profile.starts_plugin_host(), starts_plugin_host); } } diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs index 25370ebc8a..9563f93cd9 100644 --- a/src/apps/cli/src/modes/exec/lifecycle.rs +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -13,7 +13,7 @@ use std::time::Duration; use bitfun_agent_runtime::sdk::{ PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestEvent, - PortErrorKind, RuntimeError, + PortErrorKind, RuntimeError, TurnTokenUsage, }; use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::{AgenticEvent, ToolEventIdentity}; @@ -53,65 +53,7 @@ pub(crate) enum ExecApprovalMode { Auto, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub(super) struct ExecTokenUsage { - pub(super) input_tokens: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) output_tokens: Option, - pub(super) total_tokens: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) cached_tokens: Option, -} - -impl ExecTokenUsage { - fn merge_round(&mut self, round: Self) { - self.input_tokens = self.input_tokens.saturating_add(round.input_tokens); - self.output_tokens = self - .output_tokens - .zip(round.output_tokens) - .map(|(current, next)| current.saturating_add(next)); - self.total_tokens = self.total_tokens.saturating_add(round.total_tokens); - self.cached_tokens = self - .cached_tokens - .zip(round.cached_tokens) - .map(|(current, next)| current.saturating_add(next)); - } - - pub(super) fn accumulate_event<'a>( - aggregate: &mut Option, - event: &'a AgenticEvent, - expected_turn_id: &str, - ) -> Option<&'a str> { - let AgenticEvent::TokenUsageUpdated { - turn_id, - model_config_id, - input_tokens, - output_tokens, - total_tokens, - cached_tokens, - .. - } = event - else { - return None; - }; - if turn_id != expected_turn_id { - return None; - } - - let round = Self { - input_tokens: *input_tokens, - output_tokens: *output_tokens, - total_tokens: *total_tokens, - cached_tokens: *cached_tokens, - }; - if let Some(total) = aggregate.as_mut() { - total.merge_round(round); - } else { - *aggregate = Some(round); - } - Some(model_config_id) - } -} +pub(super) type ExecTokenUsage = TurnTokenUsage; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub(super) struct ExecJsonResult { diff --git a/src/apps/cli/src/peer_host/commands/snapshot.rs b/src/apps/cli/src/peer_host/commands/snapshot.rs index cc3084a809..cf84f299ea 100644 --- a/src/apps/cli/src/peer_host/commands/snapshot.rs +++ b/src/apps/cli/src/peer_host/commands/snapshot.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_runtime_ports::{ - AgentSessionRollbackToTurnRequest, LocalWorkspaceSnapshotPort, + AgentSessionRollbackToTurnRequest, AgentSessionWorkspaceLocation, LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, PortError, PortErrorKind, }; @@ -33,10 +33,12 @@ pub(super) async fn require_local_snapshot_workspace( async fn require_complete_rollback_workspace( request: &Value, workspace_path: &str, + explicit_location: Option, ) -> Result<(), String> { let is_remote = optional_string(request, "remoteConnectionId").is_some() || optional_string(request, "remoteSshHost").is_some() - || is_remote_path(workspace_path).await; + || explicit_location == Some(AgentSessionWorkspaceLocation::Remote) + || (explicit_location.is_none() && is_remote_path(workspace_path).await); if is_remote { return Err(format!( "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {workspace_path}" @@ -134,7 +136,12 @@ pub(crate) async fn rollback_session_to_turn( .map_err(|error| format!("Invalid targeted Session rollback request: {error}"))?; bitfun_agent_runtime::session_control::validate_session_id(&rollback_request.session_id)?; - require_complete_rollback_workspace(request, &rollback_request.workspace_path).await?; + require_complete_rollback_workspace( + request, + &rollback_request.workspace_path, + rollback_request.explicit_workspace_location(), + ) + .await?; ensure_session_workspace_runtime_ownership(state, request)?; let outcome = state .agent_runtime @@ -227,6 +234,7 @@ mod tests { let rollback_error = require_complete_rollback_workspace( &json!({ "remoteConnectionId": "remote-1" }), "/root/repos", + None, ) .await .expect_err("complete remote rollback must report missing snapshot coverage"); @@ -240,7 +248,7 @@ mod tests { .find("pub(crate) async fn rollback_session_to_turn") .expect("rollback handler must exist")..]; let remote_guard = rollback_source - .find("require_complete_rollback_workspace(request, &rollback_request.workspace_path)") + .find("require_complete_rollback_workspace(") .expect("rollback must have an explicit remote guard"); let runtime_call = rollback_source .find(".rollback_session_to_turn(") @@ -248,6 +256,34 @@ mod tests { assert!(remote_guard < runtime_call); } + #[tokio::test] + async fn explicit_local_rollback_identity_wins_over_a_remote_path_collision() { + let workspace = tempfile::tempdir().expect("create local workspace"); + let workspace_path = workspace.path().to_string_lossy().to_string(); + let remote = + bitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager(); + remote + .register_remote_workspace( + workspace_path.clone(), + "peer-rollback-path-collision".to_string(), + "Peer rollback collision test".to_string(), + "remote.example".to_string(), + ) + .await; + + require_complete_rollback_workspace( + &json!({ "workspaceId": "local_workspace-1" }), + &workspace_path, + Some(bitfun_runtime_ports::AgentSessionWorkspaceLocation::Local), + ) + .await + .expect("explicit local identity must disambiguate the registered remote path"); + + remote + .unregister_remote_workspace("peer-rollback-path-collision", &workspace_path) + .await; + } + #[tokio::test] async fn local_snapshot_adapter_calls_each_port_operation_once_with_typed_requests() { let port = RecordingSnapshotPort::default(); diff --git a/src/apps/cli/src/plugin_host_activation.rs b/src/apps/cli/src/plugin_host_activation.rs new file mode 100644 index 0000000000..d4d13a26f3 --- /dev/null +++ b/src/apps/cli/src/plugin_host_activation.rs @@ -0,0 +1,121 @@ +use std::path::PathBuf; + +use bitfun_core::BitFunResult; +use bitfun_runtime_ports::AgentSessionWorkspaceBinding; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PluginWorkspaceActivationTarget { + directory: PathBuf, + worktree: PathBuf, + project_id: Option, +} + +fn activation_target( + binding: &AgentSessionWorkspaceBinding, +) -> Option { + if binding.remote_connection_id.is_some() || binding.remote_ssh_host.is_some() { + return None; + } + + let workspace = PathBuf::from(&binding.workspace_path); + Some(PluginWorkspaceActivationTarget { + directory: workspace.clone(), + worktree: workspace, + project_id: binding.workspace_id.clone(), + }) +} + +pub(crate) async fn ensure_configured_plugin_execution_supported() -> BitFunResult { + // Plugin activation is optional. Isolated Runtime clients (including unit + // tests) may create sessions before the process-level config service is + // initialized; that must not make ordinary session creation fail. + if !bitfun_core::service::config::GlobalConfigManager::is_initialized() { + return Ok(false); + } + + let config_service = bitfun_core::service::config::get_global_config_service().await?; + let config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await?; + let has_configured_plugins = config.has_configured_plugins(); + if has_configured_plugins + && crate::PLUGIN_HOST_LAUNCH_POLICY + == bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled + { + return Err(bitfun_core::BitFunError::NotImplemented( + "Configured Plugin Host execution is not enabled; plugin definitions were not imported or executed" + .to_string(), + )); + } + Ok(has_configured_plugins) +} + +pub(crate) async fn ensure_plugin_workspace_ready( + binding: &AgentSessionWorkspaceBinding, +) -> BitFunResult<()> { + if !ensure_configured_plugin_execution_supported().await? { + return Ok(()); + } + + let Some(target) = activation_target(binding) else { + return Err(bitfun_core::BitFunError::NotImplemented( + "Configured Plugin Host is unsupported for Remote CLI workspaces; no controller-local fallback was attempted" + .to_string(), + )); + }; + + bitfun_core::plugin_host::ensure_configured_plugin_instance( + crate::PLUGIN_HOST_LAUNCH_POLICY, + target.directory, + target.worktree, + target.project_id, + serde_json::Map::new(), + ) + .await + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::{activation_target, PluginWorkspaceActivationTarget}; + use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; + use std::path::PathBuf; + + fn binding() -> AgentSessionWorkspaceBinding { + AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: "C:/workspace/project".to_string(), + project_workspace_path: Some("C:/workspace/project".to_string()), + execution_target: Some(SessionExecutionTarget::local("C:/workspace/project")), + remote_connection_id: None, + remote_ssh_host: None, + } + } + + #[test] + fn local_binding_maps_to_plugin_workspace_target() { + assert_eq!( + activation_target(&binding()), + Some(PluginWorkspaceActivationTarget { + directory: PathBuf::from("C:/workspace/project"), + worktree: PathBuf::from("C:/workspace/project"), + project_id: Some("workspace-1".to_string()), + }) + ); + } + + #[test] + fn remote_binding_skips_local_plugin_host() { + let mut binding = binding(); + binding.remote_connection_id = Some("remote-1".to_string()); + + assert_eq!(activation_target(&binding), None); + } + + #[test] + fn cli_does_not_enable_unowned_plugin_execution() { + assert_eq!( + crate::PLUGIN_HOST_LAUNCH_POLICY, + bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled + ); + } +} diff --git a/src/apps/cli/src/product_assembly.rs b/src/apps/cli/src/product_assembly.rs index 8076fcfb12..0deb387e45 100644 --- a/src/apps/cli/src/product_assembly.rs +++ b/src/apps/cli/src/product_assembly.rs @@ -85,7 +85,7 @@ mod tests { reason: PluginRuntimeUnavailableReason::NotBuilt } )); - assert!(!parts.harness_registry().provider_ids().is_empty()); + assert!(parts.harness_registry().provider_ids().is_empty()); } #[test] @@ -108,6 +108,6 @@ mod tests { reason: PluginRuntimeUnavailableReason::UnsupportedProfile } )); - assert!(!parts.harness_registry().provider_ids().is_empty()); + assert!(parts.harness_registry().provider_ids().is_empty()); } } diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 66f2f6963f..d774666a92 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -218,3 +218,66 @@ impl AcpRuntimeContext { (self.agent_runtime.clone(), self.compatibility.clone()) } } + +/// Minimal runtime context for the `server` command's app-server host. +/// +/// Reuses the CLI product runtime (`DeliveryProfile::Cli` + the reviewed CLI +/// assembly) so the stdio app server exposes the same agent-kernel +/// capabilities as the CLI, without importing ACP protocol semantics or the +/// TUI-facing `CliRuntimeContext` owners. +#[derive(Clone)] +pub(crate) struct AppServerRuntimeContext { + agent_runtime: AgentRuntime, + event_source: AgentEventSource, + compatibility: CoreAgentRuntimeCompatibility, + _agent_event_queue_owner: CoreProductEventQueueOwner, +} + +impl AppServerRuntimeContext { + pub(crate) fn build( + agentic_system: AgenticSystem, + workspace_root: impl AsRef, + ) -> Result { + let scheduler = ensure_product_dialog_scheduler(&agentic_system); + let (_, services) = build_local_runtime_services(workspace_root, RUNTIME_EVENT_BUFFER)?; + let parts = assemble_cli_runtime_parts(services) + .context("Failed to assemble CLI product runtime")?; + let (services, harness_registry, _disabled_plugin_runtime) = parts.into_runtime_parts(); + let agent_event_queue_owner = + CoreProductEventQueueOwner::new(agentic_system.event_queue.clone()); + let agent_runtime = CoreProductAgentRuntime::build_with_event_source( + agentic_system.coordinator.clone(), + scheduler.clone(), + agentic_system.token_usage_service.clone(), + agent_event_queue_owner.runtime_source(), + services, + harness_registry, + ) + .map_err(anyhow::Error::msg) + .context("Failed to build App Server Agent Runtime SDK")?; + let event_source = agent_event_queue_owner.runtime_source(); + let compatibility = + CoreAgentRuntimeCompatibility::build(agentic_system.coordinator.clone(), scheduler); + + Ok(Self { + agent_runtime, + event_source, + compatibility, + _agent_event_queue_owner: agent_event_queue_owner, + }) + } + + pub(crate) fn parts( + &self, + ) -> ( + AgentRuntime, + AgentEventSource, + CoreAgentRuntimeCompatibility, + ) { + ( + self.agent_runtime.clone(), + self.event_source.clone(), + self.compatibility.clone(), + ) + } +} diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index 56e118a661..7f17be4526 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -957,6 +957,7 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { )); } let legacy_target = install_dir.join("bitfun-cli"); + let plugin_host_target = install_dir.join("resources").join("ext-host"); if !legacy_target.is_file() { return Err(anyhow!( "official bitfun-cli companion was not found beside {}", @@ -971,7 +972,9 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { let package_dir = find_package_dir(extract_dir.path())?; let new_primary = package_dir.join("bitfun"); let new_legacy = package_dir.join("bitfun-cli"); + let new_plugin_host = package_dir.join("resources").join("ext-host"); validate_entrypoint_pair(&new_primary, &new_legacy)?; + validate_plugin_host_resources(&new_plugin_host)?; let stage = tempfile::Builder::new() .prefix(".bitfun-update.") @@ -984,14 +987,19 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { })?; let staged_primary = stage.path().join("bitfun"); let staged_legacy = stage.path().join("bitfun-cli"); + let staged_plugin_host = stage.path().join("ext-host"); fs::copy(&new_primary, &staged_primary).context("stage bitfun")?; fs::copy(&new_legacy, &staged_legacy).context("stage bitfun-cli")?; + copy_plugin_host_resources(&new_plugin_host, &staged_plugin_host)?; fs::set_permissions(&staged_primary, fs::Permissions::from_mode(0o755))?; fs::set_permissions(&staged_legacy, fs::Permissions::from_mode(0o755))?; validate_entrypoint_pair(&staged_primary, &staged_legacy)?; + validate_plugin_host_resources(&staged_plugin_host)?; let primary_backup = stage.path().join("previous-bitfun"); let legacy_backup = stage.path().join("previous-bitfun-cli"); + let plugin_host_backup = stage.path().join("previous-ext-host"); + let plugin_host_existed = plugin_host_target.is_dir(); // Rollback runs while something has already gone wrong, so its own failures // are the ones that matter most: they are the difference between "the update @@ -1023,7 +1031,25 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { rollback_failures, )); } + if plugin_host_existed { + if let Err(error) = fs::rename(&plugin_host_target, &plugin_host_backup) { + restore(&legacy_backup, &legacy_target, &mut rollback_failures); + restore(&primary_backup, current_exe, &mut rollback_failures); + return Err(rollback_error( + error, + "back up current plugin Host resources", + rollback_failures, + )); + } + } if let Err(error) = fs::rename(&staged_primary, current_exe) { + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } restore(&legacy_backup, &legacy_target, &mut rollback_failures); restore(&primary_backup, current_exe, &mut rollback_failures); return Err(rollback_error( @@ -1036,6 +1062,13 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { if let Err(remove_error) = fs::remove_file(current_exe) { rollback_failures.push(format!("remove {}: {remove_error}", current_exe.display())); } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } restore(&legacy_backup, &legacy_target, &mut rollback_failures); restore(&primary_backup, current_exe, &mut rollback_failures); return Err(rollback_error( @@ -1044,12 +1077,73 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { rollback_failures, )); } - if let Err(error) = validate_entrypoint_pair(current_exe, &legacy_target) { + if let Err(error) = fs::create_dir_all( + plugin_host_target + .parent() + .expect("plugin Host resource directory has a parent"), + ) { + for path in [current_exe, legacy_target.as_path()] { + if let Err(remove_error) = fs::remove_file(path) { + rollback_failures.push(format!("remove {}: {remove_error}", path.display())); + } + } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } + restore(&legacy_backup, &legacy_target, &mut rollback_failures); + restore(&primary_backup, current_exe, &mut rollback_failures); + return Err(rollback_error( + error, + "create plugin Host resource directory", + rollback_failures, + )); + } + if let Err(error) = fs::rename(&staged_plugin_host, &plugin_host_target) { for path in [current_exe, legacy_target.as_path()] { if let Err(remove_error) = fs::remove_file(path) { rollback_failures.push(format!("remove {}: {remove_error}", path.display())); } } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } + restore(&legacy_backup, &legacy_target, &mut rollback_failures); + restore(&primary_backup, current_exe, &mut rollback_failures); + return Err(rollback_error( + error, + "install updated plugin Host resources", + rollback_failures, + )); + } + let validation = validate_entrypoint_pair(current_exe, &legacy_target) + .and_then(|_| validate_plugin_host_resources(&plugin_host_target)); + if let Err(error) = validation { + for path in [current_exe, legacy_target.as_path()] { + if let Err(remove_error) = fs::remove_file(path) { + rollback_failures.push(format!("remove {}: {remove_error}", path.display())); + } + } + if let Err(remove_error) = fs::remove_dir_all(&plugin_host_target) { + rollback_failures.push(format!( + "remove {}: {remove_error}", + plugin_host_target.display() + )); + } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } restore(&legacy_backup, &legacy_target, &mut rollback_failures); restore(&primary_backup, current_exe, &mut rollback_failures); let failed = error.context("validate installed CLI update"); @@ -1104,6 +1198,33 @@ fn validate_entrypoint_pair(primary: &Path, legacy: &Path) -> Result<()> { Ok(()) } +fn validate_plugin_host_resources(directory: &Path) -> Result<()> { + for entry in ["extension-host.js"] { + let path = directory.join(entry); + if !path.is_file() { + return Err(anyhow!( + "CLI package is missing plugin Host resource {}", + path.display() + )); + } + } + Ok(()) +} + +fn copy_plugin_host_resources(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination).with_context(|| { + format!( + "create plugin Host staging directory {}", + destination.display() + ) + })?; + for entry in ["extension-host.js"] { + fs::copy(source.join(entry), destination.join(entry)) + .with_context(|| format!("stage plugin Host resource {entry}"))?; + } + Ok(()) +} + fn current_platform_key() -> Option<&'static str> { if !cfg!(target_os = "linux") { return None; diff --git a/src/apps/cli/src/server_host.rs b/src/apps/cli/src/server_host.rs new file mode 100644 index 0000000000..cd6bed2c62 --- /dev/null +++ b/src/apps/cli/src/server_host.rs @@ -0,0 +1,389 @@ +//! Independent stdio Server Host behind the `bitfun server` command. +//! +//! This module is the only assembly point in the CLI that may import the App +//! Server implementation (`bitfun_app_server`). `bitfun server` is not a TUI, +//! controller, or headless-CLI feature: it is a separate Host surface that +//! reuses the reviewed CLI product assembly, selected with +//! `DeliveryProfile::Cli` because the CLI assembly is the reviewed kernel +//! wiring for a cwd-scoped terminal-capable host, and then caps what the +//! shared App Server surface may do on that host. +//! +//! Host-owned guarantees enforced here, while the shared server stays +//! generic: +//! - an immutable identity, a canonical cwd workspace scope, and an explicit +//! method allowlist injected through `AppServerHostPolicy`; every request +//! outside the allowlist or workspace scope fails closed before any domain +//! handler runs; +//! - transport limits advertised in `app/initialize` and enforced at the +//! stdin reader, which fails closed on frames over the limit; +//! - stdin EOF becomes a loss-free disconnect signal; after `serve` returns, +//! the Host cancels in-flight turns and exits deterministically. +//! +//! TUI, controller, and headless CLI code must keep App Server +//! implementation, client, and protocol imports forbidden; this module is the +//! reviewed exception. See docs/architecture/app-server-architecture.md for +//! the Host contract. + +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll}; + +use anyhow::{Context as _, Result}; +use futures_util::io::AsyncRead; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use bitfun_app_server::{ + AppManagementService, AppServerDisconnect, AppServerHostLimits, AppServerHostPolicy, + BitfunAppRuntime, BitfunAppServer, +}; + +/// Host identity injected into the connection policy. +const SERVER_HOST_IDENTITY: &str = "bitfun-cli-server-host"; + +/// Explicit method allowlist for the stdio Host. +/// +/// Read-only session, agent, permission, workspace, git, config, and i18n +/// methods plus the safe management catalog and snapshot methods. State +/// changing config, model, skill, subagent, MCP, account, settings-sync, +/// worktree, external-source control, and hook mutation methods are +/// intentionally absent: a cwd-scoped stdio Host must not mutate host-local +/// product state on behalf of a client. +const ALLOWED_METHODS: &[&str] = &[ + "app/initialize", + "app/health", + "app/syncEvents", + "app/eventStreamState", + "agent/createSession", + "agent/listSessions", + "agent/deleteSession", + "agent/submitTurn", + "agent/submitDialogTurn", + "agent/steerTurn", + "agent/runUserShellCommand", + "agent/submitUserAnswers", + "agent/cancelTurn", + "agent/run", + "agent/event", + "agent/frontendEvent", + "session/sync", + "session/readTranscript", + "session/resolveWorkspace", + "session/rename", + "session/setArchived", + "session/updateModel", + "session/updateMode", + "session/fork", + "session/forkAtTurn", + "session/forkBeforeTurn", + "session/restore", + "session/compact", + "session/undo", + "session/redo", + "session/reloadContext", + "session/usage", + "session/waitForSettlement", + "session/lineage", + "session/inspectLineage", + "session/cancelLineage", + "search/sessionContent", + "agent/listModes", + "agent/respondPermission", + "agent/respondPermissionBatch", + "agent/listPendingPermissionRequests", + "agent/listProjectPermissionGrants", + "agent/removeProjectPermissionGrant", + "agent/clearProjectPermissionGrants", + "agent/listProjectPermissionAudit", + "workspace/diff", + "workspace/searchReferences", + "workspace/messageReferences", + "git/isRepository", + "git/getStatus", + "git/getBranches", + "config/event", + "config/getAgentProfileConfigs", + "config/getAgentProfileConfig", + "config/getModelConfigs", + "config/getTuiModelCatalog", + "model/projectReasoningCatalog", + "config/getConfig", + "config/getConfigs", + "config/validateConfig", + "i18n/getCurrentLanguage", + "i18n/getSupportedLanguages", + "i18n/getConfig", + "externalHook/snapshot", + "nativeHook/overview", + "skill/list", + "subagent/list", + "externalSource/snapshot", +]; + +/// Frame-limited stdin reader for the Host transport. +/// +/// Counting is per line: bytes are reset on each newline and counted +/// otherwise. EOF becomes the loss-free disconnect signal shared with the +/// event forwarder, and a frame that exceeds the advertised Host limit fails +/// closed with a protocol error line and an `InvalidData` reader error. +struct StdioFrameReader { + inner: tokio_util::compat::Compat, + max_frame_bytes: u64, + line_bytes: u64, + disconnect: Arc, + error_reporter: StdioErrorReporter, +} + +impl StdioFrameReader { + fn new( + inner: tokio_util::compat::Compat, + max_frame_bytes: u64, + disconnect: Arc, + error_reporter: StdioErrorReporter, + ) -> Self { + Self { + inner, + max_frame_bytes, + line_bytes: 0, + disconnect, + error_reporter, + } + } + + fn account(&mut self, bytes: &[u8]) -> bool { + for &byte in bytes { + if byte == b'\n' { + self.line_bytes = 0; + continue; + } + self.line_bytes += 1; + if self.line_bytes > self.max_frame_bytes { + return true; + } + } + false + } +} + +impl AsyncRead for StdioFrameReader { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut [u8], + ) -> Poll> { + let this = self.get_mut(); + let inner = Pin::new(&mut this.inner); + match inner.poll_read(cx, buf) { + Poll::Ready(Ok(0)) => { + this.disconnect.signal(); + Poll::Ready(Ok(0)) + } + Poll::Ready(Ok(read)) => { + if this.account(&buf[..read]) { + this.disconnect.signal(); + this.error_reporter + .report_frame_exceeds_limit(this.max_frame_bytes); + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "App-server frame exceeds the Host transport limit", + ))); + } + Poll::Ready(Ok(read)) + } + other => other, + } + } +} + +/// Writes at most one protocol error line to stdout outside the normal +/// response path, used when the transport reader itself fails closed. +#[derive(Clone)] +struct StdioErrorReporter { + stdout: Arc>, + reported: Arc, + written: Arc, +} + +impl StdioErrorReporter { + fn new(stdout: tokio::io::Stdout) -> Self { + Self { + stdout: Arc::new(tokio::sync::Mutex::new(stdout)), + reported: Arc::new(AtomicBool::new(false)), + written: Arc::new(tokio::sync::Notify::new()), + } + } + + fn report_frame_exceeds_limit(&self, max_frame_bytes: u64) { + if self.reported.swap(true, Ordering::SeqCst) { + return; + } + let stdout = self.stdout.clone(); + let written = self.written.clone(); + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let message = serde_json::json!({ + "jsonrpc": "2.0", + "error": { + "code": -32602, + "message": "Frame exceeds Host transport limit", + "data": { + "reason": "frame_exceeds_host_limit", + "maxFrameBytes": max_frame_bytes, + }, + }, + }); + let mut line = serde_json::to_vec(&message).expect("serialize frame-limit error"); + line.push(b'\n'); + let mut stdout = stdout.lock().await; + let _ = stdout.write_all(&line).await; + let _ = stdout.flush().await; + written.notify_one(); + }); + } + + /// Wait for a pending frame-limit error line to reach stdout before the + /// Host exits, so the client always observes the failure before EOF. + async fn flush_pending(&self) { + if !self.reported.load(Ordering::SeqCst) { + return; + } + let _ = + tokio::time::timeout(std::time::Duration::from_secs(5), self.written.notified()).await; + } +} + +/// Serve the app-server surface over stdio for the `server` command. +/// +/// stdout is reserved for JSON-RPC traffic (run_cli routes logs to stderr for +/// non-interactive commands). The workspace scope is the canonical current +/// directory only, and stdin EOF ends the connection, cancels in-flight +/// turns, and exits the process. +pub(crate) async fn serve() -> Result<()> { + crate::setup_workspace(); + let workspace_root = std::env::current_dir().context("Failed to resolve server workspace")?; + + crate::agent::agentic_system::select_agentic_system_profile( + bitfun_core::product_assembly::DeliveryProfile::Cli, + )?; + bitfun_core::service::config::initialize_global_config() + .await + .context("Failed to initialize global config service")?; + tracing::info!("Global config service initialized"); + + use bitfun_core::infrastructure::ai::AIClientFactory; + AIClientFactory::initialize_global() + .await + .context("Failed to initialize global AIClientFactory")?; + tracing::info!("Global AI client factory initialized"); + + crate::initialize_terminal_service().await; + + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let deployment = bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded; + let runtime_ownership = bitfun_core::runtime_ownership::CoreRuntimeOwnership::fixed_workspace( + path_manager.as_ref(), + "server", + &workspace_root, + deployment, + ) + .map_err(|error| anyhow::anyhow!(error.startup_message(deployment, "server")))?; + + let agentic_system = crate::agent::agentic_system::init_agentic_system( + bitfun_core::product_assembly::DeliveryProfile::Cli, + std::sync::Arc::new(runtime_ownership), + ) + .await + .context("Failed to initialize agentic system")?; + tracing::info!("Agentic system initialized"); + + let context = crate::runtime::AppServerRuntimeContext::build(agentic_system, &workspace_root)?; + let (agent_runtime, event_source, compatibility) = context.parts(); + let disconnect_runtime = agent_runtime.clone(); + let compatibility = std::sync::Arc::new(compatibility); + let app_runtime = BitfunAppRuntime::new(agent_runtime, event_source) + .with_context_reload(compatibility.clone()) + .with_product_search(compatibility); + let management = std::sync::Arc::new( + AppManagementService::load() + .await + .context("Failed to load app-server management service")?, + ); + + let limits = AppServerHostLimits::local_stdio(); + let policy = AppServerHostPolicy::new( + SERVER_HOST_IDENTITY, + &workspace_root, + ALLOWED_METHODS.iter().copied(), + ) + .map_err(anyhow::Error::new) + .context("Failed to build the server Host policy")?; + let disconnect = std::sync::Arc::new(AppServerDisconnect::default()); + let server = BitfunAppServer::new(app_runtime) + .with_management(management) + .with_host_limits(limits) + .with_host_policy(policy.clone()) + .with_disconnect(disconnect.clone()); + + let error_reporter = StdioErrorReporter::new(tokio::io::stdout()); + let stdout = tokio::io::stdout().compat_write(); + let stdin = StdioFrameReader::new( + tokio::io::stdin().compat(), + limits.max_frame_bytes, + disconnect.clone(), + error_reporter.clone(), + ); + + let served = server + .serve(bitfun_app_server::protocol::ByteStreams::new(stdout, stdin)) + .await; + + cancel_active_turns(&disconnect_runtime, policy.workspace_root()).await; + + match served { + Ok(()) => { + tracing::info!("App-server stdio connection ended, exiting the server Host"); + std::process::exit(0); + } + Err(error) => { + tracing::error!(error = ?error, "App-server stdio serving failed"); + error_reporter.flush_pending().await; + std::process::exit(1); + } + } +} + +/// Cancel every in-flight turn in the Host workspace after the connection +/// ends, so a client that disconnected mid-turn cannot leave work running. +async fn cancel_active_turns( + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + workspace_root: &std::path::Path, +) { + use bitfun_agent_runtime::sdk::{AgentSessionListRequest, AgentTurnCancellationRequest}; + let request = AgentSessionListRequest { + workspace_path: workspace_root.to_string_lossy().into_owned(), + remote_connection_id: None, + remote_ssh_host: None, + }; + let sessions = match runtime.list_sessions(request).await { + Ok(sessions) => sessions, + Err(error) => { + tracing::warn!(%error, "Failed to list Host sessions for disconnect cancellation"); + return; + } + }; + for session in sessions { + let request = AgentTurnCancellationRequest { + session_id: session.session_id, + turn_id: None, + source: None, + requester_session_id: None, + reason: Some("host transport disconnect".to_string()), + wait_timeout_ms: Some(1000), + cancel_descendants: true, + }; + if let Err(error) = runtime.cancel_turn(request).await { + tracing::warn!(%error, "Failed to cancel a Host turn after disconnect"); + } + } +} diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 2355453cb2..54b01811a8 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -271,12 +271,17 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .await .map(|sessions| RuntimeIpcOperationResult::Sessions { sessions }) .map_err(runtime_ipc_error), - RuntimeIpcOperation::CreateSession { request } => self - .runtime - .create_session(request) - .await - .map(|session| RuntimeIpcOperationResult::SessionCreated { session }) - .map_err(runtime_ipc_error), + RuntimeIpcOperation::CreateSession { request } => { + let session = self + .runtime + .create_session(request) + .await + .map_err(runtime_ipc_error)?; + let workspace_binding = self.session_workspace_binding(&session.session_id).await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; + Ok(RuntimeIpcOperationResult::SessionCreated { session }) + } RuntimeIpcOperation::RestoreSession { request } => { let restored = self .runtime @@ -313,6 +318,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let workspace_binding = self .session_workspace_binding(&restored.session.session_id) .await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; Ok(RuntimeIpcOperationResult::SessionRestored { session: restored.session, state: runtime_session_state(restored.state), @@ -369,6 +376,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let workspace_binding = self .session_workspace_binding(&restored.session.session_id) .await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; Ok(RuntimeIpcOperationResult::SessionForked { session: restored.session, workspace_binding, @@ -482,6 +491,9 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .map(|snapshot| RuntimeIpcOperationResult::WorkspaceDiff { snapshot }) .map_err(runtime_ipc_error), RuntimeIpcOperation::SubmitTurn { request } => { + let workspace_binding = self.session_workspace_binding(&request.session_id).await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; let outcome = self .runtime .submit_dialog_turn(request) @@ -518,15 +530,19 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { }, }) .map_err(runtime_ipc_error), - RuntimeIpcOperation::RunUserShellCommand { request } => self - .runtime - .run_user_shell_command(request) - .await - .map(|result| RuntimeIpcOperationResult::TurnAccepted { - session_id: result.session_id, - turn_id: result.turn_id, - }) - .map_err(runtime_ipc_error), + RuntimeIpcOperation::RunUserShellCommand { request } => { + let workspace_binding = self.session_workspace_binding(&request.session_id).await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; + self.runtime + .run_user_shell_command(request) + .await + .map(|result| RuntimeIpcOperationResult::TurnAccepted { + session_id: result.session_id, + turn_id: result.turn_id, + }) + .map_err(runtime_ipc_error) + } RuntimeIpcOperation::CancelTurn { request } => self .runtime .cancel_turn(request) @@ -755,6 +771,15 @@ async fn await_permission_route( } impl SharedRuntimeHandler { + async fn ensure_plugin_workspace_ready( + &self, + binding: &AgentSessionWorkspaceBinding, + ) -> std::result::Result<(), RuntimeIpcError> { + crate::plugin_host_activation::ensure_plugin_workspace_ready(binding) + .await + .map_err(core_ipc_error) + } + async fn session_workspace_binding( &self, session_id: &str, diff --git a/src/apps/cli/tests/app_server_stdio_cli.rs b/src/apps/cli/tests/app_server_stdio_cli.rs new file mode 100644 index 0000000000..ad3a8ac34a --- /dev/null +++ b/src/apps/cli/tests/app_server_stdio_cli.rs @@ -0,0 +1,357 @@ +mod support; + +use std::process::Stdio; +use std::time::Duration; + +use serde_json::{json, Value}; +use support::{CliTestEnvironment, MockOpenAiServer}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + +struct AppServerProcess { + child: tokio::process::Child, + stdin: Option, + stdout: Option>, + stderr_reader: tokio::task::JoinHandle, +} + +impl AppServerProcess { + async fn spawn(environment: &CliTestEnvironment) -> Self { + let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_bitfun")); + command + .arg("server") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + environment.apply_tokio_environment(&mut command); + + let mut child = command.spawn().expect("start production app-server Host"); + let stdin = child.stdin.take().expect("app-server stdin"); + let stdout = BufReader::new(child.stdout.take().expect("app-server stdout")); + let mut stderr = child.stderr.take().expect("app-server stderr"); + let stderr_reader = tokio::spawn(async move { + let mut bytes = Vec::new(); + stderr + .read_to_end(&mut bytes) + .await + .expect("read app-server stderr"); + String::from_utf8_lossy(&bytes).into_owned() + }); + + Self { + child, + stdin: Some(stdin), + stdout: Some(stdout), + stderr_reader, + } + } + + async fn initialize(&mut self) -> Value { + self.request( + 1, + "app/initialize", + json!({ + "protocolVersion": 3, + "client": { "name": "stdio-host-test", "version": "0.0.1" } + }), + ) + .await + } + + async fn request(&mut self, id: i64, method: &str, params: Value) -> Value { + self.send_request(id, method, params).await; + self.read_response(id, method).await + } + + async fn send_request(&mut self, id: i64, method: &str, params: Value) { + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + let mut request = serde_json::to_vec(&request).expect("serialize app-server request"); + request.push(b'\n'); + let stdin = self + .stdin + .as_mut() + .expect("app-server stdin remains available"); + stdin + .write_all(&request) + .await + .expect("write app-server request"); + stdin.flush().await.expect("flush app-server request"); + } + + async fn read_response(&mut self, expected_id: i64, operation: &str) -> Value { + self.read_until(operation, |message| { + message.get("id").and_then(Value::as_i64) == Some(expected_id) + }) + .await + } + + async fn read_until(&mut self, operation: &str, predicate: impl Fn(&Value) -> bool) -> Value { + loop { + let mut line = String::new(); + let bytes_read = tokio::time::timeout( + Duration::from_secs(60), + self.stdout + .as_mut() + .expect("app-server stdout remains available") + .read_line(&mut line), + ) + .await + .unwrap_or_else(|_| panic!("app-server {operation} timed out")) + .expect("read app-server stdout"); + assert_ne!( + bytes_read, 0, + "app-server stdout closed while waiting for {operation}" + ); + + let message: Value = serde_json::from_str(&line).unwrap_or_else(|error| { + panic!("app-server stdout contained non-JSON data: {error}: {line}") + }); + if predicate(&message) { + return message; + } + assert!( + message.get("method").is_some() || message.get("error").is_some(), + "unexpected app-server response while waiting for {operation}: {message}" + ); + } + } + + async fn wait_for_exit(&mut self, operation: &str) -> std::process::ExitStatus { + tokio::time::timeout(Duration::from_secs(10), self.child.wait()) + .await + .unwrap_or_else(|_| panic!("app-server Host did not exit after {operation}")) + .expect("wait for app-server Host") + } + + async fn shutdown(mut self) -> String { + drop(self.stdin.take()); + drop(self.stdout.take()); + let status = self.wait_for_exit("stdin close").await; + assert!(status.success(), "app-server Host exited with {status}"); + self.stderr_reader + .await + .expect("join app-server stderr reader") + } +} + +fn advertised_methods(initialize: &Value) -> Vec { + initialize + .pointer("/result/capabilities") + .and_then(Value::as_array) + .expect("capabilities array") + .iter() + .filter_map(|capability| capability.get("methods")) + .flat_map(|methods| methods.as_array().expect("methods array").iter()) + .filter_map(Value::as_str) + .map(str::to_string) + .collect() +} + +#[tokio::test] +async fn app_server_stdio_initializes_and_advertises_only_served_capabilities() { + let model = MockOpenAiServer::immediate(); + let environment = CliTestEnvironment::new(); + environment.initialize_git_repository(); + environment.configure_mock_model(model.base_url()); + + let mut server = AppServerProcess::spawn(&environment).await; + let initialize = server.initialize().await; + assert!(initialize.get("error").is_none(), "{initialize}"); + assert_eq!( + initialize.pointer("/result/server/name"), + Some(&json!("bitfun-app-server")) + ); + assert_eq!( + initialize.pointer("/result/limits/maxFrameBytes"), + Some(&json!(16777216)) + ); + + let methods = advertised_methods(&initialize); + assert!( + methods.contains(&"session/reloadContext".to_string()), + "context reload must be advertised when the CLI provides the reviewed compatibility port" + ); + for served in [ + "agent/createSession", + "session/sync", + "search/sessionContent", + "git/isRepository", + "config/getConfig", + "i18n/getCurrentLanguage", + "externalHook/snapshot", + "nativeHook/overview", + "skill/list", + "subagent/list", + "externalSource/snapshot", + ] { + assert!( + methods.contains(&served.to_string()), + "{served} must be advertised by the stdio Host" + ); + } + for denied in [ + "config/setConfig", + "config/saveCloudSpeechConfig", + "config/setAgentProfileConfig", + "config/resetAgentProfileConfig", + "i18n/setLanguage", + "i18n/setConfig", + "model/list", + "model/get", + "model/add", + "model/update", + "model/delete", + "model/setDefault", + "skill/setEnabled", + "subagent/setEnabled", + "mcp/list", + "account/snapshot", + "settingsSync/snapshot", + "worktree/bindSession", + "externalSource/control", + "externalSource/review", + "externalSource/setNativeCommandChoice", + "externalSource/expandCommand", + "externalSource/apply", + "externalHook/plan", + "externalHook/apply", + "externalHook/mutate", + ] { + assert!( + !methods.contains(&denied.to_string()), + "{denied} must not be advertised by the stdio Host" + ); + } + + let health = server.request(2, "app/health", json!({})).await; + assert_eq!(health.pointer("/result/status"), Some(&json!("ready"))); + + let stderr = server.shutdown().await; + assert!( + !stderr.contains("panicked"), + "app-server Host panicked: {stderr}" + ); +} + +#[tokio::test] +async fn app_server_stdio_fails_closed_on_scope_and_allowlist() { + let model = MockOpenAiServer::immediate(); + let environment = CliTestEnvironment::new(); + environment.initialize_git_repository(); + environment.configure_mock_model(model.base_url()); + + let mut server = AppServerProcess::spawn(&environment).await; + let initialize = server.initialize().await; + assert!(initialize.get("error").is_none(), "{initialize}"); + + let outside = environment + .workspace() + .parent() + .expect("workspace parent") + .join("outside") + .to_string_lossy() + .to_string(); + let denied_path = server + .request(2, "git/isRepository", json!({ "repositoryPath": outside })) + .await; + assert_eq!(denied_path.pointer("/error/code"), Some(&json!(-32602))); + assert_eq!( + denied_path.pointer("/error/data/reason"), + Some(&json!("path_outside_workspace_scope")) + ); + + let denied_method = server + .request( + 3, + "config/setConfig", + json!({ "configId": "mode", "value": "Plan" }), + ) + .await; + assert_eq!(denied_method.pointer("/error/code"), Some(&json!(-32601))); + assert_eq!( + denied_method.pointer("/error/data/reason"), + Some(&json!("method_not_allowed_by_host_policy")) + ); + + let stderr = server.shutdown().await; + assert!( + !stderr.contains("panicked"), + "app-server Host panicked: {stderr}" + ); +} + +#[tokio::test] +async fn app_server_stdio_exits_deterministically_on_stdin_eof() { + let model = MockOpenAiServer::immediate(); + let environment = CliTestEnvironment::new(); + environment.initialize_git_repository(); + environment.configure_mock_model(model.base_url()); + + let mut server = AppServerProcess::spawn(&environment).await; + let initialize = server.initialize().await; + assert!(initialize.get("error").is_none(), "{initialize}"); + + drop(server.stdin.take()); + drop(server.stdout.take()); + let status = server.wait_for_exit("stdin EOF").await; + assert!(status.success(), "app-server Host exited with {status}"); + + let stderr = server + .stderr_reader + .await + .expect("join app-server stderr reader"); + assert!( + !stderr.contains("panicked"), + "app-server Host panicked: {stderr}" + ); +} + +#[tokio::test] +async fn app_server_stdio_fails_closed_on_oversized_frames() { + let model = MockOpenAiServer::immediate(); + let environment = CliTestEnvironment::new(); + environment.initialize_git_repository(); + environment.configure_mock_model(model.base_url()); + + let mut server = AppServerProcess::spawn(&environment).await; + let initialize = server.initialize().await; + assert!(initialize.get("error").is_none(), "{initialize}"); + + // 16 MiB + 1 bytes with no newline exceeds the advertised frame limit. + let oversized: Vec = vec![b'x'; 16 * 1024 * 1024 + 1]; + let mut stdin = server.stdin.take().expect("app-server stdin"); + stdin + .write_all(&oversized) + .await + .expect("write oversized frame"); + stdin.flush().await.expect("flush oversized frame"); + drop(stdin); + + let error = server + .read_until("frame limit error", |message| { + message.pointer("/error/data/reason") == Some(&json!("frame_exceeds_host_limit")) + }) + .await; + assert_eq!(error.pointer("/error/code"), Some(&json!(-32602))); + + drop(server.stdout.take()); + let status = server.wait_for_exit("oversized frame").await; + assert!( + !status.success(), + "oversized frames must terminate the Host with failure" + ); + + let stderr = server + .stderr_reader + .await + .expect("join app-server stderr reader"); + assert!( + !stderr.contains("panicked"), + "app-server Host panicked: {stderr}" + ); +} diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index efc78a46eb..ccbb49f176 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -323,13 +323,13 @@ fn interactive_tui_separates_runtime_deployment_from_domain_services() { "CliAgentRuntimeClient must own Embedded/Shared deployment and expose Remote workspace scope" ); assert!( - !CLI_MANIFEST.contains("bitfun-app-server =") + CLI_MANIFEST.contains("bitfun-app-server =") && !CLI_MANIFEST.contains("bitfun-app-server-client =") && !CLI_MANIFEST.contains("bitfun-app-server-protocol") && !CLI_MANIFEST.contains("bitfun-tui-management =") && !CHAT_MODE.contains("trait ModelService") && !CHAT_MODE.contains("trait ExternalSourceService"), - "CLI must consume stable contracts and must not depend on App Server wire DTOs or a shared TUI management crate" + "CLI may host the App Server stdio surface but must not depend on the typed App Server client transport, wire DTOs, or a shared TUI management crate" ); for runtime_operation in [ "pub(crate) async fn list_sessions(", diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 5f56a9513b..6fc396bd70 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -77,12 +77,12 @@ thiserror = { workspace = true } futures = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } -tower-http = { workspace = true } +tower-http = { workspace = true, features = ["fs"] } sha1 = { workspace = true } sha2 = { workspace = true } screenshots = { workspace = true } enigo = { workspace = true } -image = { workspace = true } +image = { workspace = true, features = ["jpeg", "png"] } resvg = { workspace = true } tempfile = { workspace = true } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 1ec2f1e28d..30279852ce 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1658,6 +1658,24 @@ pub async fn create_session( None }; + if let Err(error) = runtime + .session_application() + .ensure_configured_plugin_instance( + desktop_session_scope( + request.workspace_path.clone(), + remote_conn.clone(), + remote_ssh_host.clone(), + ), + request.workspace_id.clone(), + ) + .await + { + warn!( + "Configured workspace plugin activation failed before session creation: {}", + error + ); + } + if is_idempotent_managed_create { let session_id = request .session_id diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index c4b382ce7c..838d07c9f8 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -249,10 +249,14 @@ impl AppState { } // Initialize SSH Remote services synchronously so they're ready before app starts - let ssh_data_dir = dirs::data_local_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("BitFun") - .join("ssh"); + let ssh_data_dir = if crate::e2e_storage_guard_enabled() { + workspace_service.path_manager().user_data_dir().join("ssh") + } else { + dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("BitFun") + .join("ssh") + }; let ssh_manager = Arc::new(RwLock::new(None)); let ssh_manager_clone = ssh_manager.clone(); let remote_file_service = Arc::new(RwLock::new(None)); diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index b4d8503477..0c529d4951 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -2112,19 +2112,11 @@ mod tests { } #[test] - fn external_source_control_web_command_is_registered() { + fn external_source_control_command_is_registered() { const COMMAND: &str = "get_external_source_control_snapshot"; - let web_api = include_str!( - "../../../../web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts" - ); - - assert!( - web_api.contains(&format!("invokeSurfaceSnapshot('{COMMAND}'")), - "Web UI must invoke the stable external-source control command" - ); assert!( registered_commands().contains(COMMAND), - "Desktop must register the external-source control command invoked by Web UI" + "Desktop must register the external-source control command" ); } diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index e977cd8ee6..681dc4caea 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -9,7 +9,7 @@ use bitfun_core::service::snapshot::{ initialize_snapshot_manager_for_workspace, open_snapshot_manager_for_view, FileChangeEntry, OperationType, SnapshotConfig, SnapshotManager, }; -use bitfun_runtime_ports::SessionStoragePathRequest; +use bitfun_runtime_ports::{AgentSessionWorkspaceLocation, SessionStoragePathRequest}; use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; @@ -370,8 +370,12 @@ async fn ensure_local_snapshot_mutation_path( async fn ensure_complete_rollback_supported( workspace_path: &str, remote_scope: &SnapshotRemoteScope, + explicit_location: Option, ) -> Result<(), String> { - if remote_scope.declares_remote() || is_remote_path(workspace_path).await { + let is_remote = remote_scope.declares_remote() + || explicit_location == Some(AgentSessionWorkspaceLocation::Remote) + || (explicit_location.is_none() && is_remote_path(workspace_path).await); + if is_remote { return Err(format!( "Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {workspace_path}" )); @@ -563,7 +567,8 @@ pub async fn rollback_session( runtime: State<'_, DesktopRuntimeContext>, request: RollbackSessionRequest, ) -> Result, String> { - ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope).await?; + ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope, None) + .await?; ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let _history_mutation = begin_snapshot_history_mutation( runtime.inner(), @@ -606,7 +611,12 @@ pub async fn rollback_session_to_turn( remote_connection_id: request.remote_connection_id.clone(), remote_ssh_host: request.remote_ssh_host.clone(), }; - ensure_complete_rollback_supported(&request.workspace_path, &remote_scope).await?; + ensure_complete_rollback_supported( + &request.workspace_path, + &remote_scope, + request.explicit_workspace_location(), + ) + .await?; ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; runtime .session_application() @@ -1357,7 +1367,7 @@ mod tests { remote_ssh_host: Some("example.com".to_string()), }; - let error = ensure_complete_rollback_supported("/root/repos", &scope) + let error = ensure_complete_rollback_supported("/root/repos", &scope, None) .await .expect_err("remote rollback must fail before changing files or history"); @@ -1367,6 +1377,40 @@ mod tests { ); } + #[tokio::test] + async fn explicit_local_rollback_identity_wins_over_a_remote_path_collision() { + let workspace = tempfile::tempdir().expect("create local workspace"); + let workspace_path = workspace.path().to_string_lossy().to_string(); + let remote = + bitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager(); + remote + .register_remote_workspace( + workspace_path.clone(), + "rollback-path-collision".to_string(), + "Rollback collision test".to_string(), + "remote.example".to_string(), + ) + .await; + + let legacy_error = + ensure_complete_rollback_supported(&workspace_path, &Default::default(), None) + .await + .expect_err("legacy path-only requests must retain remote fallback behavior"); + assert!(legacy_error.contains("not supported for remote workspaces")); + + ensure_complete_rollback_supported( + &workspace_path, + &Default::default(), + Some(bitfun_runtime_ports::AgentSessionWorkspaceLocation::Local), + ) + .await + .expect("explicit local identity must disambiguate the registered remote path"); + + remote + .unregister_remote_workspace("rollback-path-collision", &workspace_path) + .await; + } + #[test] fn rollback_commands_reject_remote_workspaces_before_local_side_effects() { let source = include_str!("snapshot_service.rs"); diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index a1b2e8861d..f89f312138 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -400,11 +400,11 @@ pub struct RestartAppRequest {} #[allow(unreachable_code)] pub async fn restart_app(app: AppHandle, request: RestartAppRequest) -> Result<(), String> { let _ = request; - crate::crash_diagnostics::mark_clean_shutdown("restart_app"); crate::save_main_window_state(&app, "restart_app"); - crate::perform_process_exit_cleanup(); + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown("restart_app"); + log::info!("Desktop restart authorized after graceful shutdown"); app.restart(); - Ok(()) } #[derive(Debug, Serialize, Deserialize)] @@ -659,9 +659,10 @@ pub async fn set_main_window_transient_geometry( #[tauri::command] pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { log::info!("Quit requested via quit_app command"); - crate::crash_diagnostics::mark_clean_shutdown("quit_app_command"); crate::save_main_window_state(&app, "quit_app_command"); - crate::perform_process_exit_cleanup(); + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown("quit_app_command"); + log::info!("Desktop exit authorized after graceful shutdown: reason=quit_app_command"); app.exit(0); Ok(()) } @@ -731,9 +732,12 @@ pub async fn startup_window_control( if behavior == "quit" { log::info!("Quit requested from startup window control"); - crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); crate::save_main_window_state(&app, "startup_window_control_quit"); - crate::perform_process_exit_cleanup(); + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); + log::info!( + "Desktop exit authorized after graceful shutdown: reason=startup_window_control" + ); app.exit(0); } else { if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 7128084f7a..1f7c767b60 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -51,7 +51,7 @@ use bitfun_transport::{TauriTransportAdapter, TransportAdapter}; use serde::Deserialize; use std::sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, OnceLock, }; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; @@ -91,6 +91,9 @@ use api::system_api::*; use api::tool_api::*; use startup_trace::{DesktopStartupTrace, DesktopStartupTraceSnapshot}; +pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = + bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled; + /// Agentic Coordinator state #[derive(Clone)] pub struct CoordinatorState { @@ -315,6 +318,12 @@ fn handle_secondary_launch(app: &tauri::AppHandle) { } } +pub(crate) fn e2e_storage_guard_enabled() -> bool { + std::env::var("BITFUN_E2E_STORAGE_GUARD") + .ok() + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) +} + fn main_window_state_flags() -> StateFlags { main_window_geometry_state_flags() | StateFlags::MAXIMIZED } @@ -615,6 +624,22 @@ pub async fn run() { startup_timings.record_elapsed("initialize_global_config", step_started); startup_trace.record_elapsed_step("native_pre_tauri", "initialize_global_config", step_started); + let step_started = Instant::now(); + match bitfun_core::plugin_host::initialize_configured_plugin_host_with_log_file( + PLUGIN_HOST_LAUNCH_POLICY, + Some(session_log_dir.join("plugin-host.log")), + ) + .await + { + Ok(bitfun_core::plugin_host::PluginHostStartup::Disabled) => {} + Ok(status) => log::info!("Plugin host initialization completed: {:?}", status), + Err(error) => { + log::error!("Failed to initialize configured plugin host: {}", error); + } + } + startup_timings.record_elapsed("initialize_plugin_host", step_started); + startup_trace.record_elapsed_step("native_pre_tauri", "initialize_plugin_host", step_started); + // The three steps below only depend on the global config service (initialized // above) and write to disjoint global singletons, so they can run concurrently: // - initialize_global_i18n_service: reads config, sets the global i18n singleton @@ -1994,11 +2019,15 @@ pub async fn run() { match app { Ok(app) => { - app.run(|_app_handle, event| match event { - tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { - crash_diagnostics::mark_clean_shutdown("tauri_run_exit"); - save_main_window_state(_app_handle, "tauri_run_exit"); - perform_process_exit_cleanup(); + app.run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { api, code, .. } => { + if !PROCESS_EXIT_CLEANUP_COMPLETE.load(Ordering::Acquire) { + api.prevent_exit(); + request_desktop_exit(app_handle, code.unwrap_or(0), "tauri_exit_requested"); + } + } + tauri::RunEvent::Exit => { + perform_process_exit_cleanup_emergency(); } #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { @@ -2010,7 +2039,7 @@ pub async fn run() { } else { "dock_reopen_no_visible_windows" }; - show_main_window_on_macos(_app_handle, reason); + show_main_window_on_macos(app_handle, reason); } _ => {} }); @@ -2274,21 +2303,75 @@ fn setup_panic_hook() { return; } - perform_process_exit_cleanup(); + perform_process_exit_cleanup_emergency(); std::process::exit(1); })); } -pub(crate) fn perform_process_exit_cleanup() -> bool { - static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); +static PROCESS_EXIT_CLEANUP_STARTED: AtomicBool = AtomicBool::new(false); +static PROCESS_EXIT_CLEANUP_COMPLETE: AtomicBool = AtomicBool::new(false); +static DESKTOP_EXIT_REQUESTED: AtomicBool = AtomicBool::new(false); +static PROCESS_EXIT_CLEANUP_NOTIFY: OnceLock = OnceLock::new(); - if CLEANUP_DONE - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return false; +pub(crate) async fn perform_process_exit_cleanup() -> bool { + let notify = PROCESS_EXIT_CLEANUP_NOTIFY.get_or_init(tokio::sync::Notify::new); + if PROCESS_EXIT_CLEANUP_STARTED.swap(true, Ordering::AcqRel) { + loop { + let notified = notify.notified(); + if PROCESS_EXIT_CLEANUP_COMPLETE.load(Ordering::Acquire) { + return false; + } + notified.await; + } + } + + log::info!("Desktop process graceful shutdown started"); + match bitfun_core::plugin_host::shutdown_configured_plugin_host().await { + Ok(Some(report)) => log::info!( + "Desktop plugin host shutdown completed: generation={}, disposition={:?}, rpc_completed={}, exit_code={:?}, duration_ms={}", + report.generation, + report.disposition, + report.rpc_completed, + report.exit_code, + report.duration_ms + ), + Ok(None) => log::debug!("Desktop plugin host shutdown skipped: host not started"), + Err(error) => log::warn!("Desktop plugin host shutdown failed: {}", error), + } + if let Some(search_service) = get_global_workspace_search_service() { + search_service.shutdown_blocking(); } + bitfun_core::util::process_manager::cleanup_all_processes(); + api::remote_connect_api::cleanup_on_exit(); + PROCESS_EXIT_CLEANUP_COMPLETE.store(true, Ordering::Release); + notify.notify_waiters(); + log::info!("Desktop process graceful shutdown completed"); + true +} +pub(crate) fn request_desktop_exit(app: &tauri::AppHandle, exit_code: i32, reason: &'static str) { + if DESKTOP_EXIT_REQUESTED.swap(true, Ordering::AcqRel) { + return; + } + save_main_window_state(app, reason); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + perform_process_exit_cleanup().await; + crash_diagnostics::mark_clean_shutdown(reason); + log::info!( + "Desktop exit authorized after graceful shutdown: reason={}, exit_code={}", + reason, + exit_code + ); + app.exit(exit_code); + }); +} + +pub(crate) fn perform_process_exit_cleanup_emergency() -> bool { + if PROCESS_EXIT_CLEANUP_COMPLETE.load(Ordering::Acquire) { + return false; + } + log::warn!("Desktop emergency process cleanup started"); if let Some(search_service) = get_global_workspace_search_service() { search_service.shutdown_blocking(); } @@ -2608,6 +2691,14 @@ fn spawn_runtime_log_level_listener(default_level: log::LevelFilter) { Ok(ConfigUpdateEvent::LogLevelUpdated { new_level }) => { if let Some(level) = logging::parse_log_level(&new_level) { logging::apply_runtime_log_level(level, "config_update_event"); + if let Err(error) = + bitfun_core::plugin_host::set_configured_plugin_host_log_level( + logging::level_to_str(level), + ) + .await + { + log::warn!("Failed to update plugin host log level: {}", error); + } } else { log::warn!( "Received invalid log level from config update event: {}", @@ -2618,6 +2709,14 @@ fn spawn_runtime_log_level_listener(default_level: log::LevelFilter) { Ok(ConfigUpdateEvent::ConfigReloaded) => { let level = resolve_runtime_log_level(default_level).await; logging::apply_runtime_log_level(level, "config_reloaded"); + if let Err(error) = + bitfun_core::plugin_host::set_configured_plugin_host_log_level( + logging::level_to_str(level), + ) + .await + { + log::warn!("Failed to update plugin host log level: {}", error); + } } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Closed) => { diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 8748c0ea6f..3e7a5a6ff6 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -386,6 +386,32 @@ impl DesktopSessionApplication { self.ensure_runtime_ownership(&scope) } + pub(crate) async fn ensure_configured_plugin_instance( + &self, + request: DesktopSessionScopeRequest, + project_id: Option, + ) -> DesktopSessionApplicationResult> { + let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; + if scope.remote_connection_id.is_some() { + log::debug!( + "Configured plugin host activation skipped for remote workspace: workspace_path={}", + scope.workspace_path + ); + return Ok(None); + } + let workspace_path = PathBuf::from(&scope.workspace_path); + bitfun_core::plugin_host::ensure_configured_plugin_instance( + crate::PLUGIN_HOST_LAUNCH_POLICY, + workspace_path.clone(), + workspace_path, + project_id, + serde_json::Map::new(), + ) + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + } + pub(crate) async fn list_persisted_sessions( &self, request: DesktopSessionScopeRequest, diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 3e318e090a..b5ca83afcb 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -219,10 +219,7 @@ pub fn setup_tray( show_main_window(app); } else if id == "quit" { log::info!("Quit requested from tray menu"); - crate::crash_diagnostics::mark_clean_shutdown("tray_quit"); - crate::save_main_window_state(app, "tray_quit"); - crate::perform_process_exit_cleanup(); - app.exit(0); + crate::request_desktop_exit(app, 0, "tray_quit"); } else if id == "toggle_desktop_pet" { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { diff --git a/src/apps/desktop/src/webview_recovery.rs b/src/apps/desktop/src/webview_recovery.rs index 73fc2aabe9..a9feb82e21 100644 --- a/src/apps/desktop/src/webview_recovery.rs +++ b/src/apps/desktop/src/webview_recovery.rs @@ -239,10 +239,7 @@ mod windows { fn request_automatic_restart(app: &tauri::AppHandle) { log::warn!("Requesting controlled application restart for WebView2 recovery"); - crate::crash_diagnostics::mark_clean_shutdown("webview_recovery_restart"); - crate::save_main_window_state(app, "webview_recovery_restart"); - crate::perform_process_exit_cleanup(); - app.request_restart(); + request_controlled_restart(app, "webview_recovery_restart"); } fn show_escape_dialog(app: tauri::AppHandle) { @@ -265,19 +262,27 @@ mod windows { request_user_restart(&app) } _ => { - crate::crash_diagnostics::mark_clean_shutdown("webview_recovery_exit"); - crate::save_main_window_state(&app, "webview_recovery_exit_dialog"); - crate::perform_process_exit_cleanup(); - app.exit(1); + crate::request_desktop_exit(&app, 1, "webview_recovery_exit"); } }); } fn request_user_restart(app: &tauri::AppHandle) { - crate::crash_diagnostics::mark_clean_shutdown("webview_recovery_user_restart"); - crate::save_main_window_state(app, "webview_recovery_user_restart"); - crate::perform_process_exit_cleanup(); - app.request_restart(); + request_controlled_restart(app, "webview_recovery_user_restart"); + } + + fn request_controlled_restart(app: &tauri::AppHandle, reason: &'static str) { + crate::save_main_window_state(app, reason); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown(reason); + log::info!( + "Desktop restart authorized after graceful shutdown: reason={}", + reason + ); + app.request_restart(); + }); } fn map_failure_kind(kind: COREWEBVIEW2_PROCESS_FAILED_KIND) -> FailureKind { diff --git a/src/apps/extension-host/.gitattributes b/src/apps/extension-host/.gitattributes new file mode 100644 index 0000000000..dfe0770424 --- /dev/null +++ b/src/apps/extension-host/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/src/apps/extension-host/.gitignore b/src/apps/extension-host/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/src/apps/extension-host/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/src/apps/extension-host/PROTOCOL.md b/src/apps/extension-host/PROTOCOL.md new file mode 100644 index 0000000000..d171693f7d --- /dev/null +++ b/src/apps/extension-host/PROTOCOL.md @@ -0,0 +1,514 @@ +# Extension host protocol + +This document defines protocol version `1` for the standalone Bun extension host. The names and casing shown here are wire-level names: the Zod schemas in `src/protocol.ts`, generated `protocol.schema.json`, this document, and the Rust adapter implementation must stay identical. + +The compatibility target is the established Server plugin API published as `@opencode-ai/plugin@1.17.18`. OpenCode values below are JSON projections of public package types, not imports from OpenCode Core, Protocol, or Server. + +## Roles and connection + +- **Backend** is the Rust process. It owns the TCP listener, application state, HTTP behavior, auth persistence, lifecycle timing, supervision, and hard timeouts. +- **Host** is the Bun child process. It connects to Rust, loads plugins, retains JavaScript functions, and owns per-instance HTTP gateways. +- **Plugin** is trusted JavaScript or TypeScript loaded into the host. + +Rust binds a loopback TCP address before spawning the host and sets: + +- `OPENCODE_EXTENSION_HOST_RPC_ADDRESS`, conventionally `127.0.0.1:`. +- `OPENCODE_EXTENSION_HOST_RPC_TOKEN`, a fresh high-entropy secret for this child. + +The host makes one TCP connection and immediately calls `backend.handshake`. Rust must not issue a `host.*` call until that handshake succeeds. A failed handshake closes the connection and the host exits. + +### Framing and JSON-RPC + +Every message is a four-byte unsigned big-endian payload length followed by exactly that many bytes of UTF-8 JSON. The payload is one JSON-RPC 2.0 request, notification, success response, or error response; batches are not supported. + +The initial receive limit is 16 MiB (`16_777_216` bytes). The handshake negotiates the limit for later frames. The effective value may never exceed 64 MiB (`67_108_864` bytes), and an oversized length is rejected before allocating its payload. + +Requests can travel in both directions. Each peer must keep reading and dispatching incoming calls while awaiting a response, because plugin operations can make reentrant backend calls. Request IDs are directional strings unique for the TCP connection: + +- Host-originated: `host:` +- Backend-originated: `backend:` + +Responses echo the ID unchanged. Notifications omit `id`. Plugin stdout and stderr are ordinary process output and are never protocol channels. + +### Handshake + +The host sends: + +```json +{ + "jsonrpc": "2.0", + "id": "host:1", + "method": "backend.handshake", + "params": { + "token": "value from OPENCODE_EXTENSION_HOST_RPC_TOKEN", + "protocolVersion": 1, + "opencodeVersion": "1.17.18", + "maxFrameBytes": 16777216 + } +} +``` + +Rust returns: + +```json +{ + "jsonrpc": "2.0", + "id": "host:1", + "result": { + "protocolVersion": 1, + "maxFrameBytes": 16777216, + "cacheDirectory": "/absolute/path/to/plugin-cache" + } +} +``` + +`cacheDirectory` must be absolute and writable by the host. It is the only location in which the host installs npm plugins. The accepted `maxFrameBytes` remains fixed until disconnect. + +## Common wire types + +### JSON values + +An ordinary wire value is `null`, a boolean, a finite number, a string, an array of wire values, or a plain object with string keys and wire values. Cycles, functions, `BigInt`, symbols, `undefined`, non-finite numbers, and non-plain object instances are rejected. Optional properties are omitted rather than encoded as `undefined`. + +A serialization compatibility error identifies the path of the rejected value. The only function-valued capability projection is an `auth.loader` result's `fetch` property, described below. + +### Headers, HTTP, and diagnostics + +Headers always cross as an array of string pairs so repeated values can be preserved. + +```ts +type Headers = Array<[string, string]> + +type StreamDescriptor = { + streamID: string + length?: number +} + +type GatewayHttpRequest = { + instanceID: string + requestID: string + method: string + path: string // path and query + headers: Headers + body?: StreamDescriptor +} + +type AuthFetchRequest = { + url: string + method?: string + headers?: Headers + body?: StreamDescriptor +} + +type HttpResponse = { + status: number // 100 through 599 + statusText?: string + headers: Headers + body?: StreamDescriptor +} + +type Diagnostic = { + severity: "debug" | "info" | "warning" | "error" + code: string + message: string + plugin?: string + method?: string + data?: JsonValue +} +``` + +`requestID` identifies one HTTP or auth-fetch invocation. `length`, when present, is a non-negative byte-length hint; EOF remains authoritative. URL and `Headers` objects returned by plugins are normalized before crossing the wire. + +### Pull streams + +The process that creates a `ReadableStream` registers it and sends a `StreamDescriptor`. The receiver pulls from the owner: + +- Rust calls `host.stream.read` for a host-owned descriptor. +- Bun calls `backend.stream.read` for a backend-owned descriptor. + +Read params and results are: + +```ts +type StreamReadParams = { + instanceID: string + streamID: string + maxBytes?: number // 1 through 65_536 +} + +type StreamReadResult = { + data: string // base64 + eof: boolean +} +``` + +One read returns no more than `maxBytes`, with a 64 KiB maximum. `eof: true` releases the stream. A receiver that stops before EOF calls the owner's `*.stream.cancel` with `{ instanceID, streamID, reason? }`; cancellation is idempotent. + +### Process-local identity + +`instanceID`, `executionID`, `flowID`, `fetchID`, `requestID`, and `streamID` have no durable meaning. Rust must keep them with their creating instance and connection. Closing an instance invalidates its active capabilities; losing the process invalidates all of them. + +## Rust-to-host methods + +### Instance lifecycle + +#### `host.instance.open` + +Params: + +```ts +{ + instanceID: string + project: JsonValue + config: Record + directory: string + worktree: string + plugins: Array<{ + spec: string + options?: Record + baseDirectory?: string + }> +} +``` + +Result: + +```ts +{ + instanceID: string + config: Record + diagnostics: Diagnostic[] + hooks: string[] + tools: Array<{ + registrationID: string + id: string + plugin?: Record + description: string + parameters: JsonValue // JSON Schema + }> + auth: AuthRegistration[] + providers: Array<{ + provider: string + plugin?: Record + hasModels: boolean + }> + workspaces: Array<{ + registrationID: string + type: string + plugin?: Record + name: string + description: string + }> + gatewayURL: string +} +``` + +The gateway is listening before plugin entrypoints execute, so SDK calls during initialization work. Config hooks run sequentially before the result is sent. Failed plugins are omitted and represented in `diagnostics`; successful registrations remain available. + +Opening an active `instanceID` or a directory already owned by another instance is an error. Reopening after close creates a new instance and reruns entrypoints while preserving Bun's normal process-global module cache. + +`hooks` may contain: + +- `chat.message` +- `chat.params` +- `chat.headers` +- `permission.ask` +- `command.execute.before` +- `tool.execute.before` +- `shell.env` +- `tool.execute.after` +- `experimental.chat.messages.transform` +- `experimental.chat.system.transform` +- `experimental.provider.small_model` +- `experimental.session.compacting` +- `experimental.compaction.autocontinue` +- `experimental.text.complete` +- `tool.definition` + +`config`, `event`, `dispose`, `tool`, `auth`, and `provider` are lifecycle hooks or registrations, not operational names. + +#### `host.instance.close` + +Params: `{ instanceID }`. Result: `{ closed: boolean }`. + +The host rejects new operations, aborts active tools and fetches, releases auth flows and streams, closes the gateway, and invokes every disposer once. Dispose failures are diagnostics and do not stop remaining cleanup. Repeated close is idempotent. + +#### `host.shutdown` + +Params: `{}`. Result: `{ closed: boolean }`. + +The host closes all instances, responds, closes the RPC connection, and exits normally. RPC EOF performs the same best-effort global cleanup before exit. + +### Hooks and events + +#### `host.hook.call` + +Params: `{ instanceID, hook, input, output }`. Result: `{ input, output }`. + +`input` and `output` are JSON values. Matching hooks run sequentially in plugin order on the same live objects for this invocation. The first hook error stops the invocation; earlier mutations are not rolled back. Different hook requests may overlap. + +For `tool.definition`, `output.parameters` crosses the process boundary as JSON Schema rather than an Effect schema object. + +#### `host.event.emit` + +Params: `{ instanceID, event }`. Result: `{ accepted: true }`. + +The host schedules event hooks in plugin order and responds without awaiting completion. Later failures are sent through `backend.diagnostic.publish`. + +### Tools + +#### `host.tool.execute` + +Params: + +```ts +{ + instanceID: string + executionID: string + registrationID: string + args: JsonValue + context: { + sessionID: string + messageID: string + agent: string + callID?: string + } +} +``` + +Result is the public plugin `ToolResult`: + +```ts +type ToolResult = + | string + | { + title?: string + output: string + metadata?: Record + attachments?: Array<{ + type: "file" + mime: string + url: string + filename?: string + }> + } +``` + +The host reconstructs a per-execution `AbortSignal` and fills the public tool context's `directory` and `worktree` from the instance. `context.metadata(...)` sends the `backend.tool.metadata` notification and returns synchronously. `context.ask(...)` awaits `backend.tool.ask`. + +Tool registration parameters and later `tool.definition` parameters use their JSON Schema projection. Rust sends arguments; Bun validates them through the retained plugin schema before execution. + +Rust invokes the tool by the returned opaque `registrationID`. `id` is the plugin-facing tool name and is not an execution handle. + +#### `host.tool.cancel` + +Params: `{ instanceID, executionID }`. Result: `{ cancelled: boolean }`. + +The host aborts the retained signal. Cancellation is idempotent and does not hard-kill subprocesses created by a plugin. + +### Auth + +Auth registrations in `host.instance.open` use: + +```ts +type AuthRegistration = { + provider: string + plugin?: Record + hasLoader: boolean + methods: Array<{ + type: "oauth" | "api" + label: string + methodIndex: number + hasAuthorize: boolean + prompts: Array<{ + type: "text" | "select" + promptIndex: number + key: string + message: string + placeholder?: string + options?: Array<{ label: string; value: string; hint?: string }> + when?: { key: string; op: "eq" | "neq"; value: string } + hasValidate: boolean + hasCondition: boolean + }> + }> +} +``` + +The `has*` booleans advertise retained JavaScript capabilities. `when` remains ordinary data; validators and deprecated `condition` functions remain inside Bun. + +#### `host.auth.prompt.evaluate` + +Params: + +```ts +{ + instanceID: string + provider: string + methodIndex: number + promptIndex: number + operation: "validate" | "condition" + value?: string + inputs: Record +} +``` + +Result is `{ operation: "validate", error?: string }` or `{ operation: "condition", active: boolean }`. Rust calls only capabilities advertised by `hasValidate` or `hasCondition`; it can evaluate the serializable `when` rule itself. + +#### `host.auth.authorize` + +Params: `{ instanceID, provider, methodIndex, inputs? }`. + +An API method returns `{ type: "api", result? }`, where `result` is its public success/failed value. An OAuth method returns: + +```ts +{ + type: "oauth" + flowID: string + url: string + instructions: string + method: "auto" | "code" +} +``` + +The callback remains in Bun under `flowID`. Rust must not call an API method whose registration has `hasAuthorize: false`. + +#### `host.auth.callback` + +Params: `{ instanceID, flowID, code? }`. Result is the public OAuth success/failed union. `code` is required for a `code` flow and omitted for an `auto` flow. A flow survives until success, explicit cancellation, instance close, or process exit. + +#### `host.auth.flow.cancel` + +Params: `{ instanceID, flowID, reason? }`. Result: `{ cancelled: boolean }`. + +#### `host.auth.loader` + +Params: `{ instanceID, provider, providerInfo }`. `providerInfo` is the public SDK provider JSON value. + +Result: `{ value: Record, fetchID?: string }`. + +The loader receives a live auth getter. Every call to that getter makes a reentrant `backend.auth.get` request; the host does not cache auth state. Ordinary loader fields appear in `value`. A function-valued property named exactly `fetch` is retained in Bun and represented by `fetchID`; all other function-valued results are rejected. + +#### `host.auth.fetch` + +Params: `{ instanceID, fetchID, requestID, request: AuthFetchRequest }`. Result is `HttpResponse`. + +The host reconstructs a Fetch API request, invokes the retained fetch function, and exposes the response body as a host-owned stream. Bun pulls a backend-owned request body with `backend.stream.read`; Rust pulls the host-owned response body with `host.stream.read`. This capability is limited to provider SDK fetch overrides returned by `auth.loader`. + +#### `host.auth.fetch.cancel` + +Params: `{ instanceID, requestID, reason? }`. Result: `{ cancelled: boolean }`. + +#### `host.auth.fetch.release` + +Params: `{ instanceID, fetchID }`. Result: `{ released: boolean }`. This releases the retained function for future calls; repeated release is idempotent. + +### Providers + +#### `host.provider.models` + +Params: `{ instanceID, providerID, provider, auth? }`. Result: `{ models }`. + +`provider` is the public SDK v2 provider JSON value, `auth` is the optional public auth value, and `models` is a JSON object keyed by model ID. Rust calls this only when the corresponding registration has `hasModels: true`. + +### Workspaces + +Workspace adapters are invoked through their opaque `registrationID`; the open result also retains their descriptive `type`. Later registration of the same type replaces the earlier adapter. + +- `host.workspace.configure`: params `{ instanceID, registrationID, config }`; result `{ config }`. +- `host.workspace.create`: params `{ instanceID, registrationID, config, env, from? }`; result `{}`. +- `host.workspace.remove`: params `{ instanceID, registrationID, config }`; result `{}`. +- `host.workspace.target`: params `{ instanceID, registrationID, config }`; result is `{ target }`, where `target` is `{ type: "local", directory }` or `{ type: "remote", url, headers? }`. + +`config` and `from` use the public `WorkspaceInfo` JSON shape. `env` is a record of strings or null; null reconstructs `undefined` for the plugin. Remote `URL` and `HeadersInit` values are normalized to a URL string and header-pair list. + +### Host-owned streams + +- `host.stream.read`: params `StreamReadParams`; result `StreamReadResult`. +- `host.stream.cancel`: params `{ instanceID, streamID, reason? }`; result `{ cancelled: boolean }`. + +These methods accept only streams created by Bun, including gateway request bodies and auth-fetch response bodies. + +## Host-to-Rust methods + +### `backend.handshake` + +Authenticates and negotiates the connection as described in [Handshake](#handshake). It is the only method valid before the connection is ready. + +### `backend.http.request` + +Params are `GatewayHttpRequest`. Result is `HttpResponse`. + +`path` contains the gateway request's path and query, without its loopback origin. Bun creates the request-body descriptor, so Rust pulls it with `host.stream.read`. Rust creates the response-body descriptor, so Bun pulls it with `backend.stream.read`. WebSocket upgrades are rejected at the gateway and never forwarded. + +### `backend.auth.get` + +Params: `{ instanceID, providerID }`. Result: `{ auth: JsonValue | null }`. + +This request can arrive while Rust is awaiting `host.auth.loader` or an active auth fetch. Rust must service it reentrantly. The host makes a fresh request for every plugin getter call. + +### `backend.tool.ask` + +Params: + +```ts +{ + instanceID: string + executionID: string + permission: string + patterns: string[] + always: string[] + metadata: Record +} +``` + +Result: `{}` on approval, or a JSON-RPC error on denial/failure. The host awaits this request before resuming the tool. + +### `backend.tool.metadata` + +Notification params: `{ instanceID, executionID, title?, metadata? }`. Because this is a notification, the plugin's `metadata(...)` call returns without waiting for Rust. + +### `backend.diagnostic.publish` + +Notification params: `{ instanceID?, diagnostic }`. This reports isolated plugin load, lifecycle, event, serialization, or gateway failures. A diagnostic does not replace the error response for a directly failed request. + +### Backend-owned streams + +- `backend.stream.read`: params `StreamReadParams`; result `StreamReadResult`. +- `backend.stream.cancel`: params `{ instanceID, streamID, reason? }`; result `{ cancelled: boolean }`. + +These methods accept only streams created by Rust, including backend HTTP responses and auth-fetch request bodies. + +## Loading, ordering, and ownership + +- A declaration is `{ spec, options?, baseDirectory? }`. `baseDirectory` anchors a relative local spec. +- npm plugins install in the handshake cache with lifecycle scripts disabled. Local plugins import in place and must already resolve their runtime dependencies. +- Server entrypoint discovery supports `exports["./server"]`, `main`, direct Bun-loadable files, and index files while enforcing package-boundary containment. +- Declarations are deduplicated by npm package identity or canonical local file URL, retaining the last. +- Retained declarations resolve and import concurrently. Successful entrypoints execute sequentially in declaration order. +- Config hooks execute sequentially and isolate errors. Operational hooks execute sequentially and propagate the first error. +- Later duplicate tool IDs, auth providers, provider IDs, and workspace types replace earlier registrations. +- Event dispatch is fire-and-forget in plugin order. +- Different invocations and different instances may overlap; there is no global call lock. +- Rust owns correct hook timing and must stop using an instance's registrations after close. + +## Error model + +A JSON-RPC error is `{ code: integer, message: string, data?: JsonValue }`. Use the standard codes when applicable: + +- `-32700`: invalid JSON. +- `-32600`: invalid JSON-RPC envelope. +- `-32601`: unknown method. +- `-32602`: invalid params. +- `-32603`: unexpected internal failure. + +Application failures use the reserved server-error range `-32000` through `-32099` and put machine-readable details in `data`. Unknown instances/handles, invalid instance state, cancellation, plugin exceptions, and serialization failures must be distinguishable in that data. A serialization failure includes its offending value path. + +A frame with an invalid or oversized length is a connection-level failure. The detecting peer closes the TCP connection; all outstanding requests fail and the host performs global cleanup. Rust treats every process-local handle as lost and does not automatically replay plugin or provider work. + +## Security and recovery + +The Rust listener and every instance gateway bind to loopback only. Use a high-entropy, single-spawn handshake token. The token authenticates the expected child to Rust; it does not sandbox plugins. + +Plugin code can access the host user's files, environment variables, network, and Bun subprocess APIs. Only load trusted plugin specs. Disabling npm lifecycle scripts narrows install-time behavior, but imported plugin code itself remains fully privileged. + +The process boundary contains JavaScript crashes and keeps plugin code out of Rust. It does not provide durable execution identity or crash continuation. After EOF or process death, Rust opens fresh instances and explicitly decides what interrupted application work, if any, is safe to retry. diff --git a/src/apps/extension-host/README.md b/src/apps/extension-host/README.md new file mode 100644 index 0000000000..f5abc28eea --- /dev/null +++ b/src/apps/extension-host/README.md @@ -0,0 +1,123 @@ +# OpenCode extension host + +This directory is BitFun's standalone Bun process for running established OpenCode Server plugins outside the OpenCode server. It targets the public `@opencode-ai/plugin` and `@opencode-ai/sdk` contract at version `1.17.18` and is supervised by the Rust backend. + +The host is a compatibility process, not an OpenCode server. Rust owns application state, persistence, HTTP behavior, lifecycle timing, and process supervision. The Bun process owns plugin resolution, JavaScript execution, and the function-valued capabilities that cannot cross a JSON boundary. + +## Compatibility boundary + +The host supports: + +- Public OpenCode Server plugin entrypoints and hook types from `@opencode-ai/plugin@1.17.18`. +- npm package specs and local files or directories. +- Package `exports["./server"]`, package `main`, and Bun-loadable `index.ts`, `index.tsx`, `index.js`, `index.mjs`, or `index.cjs` files. +- Object-form `{ id, server }` modules and legacy function exports. +- Plugin tools, auth providers and OAuth callbacks, provider model callbacks, and experimental workspace adapters. +- Multiple independent directory-scoped instances in one host process. +- The official OpenCode SDK, raw HTTP, and SSE through a per-instance loopback gateway. + +It deliberately does **not** support: + +- TUI plugins or the v2 plugin API. +- OpenCode's built-in plugins. +- Discovery or loading of `opencode.json` and other OpenCode configuration files. +- WebSocket proxying. A gateway request that attempts a WebSocket upgrade is rejected. +- Isolation from a malicious plugin. Plugins execute as trusted native extensions with the host user's filesystem, environment, network, and subprocess authority. + +Local plugins are imported in place and must already be able to resolve their runtime dependencies. npm plugins are installed into the cache directory supplied by Rust during the handshake, with lifecycle scripts disabled. The host never installs into or edits a local plugin project. + +## Architecture + +Rust first binds a loopback TCP listener and then launches the host. The host connects to the address in `OPENCODE_EXTENSION_HOST_RPC_ADDRESS` and authenticates its first request with `OPENCODE_EXTENSION_HOST_RPC_TOKEN`. + +```text +OpenCode plugin + |-- hooks, tools, auth, provider, workspace --> Bun extension host + |-- official SDK / raw HTTP / SSE ----------> per-instance 127.0.0.1 gateway + | +Rust backend <====== framed bidirectional JSON-RPC ==+ +``` + +Control traffic uses JSON-RPC 2.0 messages framed by a four-byte big-endian length. Requests can travel in either direction and may be reentrant; plugin stdout and stderr are never used as protocol channels. HTTP and fetch bodies use pull-based stream handles so the receiver controls backpressure instead of embedding unbounded bodies in JSON. + +Each `host.instance.open` call creates one logical plugin instance and one HTTP gateway. The host resolves and imports retained plugin declarations concurrently, executes successful entrypoints in declaration order, runs their config hooks, and returns the resulting registrations. Operational hook calls are ordered within one invocation, but unrelated invocations and unrelated instances may overlap. + +Closing an instance rejects new work, cancels active tools and fetches, closes its gateway, and invokes every registered disposer once. Losing the RPC connection applies the same cleanup to all instances and terminates the host. Rust is responsible for restarting the process and deciding whether any application work should be retried. + +See [PROTOCOL.md](./PROTOCOL.md) for the complete method, error, and wire contract. `protocol.schema.json` is the generated machine-readable form consumed by a future Rust client; the Zod schemas in the implementation are canonical. + +## Build and launch + +The directory has its own dependency lock and does not rely on workspace-internal OpenCode packages. + +```sh +cd src/apps/extension-host +bun install --frozen-lockfile +bun typecheck +bun test +bun run build +``` + +The Rust supervisor should bind its listener before spawning the built host and inherit or redirect stdout and stderr normally: + +```sh +OPENCODE_EXTENSION_HOST_RPC_ADDRESS=127.0.0.1:48731 \ +OPENCODE_EXTENSION_HOST_RPC_TOKEN="$ONE_TIME_RANDOM_TOKEN" \ +bun ./dist/extension-host.js +``` + +The first host-to-Rust call is `backend.handshake`. Rust verifies the token, negotiates a frame limit, and supplies the npm plugin cache directory. Do not send instance requests until that handshake succeeds. The default negotiated frame limit is 16 MiB and neither peer may negotiate more than 64 MiB. + +At startup the host appends `127.0.0.1`, `localhost`, and `::1` to both `NO_PROXY` and `no_proxy`. This keeps the injected SDK and raw `serverUrl` traffic on the per-instance loopback gateway even when the supervisor environment defines an HTTP proxy; all other proxy settings remain visible to plugins. + +A normal supervisor sequence is: + +1. Bind the Rust-owned loopback listener, generate a fresh token, and spawn the host with the two environment variables. +2. Accept the connection and complete `backend.handshake`. +3. Open one or more instances with explicit project/config values and ordered plugin declarations. +4. Invoke hooks, tools, auth flows, provider callbacks, workspace adapters, and HTTP forwarding as application state requires. +5. Close individual instances when their directories are released. +6. Call `host.shutdown` for an orderly process shutdown, or close the TCP connection to force global cleanup. + +Rust should impose its own startup and request deadlines. The host intentionally does not provide durable recovery: instance IDs, registrations, active executions, stream handles, and auth-flow handles are process-local. + +## Loading plugins + +Rust passes declarations directly to `host.instance.open`: + +```ts +type PluginDeclaration = { + spec: string + options?: Record + baseDirectory?: string +} +``` + +`baseDirectory` anchors a relative local `spec`; it does not change the plugin's process working directory. A declaration without `baseDirectory` resolves a relative path from the instance directory. + +Declarations are deduplicated by npm package identity or canonical local file URL, retaining the last declaration. Resolution and import happen concurrently, but successful entrypoints execute sequentially in retained order. Later registrations replace earlier tools with the same tool ID, auth hooks for the same provider, provider hooks for the same provider ID, and workspace adapters for the same type. + +For npm packages, `engines.opencode` is checked against `1.17.18`. Install, entrypoint, compatibility, import, and entrypoint-execution failures are isolated to that plugin and returned as structured diagnostics. Config-hook and dispose-hook failures are also isolated; mutations completed before a config-hook failure remain visible. + +[`examples/example-plugin.ts`](./examples/example-plugin.ts) demonstrates the public plugin shape and the injected SDK, raw gateway URL, Bun shell, tool context, hook, and workspace APIs. + +## Gateway and streams + +Every instance gets a distinct `127.0.0.1` HTTP URL before its plugin entrypoints run. The injected SDK client uses that URL, and plugins may also use the injected `serverUrl` directly. The gateway forwards method, path and query, headers, and a streaming request body through `backend.http.request`, then reconstructs the backend's status, headers, and streaming response body. SSE therefore remains an ordinary streamed HTTP response. + +The side that creates a stream owns it. The other side repeatedly calls that owner's `*.stream.read` method, which returns at most 64 KiB encoded as base64, and then cancels or consumes the stream to EOF. Stream IDs, like every other opaque handle, are scoped to an instance and become invalid when that instance closes. + +## Serialization and diagnostics + +Only JSON-compatible data crosses the control channel. Cycles, functions, `BigInt`, and non-finite numbers produce a compatibility error that identifies the failing value path. Two deliberate projections handle public plugin values that are not natively serializable: + +- Tool parameter schemas cross as JSON Schema, including the `tool.definition` hook's `output.parameters` value. +- A function-valued `auth.loader` result named `fetch` remains in Bun and crosses as an opaque fetch handle. Other function-valued loader results are rejected. + +Plugin load failures, fire-and-forget event failures, and isolated lifecycle failures are published with `backend.diagnostic.publish`. Request failures use structured JSON-RPC errors; see [PROTOCOL.md](./PROTOCOL.md#error-model) for the stable error envelope. + +## Keeping the host self-contained + +Keep this entire directory together rather than moving only `dist/extension-host.js`. `package.json`, `bun.lock`, `protocol.schema.json`, and the protocol documentation must stay versioned as one unit with the JavaScript and Rust sides. After dependency or protocol changes, run the validation commands above and a subprocess handshake smoke test. + +Do not replace the pinned public packages with imports from this monorepo's Core, Protocol, Server, or generated internal modules. The dependency boundary is intentional: the extracted host must remain runnable without an OpenCode source checkout. diff --git a/src/apps/extension-host/bun.lock b/src/apps/extension-host/bun.lock new file mode 100644 index 0000000000..11906cf751 --- /dev/null +++ b/src/apps/extension-host/bun.lock @@ -0,0 +1,112 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@opencode-ai/extension-host", + "dependencies": { + "@opencode-ai/plugin": "1.17.18", + "@opencode-ai/sdk": "1.17.18", + "npm-package-arg": "13.0.2", + "zod": "4.1.8", + }, + "devDependencies": { + "@tsconfig/bun": "1.0.9", + "@types/bun": "1.3.13", + "@types/npm-package-arg": "6.1.4", + "typescript": "5.8.2", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.17.18", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.17.18", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.3", "@opentui/keymap": ">=0.4.3", "@opentui/solid": ">=0.4.3" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-tqVBzhTHYUzO0laAmcQeBtT56tXYM5VGUk9V60O+cMx4kkSfac4qEfPVguCGUMJnfcU+u+EPvpME6xmHWoQE8w=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/npm-package-arg": ["@types/npm-package-arg@6.1.4", "", {}, "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/src/apps/extension-host/examples/example-plugin.ts b/src/apps/extension-host/examples/example-plugin.ts new file mode 100644 index 0000000000..8c2e90fdd7 --- /dev/null +++ b/src/apps/extension-host/examples/example-plugin.ts @@ -0,0 +1,84 @@ +import type { Plugin, PluginModule } from "@opencode-ai/plugin" +import { tool } from "@opencode-ai/plugin" + +const ExamplePlugin: Plugin = async (input) => { + input.experimental_workspace.register("example-local", { + name: "Example local workspace", + description: "Use the plugin instance directory as a local workspace", + configure(workspace) { + return { + ...workspace, + directory: workspace.directory ?? input.directory, + } + }, + async create() {}, + async remove() {}, + target(workspace) { + if (!workspace.directory) throw new Error("Example workspace has no directory") + return { + type: "local", + directory: workspace.directory, + } + }, + }) + + return { + tool: { + extension_host_info: tool({ + description: "Exercise the injected OpenCode SDK client and raw HTTP gateway", + args: {}, + async execute(_args, context) { + context.metadata({ + title: "Inspect extension host", + metadata: { projectID: input.project.id }, + }) + + await input.client.project.current() + const response = await fetch(new URL("/project/current", input.serverUrl), { + signal: context.abort, + }) + await response.body?.cancel() + + return { + title: "Extension host", + output: JSON.stringify( + { + projectID: input.project.id, + directory: input.directory, + worktree: input.worktree, + serverUrl: input.serverUrl.href, + rawGatewayStatus: response.status, + }, + null, + 2, + ), + } + }, + }), + extension_host_bun_version: tool({ + description: "Exercise the injected Bun shell after requesting permission", + args: {}, + async execute(_args, context) { + await context.ask({ + permission: "example_shell", + patterns: ["bun --version"], + always: [], + metadata: { command: "bun --version" }, + }) + context.abort.throwIfAborted() + const version = (await input.$`bun --version`.text()).trim() + context.abort.throwIfAborted() + return `Bun ${version}` + }, + }), + }, + async "chat.headers"(_hookInput, output) { + output.headers["x-example-extension-host"] = "1" + }, + } +} + +export default { + id: "example-extension-host", + server: ExamplePlugin, +} satisfies PluginModule diff --git a/src/apps/extension-host/package.json b/src/apps/extension-host/package.json new file mode 100644 index 0000000000..5bf85af17c --- /dev/null +++ b/src/apps/extension-host/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/extension-host", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "bun@1.3.14", + "scripts": { + "build": "bun build ./src/main.ts --target=bun --outfile=dist/extension-host.js", + "generate": "bun run ./script/generate-protocol.ts", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@opencode-ai/plugin": "1.17.18", + "@opencode-ai/sdk": "1.17.18", + "npm-package-arg": "13.0.2", + "semver": "7.7.4", + "zod": "4.1.8" + }, + "devDependencies": { + "@tsconfig/bun": "1.0.9", + "@types/bun": "1.3.13", + "@types/npm-package-arg": "6.1.4", + "@types/node": "26.1.1", + "typescript": "5.8.2" + } +} diff --git a/src/apps/extension-host/protocol.schema.json b/src/apps/extension-host/protocol.schema.json new file mode 100644 index 0000000000..4244566483 --- /dev/null +++ b/src/apps/extension-host/protocol.schema.json @@ -0,0 +1,3354 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencode.ai/schemas/extension-host/protocol-v1.json", + "title": "OpenCode extension host protocol", + "description": "JSON-RPC 2.0 envelopes and method schemas for the standalone OpenCode 1.17.18 Bun extension host.", + "oneOf": [ + { + "$ref": "#/$defs/RpcRequest" + }, + { + "$ref": "#/$defs/RpcNotification" + }, + { + "$ref": "#/$defs/RpcSuccessResponse" + }, + { + "$ref": "#/$defs/RpcErrorResponse" + } + ], + "$defs": { + "RpcRequest": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string", + "minLength": 1 + }, + "params": { + "$ref": "#/$defs/RpcRequest/$defs/__schema0" + } + }, + "required": [ + "jsonrpc", + "id", + "method" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcRequest/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcRequest/$defs/__schema0" + } + } + ] + } + } + }, + "RpcNotification": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "method": { + "type": "string", + "minLength": 1 + }, + "params": { + "$ref": "#/$defs/RpcNotification/$defs/__schema0" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcNotification/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcNotification/$defs/__schema0" + } + } + ] + } + } + }, + "RpcSuccessResponse": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "result": { + "$ref": "#/$defs/RpcSuccessResponse/$defs/__schema0" + } + }, + "required": [ + "jsonrpc", + "id", + "result" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcSuccessResponse/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcSuccessResponse/$defs/__schema0" + } + } + ] + } + } + }, + "RpcErrorResponse": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/RpcErrorResponse/$defs/__schema0" + } + }, + "required": [ + "code", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "jsonrpc", + "id", + "error" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcErrorResponse/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcErrorResponse/$defs/__schema0" + } + } + ] + } + } + }, + "HostPluginsPrepareParams": { + "type": "object", + "properties": { + "plugins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string", + "minLength": 1 + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostPluginsPrepareParams/$defs/__schema0" + } + }, + "baseDirectory": { + "type": "string" + } + }, + "required": [ + "spec" + ], + "additionalProperties": false + } + }, + "configurationFingerprint": { + "type": "string", + "minLength": 1 + }, + "defaultBaseDirectory": { + "type": "string" + } + }, + "required": [ + "plugins" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostPluginsPrepareParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostPluginsPrepareParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostPluginsPrepareResult": { + "type": "object", + "properties": { + "configurationFingerprint": { + "type": "string", + "minLength": 1 + }, + "prepared": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "file", + "npm" + ] + }, + "target": { + "type": "string" + }, + "entry": { + "type": "string" + }, + "cache": { + "type": "string", + "enum": [ + "hit", + "installed", + "validated" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "spec", + "source", + "target", + "entry", + "cache" + ], + "additionalProperties": false + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string" + }, + "stage": { + "type": "string", + "enum": [ + "declaration", + "resolve", + "install", + "entry", + "compatibility", + "load", + "shape" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "spec", + "stage", + "message" + ], + "additionalProperties": false + } + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "method": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/HostPluginsPrepareResult/$defs/__schema0" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "additionalProperties": false + } + } + }, + "required": [ + "prepared", + "failed", + "diagnostics" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostPluginsPrepareResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostPluginsPrepareResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostInstanceOpenParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "project": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + }, + "directory": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "plugins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string", + "minLength": 1 + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + }, + "baseDirectory": { + "type": "string" + } + }, + "required": [ + "spec" + ], + "additionalProperties": false + } + }, + "configurationFingerprint": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID", + "project", + "config", + "directory", + "worktree", + "plugins" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostInstanceOpenResult": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "method": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "additionalProperties": false + } + }, + "hooks": { + "type": "array", + "items": { + "type": "string" + } + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "properties": { + "registrationID": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "description": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "required": [ + "registrationID", + "id", + "description", + "parameters" + ], + "additionalProperties": false + } + }, + "auth": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "hasLoader": { + "type": "boolean" + }, + "methods": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth", + "api" + ] + }, + "label": { + "type": "string" + }, + "methodIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "hasAuthorize": { + "type": "boolean" + }, + "prompts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "select" + ] + }, + "promptIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + } + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "hasValidate": { + "type": "boolean" + }, + "hasCondition": { + "type": "boolean" + } + }, + "required": [ + "type", + "promptIndex", + "key", + "message", + "hasValidate", + "hasCondition" + ], + "additionalProperties": false + } + } + }, + "required": [ + "type", + "label", + "methodIndex", + "hasAuthorize", + "prompts" + ], + "additionalProperties": false + } + } + }, + "required": [ + "provider", + "hasLoader", + "methods" + ], + "additionalProperties": false + } + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "hasModels": { + "type": "boolean" + } + }, + "required": [ + "provider", + "hasModels" + ], + "additionalProperties": false + } + }, + "workspaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "registrationID": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "registrationID", + "type", + "name", + "description" + ], + "additionalProperties": false + } + }, + "gatewayURL": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "instanceID", + "config", + "diagnostics", + "hooks", + "tools", + "auth", + "providers", + "workspaces", + "gatewayURL" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostInstanceCloseParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID" + ], + "additionalProperties": false + }, + "HostInstanceCloseResult": { + "type": "object", + "properties": { + "closed": { + "type": "boolean" + } + }, + "required": [ + "closed" + ], + "additionalProperties": false + }, + "HostLogSetLevelParams": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error", + "off" + ] + } + }, + "required": [ + "level" + ], + "additionalProperties": false + }, + "HostLogSetLevelResult": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error", + "off" + ] + } + }, + "required": [ + "level" + ], + "additionalProperties": false + }, + "HostShutdownParams": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "HostShutdownResult": { + "type": "object", + "properties": { + "closed": { + "type": "boolean" + } + }, + "required": [ + "closed" + ], + "additionalProperties": false + }, + "HostHookCallParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "hook": { + "type": "string", + "minLength": 1 + }, + "input": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + }, + "output": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "hook", + "input", + "output" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostHookCallResult": { + "type": "object", + "properties": { + "input": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + }, + "output": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + } + }, + "required": [ + "input", + "output" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostEventEmitParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "event": { + "$ref": "#/$defs/HostEventEmitParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "event" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostEventEmitParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostEventEmitParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostEventEmitResult": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean", + "const": true + } + }, + "required": [ + "accepted" + ], + "additionalProperties": false + }, + "HostToolExecuteParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "args": { + "$ref": "#/$defs/HostToolExecuteParams/$defs/__schema0" + }, + "context": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "sessionID", + "messageID", + "agent" + ], + "additionalProperties": false + } + }, + "required": [ + "instanceID", + "executionID", + "registrationID", + "args", + "context" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostToolExecuteParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostToolExecuteResult": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "output": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "type", + "mime", + "url" + ], + "additionalProperties": false + } + } + }, + "required": [ + "output" + ], + "additionalProperties": false + } + ], + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostToolCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "executionID" + ], + "additionalProperties": false + }, + "HostToolCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "HostAuthPromptEvaluateParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "methodIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "promptIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "operation": { + "type": "string", + "enum": [ + "validate", + "condition" + ] + }, + "value": { + "type": "string" + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "instanceID", + "provider", + "methodIndex", + "promptIndex", + "operation", + "inputs" + ], + "additionalProperties": false + }, + "HostAuthPromptEvaluateResult": { + "anyOf": [ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "validate" + }, + "error": { + "type": "string" + } + }, + "required": [ + "operation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "condition" + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "operation", + "active" + ], + "additionalProperties": false + } + ] + }, + "HostAuthAuthorizeParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "methodIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "instanceID", + "provider", + "methodIndex" + ], + "additionalProperties": false + }, + "HostAuthAuthorizeResult": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth" + }, + "flowID": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "format": "uri" + }, + "instructions": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "auto", + "code" + ] + } + }, + "required": [ + "type", + "flowID", + "url", + "instructions", + "method" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "api" + }, + "result": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" + } + }, + "required": [ + "type", + "refresh", + "access", + "expires" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "key" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "failed" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "HostAuthCallbackParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "flowID": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string" + } + }, + "required": [ + "instanceID", + "flowID" + ], + "additionalProperties": false + }, + "HostAuthCallbackResult": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" + } + }, + "required": [ + "type", + "refresh", + "access", + "expires" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "key" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "failed" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "HostAuthFlowCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "flowID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "flowID" + ], + "additionalProperties": false + }, + "HostAuthFlowCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "HostAuthLoaderParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "providerInfo": { + "$ref": "#/$defs/HostAuthLoaderParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "provider", + "providerInfo" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostAuthLoaderParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostAuthLoaderParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostAuthLoaderResult": { + "type": "object", + "properties": { + "value": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostAuthLoaderResult/$defs/__schema0" + } + }, + "fetchID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "value" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostAuthLoaderResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostAuthLoaderResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostAuthFetchParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "fetchID": { + "type": "string", + "minLength": 1 + }, + "requestID": { + "type": "string", + "minLength": 1 + }, + "request": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "method": { + "type": "string", + "minLength": 1 + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "instanceID", + "fetchID", + "requestID", + "request" + ], + "additionalProperties": false + }, + "HostAuthFetchResult": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "statusText": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "headers" + ], + "additionalProperties": false + }, + "HostAuthFetchCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "requestID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "requestID" + ], + "additionalProperties": false + }, + "HostAuthFetchCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "HostAuthFetchReleaseParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "fetchID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID", + "fetchID" + ], + "additionalProperties": false + }, + "HostAuthFetchReleaseResult": { + "type": "object", + "properties": { + "released": { + "type": "boolean" + } + }, + "required": [ + "released" + ], + "additionalProperties": false + }, + "HostProviderModelsParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "providerID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + }, + "auth": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "providerID", + "provider" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostProviderModelsResult": { + "type": "object", + "properties": { + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostProviderModelsResult/$defs/__schema0" + } + } + }, + "required": [ + "models" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostProviderModelsResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostProviderModelsResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceConfigureParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceConfigureParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceConfigureParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceConfigureParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceConfigureResult": { + "type": "object", + "properties": { + "config": { + "$ref": "#/$defs/HostWorkspaceConfigureResult/$defs/__schema0" + } + }, + "required": [ + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceConfigureResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceConfigureResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceCreateParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "from": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config", + "env" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceCreateResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "HostWorkspaceRemoveParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceRemoveParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceRemoveParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceRemoveParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceRemoveResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "HostWorkspaceTargetParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceTargetParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceTargetParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceTargetParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceTargetResult": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "local" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "type", + "directory" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "remote" + }, + "url": { + "type": "string", + "format": "uri" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "HostStreamReadParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "maxBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 65536 + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "HostStreamReadResult": { + "type": "object", + "properties": { + "data": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + }, + "eof": { + "type": "boolean" + } + }, + "required": [ + "data", + "eof" + ], + "additionalProperties": false + }, + "HostStreamCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "HostStreamCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "BackendHandshakeParams": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "number", + "const": 1 + }, + "opencodeVersion": { + "type": "string", + "const": "1.17.18" + }, + "maxFrameBytes": { + "type": "integer", + "minimum": 65536, + "maximum": 67108864 + } + }, + "required": [ + "token", + "protocolVersion", + "opencodeVersion", + "maxFrameBytes" + ], + "additionalProperties": false + }, + "BackendHandshakeResult": { + "type": "object", + "properties": { + "protocolVersion": { + "type": "number", + "const": 1 + }, + "maxFrameBytes": { + "type": "integer", + "minimum": 65536, + "maximum": 67108864 + }, + "cacheDirectory": { + "type": "string" + } + }, + "required": [ + "protocolVersion", + "maxFrameBytes", + "cacheDirectory" + ], + "additionalProperties": false + }, + "BackendHttpRequestParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "requestID": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "instanceID", + "requestID", + "method", + "path", + "headers" + ], + "additionalProperties": false + }, + "BackendHttpRequestResult": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "statusText": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "headers" + ], + "additionalProperties": false + }, + "BackendAuthGetParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "providerID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID", + "providerID" + ], + "additionalProperties": false + }, + "BackendAuthGetResult": { + "type": "object", + "properties": { + "auth": { + "anyOf": [ + { + "$ref": "#/$defs/BackendAuthGetResult/$defs/__schema0" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "auth" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendAuthGetResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendAuthGetResult/$defs/__schema0" + } + } + ] + } + } + }, + "BackendToolAskParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolAskParams/$defs/__schema0" + } + } + }, + "required": [ + "instanceID", + "executionID", + "permission", + "patterns", + "always", + "metadata" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendToolAskParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolAskParams/$defs/__schema0" + } + } + ] + } + } + }, + "BackendToolAskResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "BackendToolMetadataParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolMetadataParams/$defs/__schema0" + } + } + }, + "required": [ + "instanceID", + "executionID" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendToolMetadataParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolMetadataParams/$defs/__schema0" + } + } + ] + } + } + }, + "BackendToolMetadataResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "BackendDiagnosticPublishParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "diagnostic": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "method": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/BackendDiagnosticPublishParams/$defs/__schema0" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "diagnostic" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendDiagnosticPublishParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendDiagnosticPublishParams/$defs/__schema0" + } + } + ] + } + } + }, + "BackendDiagnosticPublishResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "BackendStreamReadParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "maxBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 65536 + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "BackendStreamReadResult": { + "type": "object", + "properties": { + "data": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + }, + "eof": { + "type": "boolean" + } + }, + "required": [ + "data", + "eof" + ], + "additionalProperties": false + }, + "BackendStreamCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "BackendStreamCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + } + }, + "x-protocol-version": 1, + "x-methods": { + "host.plugins.prepare": { + "direction": "rust-to-host", + "params": "#/$defs/HostPluginsPrepareParams", + "result": "#/$defs/HostPluginsPrepareResult" + }, + "host.instance.open": { + "direction": "rust-to-host", + "params": "#/$defs/HostInstanceOpenParams", + "result": "#/$defs/HostInstanceOpenResult" + }, + "host.instance.close": { + "direction": "rust-to-host", + "params": "#/$defs/HostInstanceCloseParams", + "result": "#/$defs/HostInstanceCloseResult" + }, + "host.log.setLevel": { + "direction": "rust-to-host", + "params": "#/$defs/HostLogSetLevelParams", + "result": "#/$defs/HostLogSetLevelResult" + }, + "host.shutdown": { + "direction": "rust-to-host", + "params": "#/$defs/HostShutdownParams", + "result": "#/$defs/HostShutdownResult" + }, + "host.hook.call": { + "direction": "rust-to-host", + "params": "#/$defs/HostHookCallParams", + "result": "#/$defs/HostHookCallResult" + }, + "host.event.emit": { + "direction": "rust-to-host", + "params": "#/$defs/HostEventEmitParams", + "result": "#/$defs/HostEventEmitResult" + }, + "host.tool.execute": { + "direction": "rust-to-host", + "params": "#/$defs/HostToolExecuteParams", + "result": "#/$defs/HostToolExecuteResult" + }, + "host.tool.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostToolCancelParams", + "result": "#/$defs/HostToolCancelResult" + }, + "host.auth.prompt.evaluate": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthPromptEvaluateParams", + "result": "#/$defs/HostAuthPromptEvaluateResult" + }, + "host.auth.authorize": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthAuthorizeParams", + "result": "#/$defs/HostAuthAuthorizeResult" + }, + "host.auth.callback": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthCallbackParams", + "result": "#/$defs/HostAuthCallbackResult" + }, + "host.auth.flow.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFlowCancelParams", + "result": "#/$defs/HostAuthFlowCancelResult" + }, + "host.auth.loader": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthLoaderParams", + "result": "#/$defs/HostAuthLoaderResult" + }, + "host.auth.fetch": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFetchParams", + "result": "#/$defs/HostAuthFetchResult" + }, + "host.auth.fetch.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFetchCancelParams", + "result": "#/$defs/HostAuthFetchCancelResult" + }, + "host.auth.fetch.release": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFetchReleaseParams", + "result": "#/$defs/HostAuthFetchReleaseResult" + }, + "host.provider.models": { + "direction": "rust-to-host", + "params": "#/$defs/HostProviderModelsParams", + "result": "#/$defs/HostProviderModelsResult" + }, + "host.workspace.configure": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceConfigureParams", + "result": "#/$defs/HostWorkspaceConfigureResult" + }, + "host.workspace.create": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceCreateParams", + "result": "#/$defs/HostWorkspaceCreateResult" + }, + "host.workspace.remove": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceRemoveParams", + "result": "#/$defs/HostWorkspaceRemoveResult" + }, + "host.workspace.target": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceTargetParams", + "result": "#/$defs/HostWorkspaceTargetResult" + }, + "host.stream.read": { + "direction": "rust-to-host", + "params": "#/$defs/HostStreamReadParams", + "result": "#/$defs/HostStreamReadResult" + }, + "host.stream.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostStreamCancelParams", + "result": "#/$defs/HostStreamCancelResult" + }, + "backend.handshake": { + "direction": "host-to-rust", + "params": "#/$defs/BackendHandshakeParams", + "result": "#/$defs/BackendHandshakeResult" + }, + "backend.http.request": { + "direction": "host-to-rust", + "params": "#/$defs/BackendHttpRequestParams", + "result": "#/$defs/BackendHttpRequestResult" + }, + "backend.auth.get": { + "direction": "host-to-rust", + "params": "#/$defs/BackendAuthGetParams", + "result": "#/$defs/BackendAuthGetResult" + }, + "backend.tool.ask": { + "direction": "host-to-rust", + "params": "#/$defs/BackendToolAskParams", + "result": "#/$defs/BackendToolAskResult" + }, + "backend.tool.metadata": { + "direction": "host-to-rust", + "params": "#/$defs/BackendToolMetadataParams", + "result": "#/$defs/BackendToolMetadataResult" + }, + "backend.diagnostic.publish": { + "direction": "host-to-rust", + "params": "#/$defs/BackendDiagnosticPublishParams", + "result": "#/$defs/BackendDiagnosticPublishResult" + }, + "backend.stream.read": { + "direction": "host-to-rust", + "params": "#/$defs/BackendStreamReadParams", + "result": "#/$defs/BackendStreamReadResult" + }, + "backend.stream.cancel": { + "direction": "host-to-rust", + "params": "#/$defs/BackendStreamCancelParams", + "result": "#/$defs/BackendStreamCancelResult" + } + } +} diff --git a/src/apps/extension-host/script/generate-protocol.ts b/src/apps/extension-host/script/generate-protocol.ts new file mode 100644 index 0000000000..93c701ed8c --- /dev/null +++ b/src/apps/extension-host/script/generate-protocol.ts @@ -0,0 +1,90 @@ +import { z } from "zod" +import { + BackendMethodSchemas, + HostMethodSchemas, + PROTOCOL_VERSION, + RpcErrorResponseSchema, + RpcNotificationSchema, + RpcRequestSchema, + RpcSuccessResponseSchema, +} from "../src/protocol" + +const definitions: Record = {} +const methods: Record = {} + +addDefinition("RpcRequest", RpcRequestSchema) +addDefinition("RpcNotification", RpcNotificationSchema) +addDefinition("RpcSuccessResponse", RpcSuccessResponseSchema) +addDefinition("RpcErrorResponse", RpcErrorResponseSchema) + +for (const [method, definition] of Object.entries(HostMethodSchemas)) addMethod("rust-to-host", method, definition) +for (const [method, definition] of Object.entries(BackendMethodSchemas)) addMethod("host-to-rust", method, definition) + +const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://opencode.ai/schemas/extension-host/protocol-v1.json", + title: "OpenCode extension host protocol", + description: "JSON-RPC 2.0 envelopes and method schemas for the standalone OpenCode 1.17.18 Bun extension host.", + oneOf: [ + { $ref: "#/$defs/RpcRequest" }, + { $ref: "#/$defs/RpcNotification" }, + { $ref: "#/$defs/RpcSuccessResponse" }, + { $ref: "#/$defs/RpcErrorResponse" }, + ], + $defs: definitions, + "x-protocol-version": PROTOCOL_VERSION, + "x-methods": methods, +} + +const output = `${JSON.stringify(schema, null, 2)}\n` +const path = new URL("../protocol.schema.json", import.meta.url) +if (process.argv.includes("--check")) { + const current = await Bun.file(path) + .text() + .catch(() => "") + if (current !== output) { + console.error("protocol.schema.json is out of date; run bun run generate") + process.exit(1) + } + process.exit(0) +} +await Bun.write(path, output) + +function addMethod( + direction: "rust-to-host" | "host-to-rust", + method: string, + definition: { params: z.ZodType; result: z.ZodType }, +) { + const name = method + .split(".") + .map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`) + .join("") + const params = `${name}Params` + const result = `${name}Result` + addDefinition(params, definition.params) + addDefinition(result, definition.result) + methods[method] = { + direction, + params: `#/$defs/${params}`, + result: `#/$defs/${result}`, + } +} + +function addDefinition(name: string, value: z.ZodType) { + definitions[name] = scopeReferences(z.toJSONSchema(value, { target: "draft-2020-12" }), `#/$defs/${name}`) +} + +function scopeReferences(value: unknown, scope: string): unknown { + if (Array.isArray(value)) return value.map((item) => scopeReferences(item, scope)) + if (typeof value !== "object" || value === null) return value + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "$schema") + .map(([key, item]) => { + if (key !== "$ref" || typeof item !== "string" || !item.startsWith("#")) { + return [key, scopeReferences(item, scope)] + } + return [key, `${scope}${item.slice(1)}`] + }), + ) +} diff --git a/src/apps/extension-host/src/backend.ts b/src/apps/extension-host/src/backend.ts new file mode 100644 index 0000000000..e1558f10df --- /dev/null +++ b/src/apps/extension-host/src/backend.ts @@ -0,0 +1,56 @@ +export type RpcConnection = { + request(method: string, params: unknown, options?: { signal?: AbortSignal }): Promise + notify(method: string, params: unknown): Promise | void +} + +export type StreamDescriptor = { + streamID: string + length?: number +} + +export type StreamBridge = { + register(instanceID: string, stream: ReadableStream, length?: number): StreamDescriptor + remote(methodPrefix: "backend" | "host", instanceID: string, descriptor: StreamDescriptor): ReadableStream + cancel(instanceID: string, descriptor: StreamDescriptor): Promise + cancelAll(instanceID: string): Promise + cancelRemote?(instanceID: string, descriptor: StreamDescriptor, reason?: string): Promise +} + +export type Diagnostic = { + level: "debug" | "info" | "warn" | "error" + message: string + instanceID?: string + plugin?: { + id?: string + spec: string + } + operation?: string + error?: { + name?: string + message: string + stack?: string + cause?: unknown + } +} + +export async function publishDiagnostic(rpc: RpcConnection, diagnostic: Diagnostic) { + const { instanceID, ...value } = diagnostic + await rpc.notify("backend.diagnostic.publish", { + ...(instanceID ? { instanceID } : {}), + diagnostic: { + severity: value.level === "warn" ? "warning" : value.level, + code: value.operation ?? "extension_host", + message: value.message, + plugin: value.plugin?.id ?? value.plugin?.spec, + method: value.operation, + data: value.error + ? { + ...(value.error.name ? { name: value.error.name } : {}), + message: value.error.message, + ...(value.error.stack ? { stack: value.error.stack } : {}), + ...(value.error.cause === undefined ? {} : { cause: String(value.error.cause) }), + } + : undefined, + }, + }) +} diff --git a/src/apps/extension-host/src/bun-loader.ts b/src/apps/extension-host/src/bun-loader.ts new file mode 100644 index 0000000000..9a43ff9cd6 --- /dev/null +++ b/src/apps/extension-host/src/bun-loader.ts @@ -0,0 +1,95 @@ +import { mkdir, realpath, stat } from "node:fs/promises" +import path from "node:path" +import { + loadPlugins, + preparePlugins, + type LoadPluginsInput, + type NpmInstaller, + type PluginCacheStatus, +} from "./loader" +import { logEvent } from "./log" + +type InstallResult = { target: string; cache: Exclude } + +const installs = new Map>() + +export function loadBunPlugins(input: Omit) { + return loadPlugins({ ...input, install: installNpmPlugin }) +} + +export function prepareBunPlugins(input: Omit) { + return preparePlugins({ ...input, install: installNpmPlugin }) +} + +export function installNpmPlugin(input: Parameters[0]): Promise { + const directory = path.join( + path.resolve(input.cacheDirectory), + "plugins", + `${packageSlug(input.packageName ?? "plugin")}-${Bun.hash(input.spec).toString(16)}`, + ) + const pending = installs.get(directory) + if (pending) return pending + const operation = installNpmPluginAt(input, directory).finally(() => installs.delete(directory)) + installs.set(directory, operation) + return operation +} + +async function installNpmPluginAt(input: Parameters[0], directory: string) { + await mkdir(directory, { recursive: true }) + const manifestPath = path.join(directory, "package.json") + if (!(await Bun.file(manifestPath).exists())) { + await Bun.write(manifestPath, `${JSON.stringify({ private: true }, null, 2)}\n`) + } + const existing = await installedPackage(directory, input.packageName) + if (existing) { + logEvent("plugin.prepare.cache_hit", { plugin: input.spec, target: existing }, "debug") + return { target: existing, cache: "hit" as const } + } + const startedAt = performance.now() + logEvent("plugin.prepare.install.begin", { plugin: input.spec, cache_directory: directory }) + const child = Bun.spawn({ + cmd: [process.execPath, ...bunAddArguments(input.spec)], + cwd: directory, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) + if (code !== 0) throw new Error(`Failed to install plugin ${input.spec}: ${stderr.trim() || stdout.trim() || `bun add exited with status ${code}`}`) + const installed = await installedPackage(directory, input.packageName) + if (installed) { + logEvent("plugin.prepare.install.completed", { + plugin: input.spec, + target: installed, + cache_directory: directory, + duration_ms: Math.round(performance.now() - startedAt), + }) + return { target: installed, cache: "installed" as const } + } + throw new Error(`Plugin ${input.spec} was installed but its package directory could not be found`) +} + +export function bunAddArguments(spec: string) { + return ["add", "--ignore-scripts", "--exact", "--", spec] +} + +async function installedPackage(directory: string, preferred?: string) { + const manifestPath = path.join(directory, "package.json") + if (!(await Bun.file(manifestPath).exists())) return + const manifest = (await Bun.file(manifestPath).json()) as Record + const dependencies = isRecord(manifest.dependencies) ? Object.keys(manifest.dependencies) : [] + const name = preferred && dependencies.includes(preferred) ? preferred : dependencies.length === 1 ? dependencies[0] : undefined + if (!name) return + const target = path.join(directory, "node_modules", name) + const metadata = await stat(target).catch(() => undefined) + return metadata?.isDirectory() ? realpath(target) : undefined +} + +function packageSlug(name: string) { + const slug = name.replaceAll(/[^A-Za-z0-9._-]/g, "-").replaceAll(/^-+|-+$/g, "") + return slug || "plugin" +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/apps/extension-host/src/errors.ts b/src/apps/extension-host/src/errors.ts new file mode 100644 index 0000000000..3f43869103 --- /dev/null +++ b/src/apps/extension-host/src/errors.ts @@ -0,0 +1,30 @@ +export class ExtensionHostError extends Error { + readonly code: number + readonly data?: unknown + + constructor(code: number, message: string, data?: unknown) { + super(message) + this.name = "ExtensionHostError" + this.code = code + this.data = data + } +} + +export type SerializedError = { + name?: string + message: string + stack?: string + cause?: SerializedError | string +} + +export function errorData(error: unknown): SerializedError { + if (!(error instanceof Error)) return { message: String(error) } + return { + name: error.name, + message: error.message, + stack: error.stack, + ...(error.cause === undefined + ? {} + : { cause: error.cause instanceof Error ? errorData(error.cause) : String(error.cause) }), + } +} diff --git a/src/apps/extension-host/src/gateway.ts b/src/apps/extension-host/src/gateway.ts new file mode 100644 index 0000000000..59e1b14cce --- /dev/null +++ b/src/apps/extension-host/src/gateway.ts @@ -0,0 +1,113 @@ +import type { RpcConnection, StreamBridge, StreamDescriptor } from "./backend" +import { publishDiagnostic } from "./backend" + +type GatewayResponse = { + status: number + statusText?: string + headers?: Array<[string, string]> | Record + body?: StreamDescriptor +} + +export type Gateway = { + url: URL + close(): Promise +} + +export type GatewayFactory = ( + input: { instanceID: string; rpc: RpcConnection; streams: StreamBridge }, +) => Gateway | Promise + +export function createGateway(input: { instanceID: string; rpc: RpcConnection; streams: StreamBridge }): Gateway { + const active = new Set() + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (request.headers.get("upgrade")) { + return Response.json( + { error: "WebSocket forwarding is not supported by this extension host" }, + { status: 426, headers: { upgrade: "close" } }, + ) + } + + const length = request.headers.get("content-length") + const body = request.body + ? input.streams.register( + input.instanceID, + request.body, + length && Number.isSafeInteger(Number(length)) ? Number(length) : undefined, + ) + : undefined + if (body) active.add(body) + + try { + const url = new URL(request.url) + const result = await input.rpc.request( + "backend.http.request", + { + instanceID: input.instanceID, + requestID: crypto.randomUUID(), + method: request.method, + path: `${url.pathname}${url.search}`, + headers: Array.from(request.headers.entries()), + body, + }, + { signal: request.signal }, + ) + validateResponse(result) + + const headers = new Headers(result.headers) + if (!result.body) { + return new Response(null, { status: result.status, statusText: result.statusText, headers }) + } + + const stream = input.streams.remote("backend", input.instanceID, result.body) + return new Response(stream, { status: result.status, statusText: result.statusText, headers }) + } catch (error) { + if (body) await input.streams.cancel(input.instanceID, body).catch(() => {}) + await publishDiagnostic(input.rpc, { + level: "error", + message: "Failed to forward plugin HTTP request", + instanceID: input.instanceID, + operation: "backend.http.request", + error: errorInfo(error), + }).catch(() => {}) + return Response.json({ error: "Extension backend request failed" }, { status: 502 }) + } finally { + if (body) active.delete(body) + } + }, + }) + + return { + url: server.url, + async close() { + await Promise.all( + Array.from(active, (descriptor) => input.streams.cancel(input.instanceID, descriptor).catch(() => {})), + ) + active.clear() + await server.stop(true) + }, + } +} + +function validateResponse(value: GatewayResponse) { + if (!value || typeof value !== "object") throw new TypeError("backend.http.request returned a non-object") + if (!Number.isInteger(value.status) || value.status < 100 || value.status > 599) { + throw new TypeError("backend.http.request returned an invalid status") + } + if (!value.body) return + if (typeof value.body.streamID !== "string" || !value.body.streamID) { + throw new TypeError("backend.http.request returned an invalid body stream") + } +} + +function errorInfo(error: unknown) { + if (!(error instanceof Error)) return { message: String(error) } + return { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + } +} diff --git a/src/apps/extension-host/src/host.ts b/src/apps/extension-host/src/host.ts new file mode 100644 index 0000000000..7b064a5cf0 --- /dev/null +++ b/src/apps/extension-host/src/host.ts @@ -0,0 +1,1147 @@ +import path from "node:path" +import { realpath } from "node:fs/promises" +import type { + AuthHook, + AuthOAuthResult, + Hooks, + PluginInput, + PluginOptions, + ProviderHook, + ToolDefinition, + WorkspaceAdapter, + WorkspaceInfo, + WorkspaceTarget, +} from "@opencode-ai/plugin" +import { createOpencodeClient } from "@opencode-ai/sdk" +import type { Auth, Provider } from "@opencode-ai/sdk/v2" +import type { RpcConnection, StreamBridge, StreamDescriptor } from "./backend" +import { publishDiagnostic } from "./backend" +import { ExtensionHostError, errorData } from "./errors" +import type { Gateway, GatewayFactory } from "./gateway" +import { logError, logEvent } from "./log" +import { + type LoadPluginsInput, + type LoadedPlugin, + type LoaderDiagnostic, + type PluginDeclaration, + loadPreparedPlugins, + type PreparePluginsResult, +} from "./loader" +import { HostMethodSchemas } from "./protocol" +import { toolParametersToJsonSchema, validateToolArguments } from "./tool-schema" +import { cloneWireValue, type WireValue } from "./wire" + +const OPENCODE_VERSION = "1.17.18" +const GENERIC_HOOKS = [ + "chat.message", + "chat.params", + "chat.headers", + "permission.ask", + "command.execute.before", + "tool.execute.before", + "shell.env", + "tool.execute.after", + "experimental.chat.messages.transform", + "experimental.chat.system.transform", + "experimental.provider.small_model", + "experimental.session.compacting", + "experimental.compaction.autocontinue", + "experimental.text.complete", + "tool.definition", +] as const + +type PluginMeta = { + id?: string + spec: string + entry: string + index: number +} + +type RuntimeDiagnostic = { + level: "error" + stage: "runtime" + spec: string + pluginID?: string + message: string + error?: { + name?: string + message: string + stack?: string + cause?: unknown + } +} + +type HostDiagnostic = LoaderDiagnostic | RuntimeDiagnostic + +type RetainedHooks = { + plugin: PluginMeta + hooks: Hooks +} + +type ToolRegistration = { + registrationID: string + plugin: PluginMeta + id: string + definition: ToolDefinition + parameters: WireValue +} + +type AuthRegistration = { + plugin: PluginMeta + hook: AuthHook +} + +type ProviderRegistration = { + plugin: PluginMeta + hook: ProviderHook +} + +type WorkspaceRegistration = { + registrationID: string + plugin: PluginMeta + type: string + adapter: WorkspaceAdapter +} + +type OAuthFlow = { + plugin: PluginMeta + method: AuthOAuthResult["method"] + callback: AuthOAuthResult["callback"] +} + +type AuthFetch = { + plugin: PluginMeta + provider: string + fetch: typeof fetch +} + +type ActiveAuthFetch = { + controller: AbortController + body?: ReadableStream + descriptor?: StreamDescriptor +} + +type Instance = { + id: string + canonicalDirectory: string + directory: string + worktree: string + status: "opening" | "open" | "closing" + gateway: Gateway + hooks: RetainedHooks[] + tools: Map + auth: Map + providers: Map + workspaces: Map + flows: Map + fetches: Map + activeTools: Map + activeFetches: Map + openDone: Promise + finishOpen(): void + closePromise?: Promise + disposed: Set + counter: number +} + +export type InstanceOpenInput = { + instanceID: string + project: WireValue + directory: string + worktree: string + config: WireValue + plugins: PluginDeclaration[] + configurationFingerprint?: string +} + +export type PluginsPrepareInput = { + plugins: PluginDeclaration[] + configurationFingerprint?: string + defaultBaseDirectory?: string +} + +export class ExtensionHost { + readonly #rpc: RpcConnection + readonly #streams: StreamBridge + readonly #cacheDirectory: string + readonly #gatewayFactory: GatewayFactory + readonly #preparePlugins: (input: LoadPluginsInput) => Promise + readonly #shell: PluginInput["$"] + readonly #instances = new Map() + readonly #directories = new Map() + readonly #opening = new Map>() + readonly #preparations = new Map>() + readonly #cancelledOpenings = new Set() + #status: "running" | "closing" | "closed" = "running" + #shutdownPromise?: Promise + + constructor(input: { + rpc: RpcConnection + streams: StreamBridge + cacheDirectory: string + gatewayFactory: GatewayFactory + preparePlugins: (input: LoadPluginsInput) => Promise + shell: PluginInput["$"] + }) { + this.#rpc = input.rpc + this.#streams = input.streams + this.#cacheDirectory = input.cacheDirectory + this.#gatewayFactory = input.gatewayFactory + this.#preparePlugins = input.preparePlugins + this.#shell = input.shell + } + + async prepare(input: PluginsPrepareInput) { + this.#assertAccepting() + const prepared = await this.#prepare({ + declarations: input.plugins, + defaultBaseDirectory: input.defaultBaseDirectory, + configurationFingerprint: input.configurationFingerprint, + }) + return HostMethodSchemas["host.plugins.prepare"].result.parse({ + ...(input.configurationFingerprint + ? { configurationFingerprint: input.configurationFingerprint } + : {}), + prepared: prepared.prepared.map((plugin) => ({ + spec: plugin.spec, + source: plugin.source, + target: plugin.target, + entry: plugin.entry, + cache: plugin.cache, + ...(typeof plugin.package?.manifest.version === "string" + ? { version: plugin.package.manifest.version } + : {}), + })), + failed: prepared.diagnostics.map((diagnostic) => ({ + spec: diagnostic.spec, + stage: diagnostic.stage, + message: diagnostic.message, + })), + diagnostics: prepared.diagnostics.map(protocolDiagnostic), + }) + } + + async open(input: InstanceOpenInput) { + this.#assertAccepting() + if (this.#instances.has(input.instanceID) || this.#opening.has(input.instanceID)) { + throw new ExtensionHostError(-32002, `Instance ${input.instanceID} already exists`, { + kind: "instance_exists", + instanceID: input.instanceID, + }) + } + const operation = Promise.withResolvers() + this.#opening.set(input.instanceID, operation.promise) + try { + const canonicalDirectory = await realpath(path.resolve(input.directory)).catch(() => + path.resolve(input.directory), + ) + this.#assertAccepting() + if (this.#cancelledOpenings.has(input.instanceID)) { + throw new ExtensionHostError(-32004, `Instance ${input.instanceID} was closed while opening`, { + kind: "instance_closing", + instanceID: input.instanceID, + }) + } + const owner = this.#directories.get(canonicalDirectory) + if (owner) { + throw new ExtensionHostError(-32002, `Directory ${canonicalDirectory} is already owned by ${owner}`, { + kind: "directory_exists", + instanceID: owner, + directory: canonicalDirectory, + }) + } + + const config = cloneWireValue(input.config, "config") + const gateway = await this.#gatewayFactory({ + instanceID: input.instanceID, + rpc: this.#rpc, + streams: this.#streams, + }) + const opened = Promise.withResolvers() + const instance: Instance = { + id: input.instanceID, + canonicalDirectory, + directory: input.directory, + worktree: input.worktree, + status: "opening", + gateway, + hooks: [], + tools: new Map(), + auth: new Map(), + providers: new Map(), + workspaces: new Map(), + flows: new Map(), + fetches: new Map(), + activeTools: new Map(), + activeFetches: new Map(), + openDone: opened.promise, + finishOpen: opened.resolve, + disposed: new Set(), + counter: 0, + } + this.#instances.set(instance.id, instance) + this.#directories.set(canonicalDirectory, instance.id) + + let failure: unknown + const diagnostics: HostDiagnostic[] = [] + try { + logEvent("plugin.activation.begin", { + instance_id: instance.id, + plugin_count: input.plugins.length, + plugins: input.plugins.map(pluginDeclarationSpec), + }) + const prepared = await this.#prepare({ + declarations: input.plugins, + defaultBaseDirectory: input.directory, + configurationFingerprint: input.configurationFingerprint, + }) + const loaded = await loadPreparedPlugins(prepared) + this.#assertOpening(instance) + diagnostics.push(...loaded.diagnostics) + + const client = createOpencodeClient({ baseUrl: gateway.url.toString(), directory: input.directory }) + for (const plugin of loaded.loaded) { + this.#assertOpening(instance) + await this.#startPlugin(instance, plugin, input.project, client, diagnostics) + } + + const activatedPlugins = instance.hooks.map(({ plugin }) => plugin.spec) + logEvent("plugin.activation.complete", { + instance_id: instance.id, + configured_plugin_count: input.plugins.length, + loaded_plugin_count: loaded.loaded.length, + activated_plugin_count: activatedPlugins.length, + plugins: activatedPlugins, + diagnostic_count: diagnostics.length, + }) + + for (const retained of instance.hooks) { + this.#assertOpening(instance) + if (!retained.hooks.config) continue + try { + await Promise.resolve(retained.hooks.config(config as never)) + } catch (error) { + const diagnostic = runtimeDiagnostic(retained.plugin, "config", error) + diagnostics.push(diagnostic) + await publishDiagnostic(this.#rpc, toPublishedDiagnostic(instance.id, diagnostic)).catch(() => {}) + } + this.#assertOpening(instance) + } + + this.#assertOpening(instance) + this.#indexRegistrations(instance, diagnostics) + this.#assertOpening(instance) + const result = HostMethodSchemas["host.instance.open"].result.parse(openResult(instance, config, diagnostics)) + instance.status = "open" + return result + } catch (error) { + logError("plugin.activation.failed", error, { + instance_id: instance.id, + configured_plugin_count: input.plugins.length, + activated_plugin_count: instance.hooks.length, + plugins: instance.hooks.map(({ plugin }) => plugin.spec), + }) + failure = error + } finally { + instance.finishOpen() + } + + await this.#beginClose(instance) + throw failure + } finally { + if (this.#opening.get(input.instanceID) === operation.promise) this.#opening.delete(input.instanceID) + this.#cancelledOpenings.delete(input.instanceID) + operation.resolve() + } + } + + async close(input: { instanceID: string }): Promise<{ closed: boolean }> { + const pending = this.#opening.get(input.instanceID) + const instance = this.#instances.get(input.instanceID) + if (!instance && pending) { + this.#cancelledOpenings.add(input.instanceID) + await pending + return { closed: true } + } + if (!instance) return { closed: false } + const first = !instance.closePromise + await this.#beginClose(instance) + return { closed: first } + } + + async shutdown() { + if (!this.#shutdownPromise) { + this.#status = "closing" + this.#shutdownPromise = (async () => { + await Promise.all([ + ...Array.from(this.#instances.values(), (instance) => this.#beginClose(instance)), + ...this.#opening.values(), + ...this.#preparations.values(), + ]) + await Promise.all(Array.from(this.#instances.values(), (instance) => this.#beginClose(instance))) + this.#status = "closed" + })() + } + await this.#shutdownPromise + return { closed: true } + } + + #prepare(input: { + declarations: readonly PluginDeclaration[] + defaultBaseDirectory?: string + configurationFingerprint?: string + }) { + const key = preparationKey(input) + const pending = this.#preparations.get(key) + if (pending) { + logEvent("plugin.prepare.waiting_existing", { + configuration_fingerprint: input.configurationFingerprint, + plugin_count: input.declarations.length, + plugins: input.declarations.map(pluginDeclarationSpec), + }, "debug") + return pending + } + + const startedAt = performance.now() + logEvent("plugin.prepare.begin", { + configuration_fingerprint: input.configurationFingerprint, + plugin_count: input.declarations.length, + plugins: input.declarations.map(pluginDeclarationSpec), + }) + const operation = this.#preparePlugins({ + declarations: input.declarations, + cacheDirectory: this.#cacheDirectory, + defaultBaseDirectory: input.defaultBaseDirectory, + compatibilityVersion: OPENCODE_VERSION, + }) + .then((result) => { + for (const diagnostic of result.diagnostics) { + logEvent("plugin.prepare.failed", { + configuration_fingerprint: input.configurationFingerprint, + plugin: diagnostic.spec, + stage: diagnostic.stage, + error_message: diagnostic.message, + }, "error") + } + logEvent("plugin.prepare.completed", { + configuration_fingerprint: input.configurationFingerprint, + configured_plugin_count: input.declarations.length, + prepared_plugin_count: result.prepared.length, + failed_plugin_count: result.diagnostics.length, + plugins: result.prepared.map((plugin) => plugin.spec), + duration_ms: Math.round(performance.now() - startedAt), + }) + return result + }) + .catch((error) => { + logError("plugin.prepare.failed", error, { + configuration_fingerprint: input.configurationFingerprint, + plugin_count: input.declarations.length, + plugins: input.declarations.map(pluginDeclarationSpec), + duration_ms: Math.round(performance.now() - startedAt), + }) + throw error + }) + this.#preparations.set(key, operation) + return operation + } + + async callHook(input: { instanceID: string; name: string; input: WireValue; output: WireValue }) { + const instance = this.#instance(input.instanceID) + const hookInput = cloneWireValue(input.input, "input") + const hookOutput = cloneWireValue(input.output, "output") + + for (const retained of instance.hooks) { + const hook = Reflect.get(retained.hooks, input.name) + if (typeof hook !== "function") continue + try { + await Promise.resolve(hook(hookInput, hookOutput)) + } catch (error) { + throw pluginError(retained.plugin, input.name, error) + } + } + + return { + input: cloneWireValue(hookInput, "input"), + output: cloneWireValue(hookOutput, "output"), + } + } + + emitEvent(input: { instanceID: string; event: WireValue }) { + const instance = this.#instance(input.instanceID) + const event = cloneWireValue(input.event, "event") + void (async () => { + for (const retained of instance.hooks) { + if (!retained.hooks.event) continue + try { + await retained.hooks.event({ event } as never) + } catch (error) { + await publishDiagnostic(this.#rpc, { + level: "error", + message: `Plugin ${retained.plugin.spec} event hook failed`, + instanceID: instance.id, + plugin: retained.plugin, + operation: "event", + error: errorData(error), + }).catch(() => {}) + } + } + })() + return { accepted: true } + } + + async executeTool(input: { + instanceID: string + registrationID: string + executionID: string + args: WireValue + context: { + sessionID: string + messageID: string + agent: string + callID?: string + } + }) { + const instance = this.#instance(input.instanceID) + const registration = findRegistration(instance.tools, input.registrationID) + if (!registration) throw missingHandle("tool", input.registrationID) + if (instance.activeTools.has(input.executionID)) { + throw new ExtensionHostError(-32002, `Tool execution ${input.executionID} already exists`) + } + + const controller = new AbortController() + instance.activeTools.set(input.executionID, controller) + try { + const args = validateToolArguments(registration.definition.args, cloneWireValue(input.args, "args")) + const result = await registration.definition.execute(args as never, { + ...input.context, + directory: instance.directory, + worktree: instance.worktree, + abort: controller.signal, + metadata: (metadata) => { + const pending = this.#rpc.notify("backend.tool.metadata", { + instanceID: instance.id, + executionID: input.executionID, + ...(cloneWireValue(metadata, "metadata") as Record), + }) + if (pending) void pending.catch(() => {}) + }, + ask: async (request) => { + await this.#rpc.request( + "backend.tool.ask", + { + instanceID: instance.id, + executionID: input.executionID, + ...(cloneWireValue(request, "request") as Record), + }, + { signal: controller.signal }, + ) + }, + }) + return cloneWireValue(result, "result") + } catch (error) { + throw pluginError(registration.plugin, `tool:${registration.id}`, error) + } finally { + instance.activeTools.delete(input.executionID) + } + } + + cancelTool(input: { instanceID: string; executionID: string; reason?: string }) { + const controller = this.#instance(input.instanceID).activeTools.get(input.executionID) + if (!controller) return { cancelled: false } + controller.abort(input.reason) + return { cancelled: true } + } + + evaluateAuthPrompt(input: { + instanceID: string + provider: string + methodIndex: number + promptIndex: number + operation: "validate" | "condition" + value?: string + inputs: Record + }) { + const registration = this.#auth(input.instanceID, input.provider) + const method = registration.hook.methods[input.methodIndex] + const prompt = method?.prompts?.[input.promptIndex] + if (!method || !prompt) { + throw missingHandle("auth prompt", `${input.provider}:${input.methodIndex}:${input.promptIndex}`) + } + + if (input.operation === "validate") { + const error = + prompt.type === "text" && prompt.validate + ? prompt.validate(input.value ?? input.inputs[prompt.key] ?? "") + : undefined + return { operation: "validate" as const, ...(error ? { error } : {}) } + } + + const active = prompt.when + ? prompt.when.op === "eq" + ? input.inputs[prompt.when.key] === prompt.when.value + : input.inputs[prompt.when.key] !== prompt.when.value + : prompt.condition + ? prompt.condition(input.inputs) + : true + return { operation: "condition" as const, active } + } + + async authorize(input: { + instanceID: string + provider: string + methodIndex: number + inputs?: Record + }) { + const instance = this.#instance(input.instanceID) + const registration = this.#auth(input.instanceID, input.provider) + const method = registration.hook.methods[input.methodIndex] + if (!method) throw missingHandle("auth method", `${input.provider}:${input.methodIndex}`) + if (method.type === "api") { + const result = method.authorize ? await method.authorize(input.inputs) : undefined + return { type: "api", ...(result === undefined ? {} : { result: cloneWireValue(result, "result") }) } + } + + const result = await method.authorize(input.inputs) + const flowID = handleID(instance, "flow") + instance.flows.set(flowID, { + plugin: registration.plugin, + method: result.method, + callback: result.callback, + } as OAuthFlow) + return { + type: "oauth", + flowID, + url: result.url, + instructions: result.instructions, + method: result.method, + } + } + + async authCallback(input: { instanceID: string; flowID: string; code?: string }) { + const instance = this.#instance(input.instanceID) + const flow = instance.flows.get(input.flowID) + if (!flow) throw missingHandle("auth flow", input.flowID) + if (flow.method === "code" && input.code === undefined) { + throw new ExtensionHostError(-32602, `Auth flow ${input.flowID} requires a code`) + } + + try { + const result = await (flow.method === "code" + ? (flow.callback as (code: string) => Promise)(input.code!) + : (flow.callback as () => Promise)()) + if (result && typeof result === "object" && Reflect.get(result, "type") === "success") { + instance.flows.delete(input.flowID) + } + return cloneWireValue(result, "result") + } catch (error) { + throw pluginError(flow.plugin, "auth.callback", error) + } + } + + cancelAuthFlow(input: { instanceID: string; flowID: string }) { + return { cancelled: this.#instance(input.instanceID).flows.delete(input.flowID) } + } + + async loadAuth(input: { instanceID: string; provider: string; providerInfo: WireValue }) { + const instance = this.#instance(input.instanceID) + const registration = this.#auth(input.instanceID, input.provider) + if (!registration.hook.loader) return { value: {} } + + try { + const result = await registration.hook.loader( + async () => { + const result = await this.#rpc.request<{ auth: Auth | null }>("backend.auth.get", { + instanceID: instance.id, + providerID: input.provider, + }) + if (!result.auth) throw new Error(`No auth is available for ${input.provider}`) + return result.auth + }, + cloneWireValue(input.providerInfo, "providerInfo") as never, + ) + if (!result || typeof result !== "object" || Array.isArray(result)) { + throw new TypeError("auth.loader must return an object") + } + + const options = { ...result } + const candidate = Reflect.get(options, "fetch") + if (candidate !== undefined && typeof candidate !== "function") { + throw new TypeError("auth.loader options.fetch must be a function") + } + Reflect.deleteProperty(options, "fetch") + const plain = cloneWireValue(options, "options") + if (!candidate) return { value: plain } + + const fetchID = handleID(instance, "fetch") + instance.fetches.set(fetchID, { + plugin: registration.plugin, + provider: input.provider, + fetch: candidate as typeof fetch, + }) + return { value: plain, fetchID } + } catch (error) { + throw pluginError(registration.plugin, "auth.loader", error) + } + } + + async authFetch(input: { + instanceID: string + fetchID: string + requestID: string + request: { + url: string + method?: string + headers?: Array<[string, string]> | Record + body?: StreamDescriptor + } + }) { + const instance = this.#instance(input.instanceID) + const registration = instance.fetches.get(input.fetchID) + if (!registration) throw missingHandle("auth fetch", input.fetchID) + if (instance.activeFetches.has(input.requestID)) { + throw new ExtensionHostError(-32002, `Auth fetch ${input.requestID} already exists`) + } + + const controller = new AbortController() + const body = input.request.body ? this.#streams.remote("backend", instance.id, input.request.body) : undefined + instance.activeFetches.set(input.requestID, { controller, body, descriptor: input.request.body }) + let returnedResponse = false + try { + const init = { + method: input.request.method, + headers: input.request.headers, + body, + signal: controller.signal, + ...(body ? { duplex: "half" as const } : {}), + } as RequestInit & { duplex?: "half" } + const response = await registration.fetch(input.request.url, init) + if (!(response instanceof Response)) throw new TypeError("auth loader fetch did not return a Response") + returnedResponse = true + return { + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers.entries()), + body: response.body + ? this.#streams.register(instance.id, response.body, contentLength(response.headers)) + : undefined, + } + } catch (error) { + throw pluginError(registration.plugin, "auth.fetch", error) + } finally { + instance.activeFetches.delete(input.requestID) + if (body && !body.locked) await body.cancel("Auth fetch completed").catch(() => {}) + if (input.request.body && !returnedResponse) { + await this.#streams.cancelRemote?.(instance.id, input.request.body, "Auth fetch failed").catch(() => {}) + } + } + } + + cancelAuthFetch(input: { instanceID: string; requestID: string; reason?: string }) { + const active = this.#instance(input.instanceID).activeFetches.get(input.requestID) + if (!active) return { cancelled: false } + active.controller.abort(input.reason) + void active.body?.cancel(input.reason).catch(() => {}) + if (active.descriptor) { + void this.#streams.cancelRemote?.(input.instanceID, active.descriptor, input.reason).catch(() => {}) + } + return { cancelled: true } + } + + releaseAuthFetch(input: { instanceID: string; fetchID: string }) { + return { released: this.#instance(input.instanceID).fetches.delete(input.fetchID) } + } + + async providerModels(input: { instanceID: string; providerID: string; provider: WireValue; auth?: WireValue }) { + const instance = this.#instance(input.instanceID) + const registration = instance.providers.get(input.providerID) + if (!registration?.hook.models) throw missingHandle("provider models", input.providerID) + try { + const result = await registration.hook.models(cloneWireValue(input.provider, "provider") as Provider, { + auth: input.auth === undefined ? undefined : (cloneWireValue(input.auth, "auth") as Auth), + }) + return { models: cloneWireValue(result, "models") } + } catch (error) { + throw pluginError(registration.plugin, "provider.models", error) + } + } + + async workspaceConfigure(input: { instanceID: string; registrationID: string; config: WireValue }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + return { + config: cloneWireValue( + await registration.adapter.configure(cloneWireValue(input.config, "config") as WorkspaceInfo), + "config", + ), + } + } + + async workspaceCreate(input: { + instanceID: string + registrationID: string + config: WireValue + env: Record + from?: WireValue + }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + await registration.adapter.create( + cloneWireValue(input.config, "config") as WorkspaceInfo, + Object.fromEntries(Object.entries(input.env).map(([key, value]) => [key, value ?? undefined])), + input.from === undefined ? undefined : (cloneWireValue(input.from, "from") as WorkspaceInfo), + ) + return {} + } + + async workspaceRemove(input: { instanceID: string; registrationID: string; config: WireValue }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + await registration.adapter.remove(cloneWireValue(input.config, "config") as WorkspaceInfo) + return {} + } + + async workspaceTarget(input: { instanceID: string; registrationID: string; config: WireValue }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + return { + target: normalizeWorkspaceTarget( + await registration.adapter.target(cloneWireValue(input.config, "config") as WorkspaceInfo), + ), + } + } + + #assertAccepting() { + if (this.#status === "running") return + throw new ExtensionHostError(-32004, "Extension host is shutting down", { kind: "host_shutting_down" }) + } + + #assertOpening(instance: Instance) { + if (this.#status === "running" && instance.status === "opening") return + throw new ExtensionHostError(-32004, `Instance ${instance.id} is closing`, { + kind: "instance_closing", + instanceID: instance.id, + }) + } + + #beginClose(instance: Instance) { + if (instance.closePromise) return instance.closePromise + instance.status = "closing" + instance.closePromise = this.#disposeInstance(instance) + return instance.closePromise + } + + async #disposeInstance(instance: Instance) { + for (const controller of instance.activeTools.values()) controller.abort("Instance closed") + for (const active of instance.activeFetches.values()) { + active.controller.abort("Instance closed") + void active.body?.cancel("Instance closed").catch(() => {}) + if (active.descriptor) { + void this.#streams.cancelRemote?.(instance.id, active.descriptor, "Instance closed").catch(() => {}) + } + } + instance.activeTools.clear() + instance.activeFetches.clear() + try { + await this.#streams.cancelAll(instance.id) + } catch (error) { + await publishDiagnostic(this.#rpc, { + level: "error", + message: `Instance ${instance.id} stream cleanup failed`, + instanceID: instance.id, + operation: "dispose", + error: errorData(error), + }).catch(() => {}) + } finally { + await instance.gateway.close().catch(() => {}) + await instance.openDone + instance.flows.clear() + instance.fetches.clear() + + for (const retained of instance.hooks) { + if (instance.disposed.has(retained)) continue + instance.disposed.add(retained) + if (!retained.hooks.dispose) continue + try { + await Promise.resolve(retained.hooks.dispose()) + } catch (error) { + await publishDiagnostic(this.#rpc, { + level: "error", + message: `Plugin ${retained.plugin.spec} dispose hook failed`, + instanceID: instance.id, + plugin: retained.plugin, + operation: "dispose", + error: errorData(error), + }).catch(() => {}) + } + } + + if (this.#instances.get(instance.id) === instance) this.#instances.delete(instance.id) + if (this.#directories.get(instance.canonicalDirectory) === instance.id) { + this.#directories.delete(instance.canonicalDirectory) + } + } + } + + #instance(instanceID: string) { + const instance = this.#instances.get(instanceID) + if (!instance || instance.status !== "open") throw missingHandle("instance", instanceID) + return instance + } + + #auth(instanceID: string, provider: string) { + const registration = this.#instance(instanceID).auth.get(provider) + if (!registration) throw missingHandle("auth provider", provider) + return registration + } + + #workspace(instanceID: string, registrationID: string) { + const registration = findRegistration(this.#instance(instanceID).workspaces, registrationID) + if (!registration) throw missingHandle("workspace", registrationID) + return registration + } + + async #startPlugin( + instance: Instance, + loaded: LoadedPlugin, + project: WireValue, + client: ReturnType, + diagnostics: HostDiagnostic[], + ) { + for (const entrypoint of loaded.entrypoints) { + this.#assertOpening(instance) + const plugin: PluginMeta = { + ...(entrypoint.id ? { id: entrypoint.id } : {}), + spec: loaded.spec, + entry: loaded.entry, + index: entrypoint.index, + } + const workspaces = new Map() + const pluginInput: PluginInput = { + client, + project: cloneWireValue(project, "project") as never, + directory: instance.directory, + worktree: instance.worktree, + serverUrl: instance.gateway.url, + $: this.#shell, + experimental_workspace: { + register: (type, adapter) => { + workspaces.set(type, { + registrationID: handleID(instance, "workspace"), + plugin, + type, + adapter, + }) + }, + }, + } + + try { + const hooks = await entrypoint.server(pluginInput, loaded.options as PluginOptions | undefined) + if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) { + throw new TypeError("Plugin entrypoint did not return a Hooks object") + } + instance.hooks.push({ plugin, hooks }) + logEvent("plugin.activation.completed", { + instance_id: instance.id, + plugin: plugin.spec, + plugin_id: plugin.id, + entrypoint_index: plugin.index, + }) + this.#assertOpening(instance) + for (const [type, registration] of workspaces) instance.workspaces.set(type, registration) + } catch (error) { + if (instance.status === "closing" || this.#status !== "running") throw error + const diagnostic = runtimeDiagnostic(plugin, "entrypoint", error) + diagnostics.push(diagnostic) + await publishDiagnostic(this.#rpc, toPublishedDiagnostic(instance.id, diagnostic)).catch(() => {}) + } + } + } + + #indexRegistrations(instance: Instance, diagnostics: HostDiagnostic[]) { + for (const retained of instance.hooks) { + for (const [id, definition] of Object.entries(retained.hooks.tool ?? {})) { + try { + instance.tools.set(id, { + registrationID: handleID(instance, "tool"), + plugin: retained.plugin, + id, + definition, + parameters: cloneWireValue(toolParametersToJsonSchema(definition.args), `tool.${id}.parameters`), + }) + } catch (error) { + diagnostics.push(runtimeDiagnostic(retained.plugin, `tool:${id}`, error)) + } + } + if (retained.hooks.auth) { + instance.auth.set(retained.hooks.auth.provider, { plugin: retained.plugin, hook: retained.hooks.auth }) + } + if (retained.hooks.provider) { + instance.providers.set(retained.hooks.provider.id, { plugin: retained.plugin, hook: retained.hooks.provider }) + } + } + } +} + +function openResult(instance: Instance, config: WireValue, diagnostics: HostDiagnostic[]) { + return { + instanceID: instance.id, + config: cloneWireValue(config, "config"), + diagnostics: diagnostics.map(protocolDiagnostic), + gatewayURL: instance.gateway.url.toString(), + hooks: GENERIC_HOOKS.filter((name) => + instance.hooks.some((retained) => typeof retained.hooks[name] === "function"), + ), + tools: Array.from(instance.tools.values(), ({ registrationID, id, plugin, definition, parameters }) => ({ + registrationID, + id, + plugin, + description: definition.description, + parameters, + })), + auth: Array.from(instance.auth.entries(), ([provider, registration]) => authDescriptor(provider, registration)), + providers: Array.from(instance.providers.entries(), ([provider, registration]) => ({ + provider, + plugin: registration.plugin, + hasModels: typeof registration.hook.models === "function", + })), + workspaces: Array.from(instance.workspaces.values(), ({ registrationID, type, plugin, adapter }) => ({ + registrationID, + type, + plugin, + name: adapter.name, + description: adapter.description, + })), + } +} + +function authDescriptor(provider: string, registration: AuthRegistration) { + return { + provider, + plugin: registration.plugin, + hasLoader: typeof registration.hook.loader === "function", + methods: registration.hook.methods.map((method, methodIndex) => ({ + type: method.type, + label: method.label, + methodIndex, + hasAuthorize: typeof method.authorize === "function", + prompts: + method.prompts?.map((prompt, promptIndex) => ({ + type: prompt.type, + key: prompt.key, + message: prompt.message, + promptIndex, + placeholder: prompt.type === "text" ? prompt.placeholder : undefined, + options: prompt.type === "select" ? prompt.options : undefined, + when: prompt.when, + hasValidate: prompt.type === "text" && typeof prompt.validate === "function", + hasCondition: typeof prompt.condition === "function", + })) ?? [], + })), + } +} + +function normalizeWorkspaceTarget(target: WorkspaceTarget) { + if (target.type === "local") return { type: "local", directory: target.directory } + return { + type: "remote", + url: target.url.toString(), + headers: target.headers ? Array.from(new Headers(target.headers).entries()) : undefined, + } +} + +function handleID(instance: Instance, type: string) { + instance.counter += 1 + return `${instance.id}:${type}:${instance.counter}` +} + +function pluginDeclarationSpec(declaration: PluginDeclaration) { + if (typeof declaration === "string") return declaration + if (Array.isArray(declaration)) return declaration[0] + return declaration.spec +} + +function preparationKey(input: { + declarations: readonly PluginDeclaration[] + defaultBaseDirectory?: string + configurationFingerprint?: string +}) { + const needsDefaultBaseDirectory = input.declarations.some( + ({ spec, baseDirectory }) => !baseDirectory && (spec.startsWith(".") || spec.startsWith("file:")), + ) + return JSON.stringify({ + configurationFingerprint: input.configurationFingerprint, + declarations: input.declarations, + ...(needsDefaultBaseDirectory ? { defaultBaseDirectory: input.defaultBaseDirectory } : {}), + }) +} + +function findRegistration(map: Map, registrationID: string) { + return Array.from(map.values()).find((registration) => registration.registrationID === registrationID) +} + +function missingHandle(type: string, id: string) { + return new ExtensionHostError(-32002, `Unknown ${type} ${id}`, { kind: "missing_handle", type, id }) +} + +function pluginError(plugin: PluginMeta, operation: string, error: unknown) { + return new ExtensionHostError(-32003, `Plugin ${plugin.spec} failed during ${operation}: ${errorMessage(error)}`, { + kind: "plugin_error", + plugin, + operation, + error: errorData(error), + }) +} + +function runtimeDiagnostic(plugin: PluginMeta, operation: string, error: unknown): RuntimeDiagnostic { + return { + level: "error", + stage: "runtime", + spec: plugin.spec, + pluginID: plugin.id, + message: `Plugin ${plugin.spec} failed during ${operation}: ${errorMessage(error)}`, + error: errorData(error), + } +} + +function toPublishedDiagnostic(instanceID: string, diagnostic: HostDiagnostic) { + return { + level: diagnostic.level, + message: diagnostic.message, + instanceID, + plugin: { id: "pluginID" in diagnostic ? diagnostic.pluginID : undefined, spec: diagnostic.spec }, + operation: diagnostic.stage, + error: diagnostic.error, + } +} + +function protocolDiagnostic(diagnostic: HostDiagnostic) { + const pluginID = "pluginID" in diagnostic ? diagnostic.pluginID : undefined + return { + severity: "error" as const, + code: diagnostic.stage, + message: diagnostic.message, + ...(pluginID ? { plugin: pluginID } : {}), + method: diagnostic.stage, + data: { + spec: diagnostic.spec, + ...(diagnostic.error ? { error: serializableDiagnosticError(diagnostic.error) } : {}), + ...(diagnostic.stage === "runtime" ? {} : { declarationIndex: diagnostic.declarationIndex }), + }, + } +} + +function serializableDiagnosticError(error: NonNullable) { + return { + ...(error.name ? { name: error.name } : {}), + message: error.message, + ...(error.stack ? { stack: error.stack } : {}), + ...(error.cause === undefined ? {} : { cause: String(error.cause) }), + } +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + +function contentLength(headers: Headers) { + const value = Number(headers.get("content-length")) + return Number.isSafeInteger(value) && value >= 0 ? value : undefined +} diff --git a/src/apps/extension-host/src/loader.ts b/src/apps/extension-host/src/loader.ts new file mode 100644 index 0000000000..0a92ae4566 --- /dev/null +++ b/src/apps/extension-host/src/loader.ts @@ -0,0 +1,620 @@ +import { readFile, realpath, stat } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import type { Plugin, PluginOptions } from "@opencode-ai/plugin" +import npmPackageArg from "npm-package-arg" +import semver from "semver" + +export const OPENCODE_COMPATIBILITY_VERSION = "1.17.18" + +const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"] +export type PluginDeclaration = { + spec: string + options?: PluginOptions + baseDirectory?: string +} + +export type PluginDeclarationInput = PluginDeclaration | string | readonly [string, PluginOptions?] + +export type PluginSource = "file" | "npm" +export type PluginCacheStatus = "hit" | "installed" | "validated" + +export type NormalizedPluginDeclaration = { + declarationIndex: number + spec: string + resolvedSpec: string + identity: string + source: PluginSource + packageName?: string + options?: PluginOptions + baseDirectory: string +} + +export type PluginPackage = { + directory: string + manifestPath: string + manifest: Record +} + +export type LoadedServerEntrypoint = { + id?: string + server: Plugin + index: number +} + +export type LoadedPlugin = NormalizedPluginDeclaration & { + target: string + entry: string + package?: PluginPackage + module: Record + entrypoints: LoadedServerEntrypoint[] +} + +export type PreparedPlugin = NormalizedPluginDeclaration & { + target: string + entry: string + cache: PluginCacheStatus + package?: PluginPackage +} + +export type LoaderDiagnosticStage = "declaration" | "resolve" | "install" | "entry" | "compatibility" | "load" | "shape" + +export type LoaderError = { + name?: string + message: string + stack?: string + cause?: string | LoaderError +} + +export type LoaderDiagnostic = { + level: "error" + declarationIndex: number + spec: string + stage: LoaderDiagnosticStage + message: string + error?: LoaderError +} + +export type NpmInstaller = (input: { + spec: string + packageName?: string + cacheDirectory: string +}) => Promise }> + +export type LoadPluginsInput = { + declarations: readonly PluginDeclarationInput[] + cacheDirectory: string + defaultBaseDirectory?: string + compatibilityVersion?: string + install?: NpmInstaller + readJson?: (file: string) => Promise> + satisfies?: (version: string, range: string) => boolean +} + +export type LoadPluginsResult = { + loaded: LoadedPlugin[] + diagnostics: LoaderDiagnostic[] +} + +export type PreparePluginsResult = { + prepared: PreparedPlugin[] + diagnostics: LoaderDiagnostic[] +} + +type PrepareCandidateResult = { prepared: PreparedPlugin } | { diagnostic: LoaderDiagnostic } +type LoadCandidateResult = { loaded: LoadedPlugin } | { diagnostic: LoaderDiagnostic } + +/** + * Resolve and import all surviving declarations concurrently. The returned + * entrypoints are intentionally not invoked here; callers execute them in the + * returned order to keep plugin initialization deterministic. + */ +export async function loadPlugins(input: LoadPluginsInput): Promise { + return loadPreparedPlugins(await preparePlugins(input)) +} + +export async function preparePlugins(input: LoadPluginsInput): Promise { + const normalized = await normalizePluginDeclarations(input.declarations, input.defaultBaseDirectory) + const readJson = input.readJson ?? readNodeJson + const satisfies = input.satisfies ?? ((version, range) => semver.satisfies(version, range)) + const install = input.install ?? unavailableInstaller + const results = await Promise.all( + normalized.declarations.map((declaration) => + prepareCandidate( + declaration, + input.cacheDirectory, + input.compatibilityVersion ?? OPENCODE_COMPATIBILITY_VERSION, + install, + readJson, + satisfies, + ), + ), + ) + + return { + prepared: results.flatMap((result) => ("prepared" in result ? [result.prepared] : [])), + diagnostics: [ + ...normalized.diagnostics, + ...results.flatMap((result) => ("diagnostic" in result ? [result.diagnostic] : [])), + ].sort((a, b) => a.declarationIndex - b.declarationIndex), + } +} + +export async function loadPreparedPlugins(input: PreparePluginsResult): Promise { + const results = await Promise.all(input.prepared.map(loadPreparedCandidate)) + return { + loaded: results.flatMap((result) => ("loaded" in result ? [result.loaded] : [])), + diagnostics: [ + ...input.diagnostics, + ...results.flatMap((result) => ("diagnostic" in result ? [result.diagnostic] : [])), + ].sort((a, b) => a.declarationIndex - b.declarationIndex), + } +} + +export const loadServerPlugins = loadPlugins + +export async function normalizePluginDeclarations( + declarations: readonly PluginDeclarationInput[], + defaultBaseDirectory = process.cwd(), +) { + const results = await Promise.all( + declarations.map(async (declaration, declarationIndex) => { + try { + return { + declaration: await normalizeDeclaration(declaration, declarationIndex, defaultBaseDirectory), + } + } catch (error) { + return { + diagnostic: makeDiagnostic(declarationIndex, declarationSpec(declaration), "declaration", error), + } + } + }), + ) + const seen = new Set() + const deduplicated: NormalizedPluginDeclaration[] = [] + + for (const result of results.toReversed()) { + if (!result.declaration) continue + if (seen.has(result.declaration.identity)) continue + seen.add(result.declaration.identity) + deduplicated.push(result.declaration) + } + + return { + declarations: deduplicated.toReversed(), + diagnostics: results.flatMap((result) => (result.diagnostic ? [result.diagnostic] : [])), + } +} + +export function extractServerEntrypoints(input: { + module: Record + spec: string + source: PluginSource + package?: PluginPackage +}): LoadedServerEntrypoint[] { + const preferred = preferredServerEntrypoint(input) + if (preferred) return [{ ...preferred, index: 0 }] + + const seen = new Set() + const result: LoadedServerEntrypoint[] = [] + + for (const value of Object.values(input.module)) { + if (seen.has(value)) continue + seen.add(value) + const server = serverFunction(value) + if (!server) throw new TypeError(`Plugin ${input.spec} export is not a function`) + result.push({ + ...(legacyPluginID(value) ? { id: legacyPluginID(value) } : {}), + server, + index: result.length, + }) + } + + if (!result.length) throw new TypeError(`Plugin ${input.spec} module is empty`) + return result +} + +export function parseNpmPluginSpecifier(spec: string, baseDirectory = process.cwd()) { + const parsed = npmPackageArg(spec, baseDirectory) + const packageName = parsed.name ?? undefined + const canonical = parsed.saveSpec ?? parsed.fetchSpec ?? parsed.raw + const installSpec = + parsed.type === "directory" || parsed.type === "file" + ? `file:${parsed.fetchSpec}` + : parsed.registry && parsed.raw === parsed.name + ? `${parsed.name}@latest` + : spec + return { + packageName, + identity: packageName ?? String(canonical), + installSpec, + type: parsed.type, + } +} + +async function prepareCandidate( + declaration: NormalizedPluginDeclaration, + cacheDirectory: string, + compatibilityVersion: string, + install: NpmInstaller, + readJson: (file: string) => Promise>, + satisfies: (version: string, range: string) => boolean, +): Promise { + let target: string + let cache: PluginCacheStatus + try { + if (declaration.source === "file") { + target = await resolveFileTarget(declaration.resolvedSpec) + cache = "validated" + } else { + const installed = await install({ + spec: parseNpmPluginSpecifier(declaration.spec, declaration.baseDirectory).installSpec, + packageName: declaration.packageName, + cacheDirectory, + }) + target = typeof installed === "string" ? installed : installed.target + cache = typeof installed === "string" ? "installed" : installed.cache + } + } catch (error) { + return { + diagnostic: makeDiagnostic( + declaration.declarationIndex, + declaration.spec, + declaration.source === "file" ? "resolve" : "install", + error, + ), + } + } + + let pkg: PluginPackage | undefined + let entry: string | undefined + try { + pkg = await readPluginPackage(target, declaration.source === "npm", readJson) + entry = await resolveServerEntrypoint(declaration.spec, declaration.source, target, pkg) + if (!entry) throw new Error(`Plugin ${declaration.spec} does not expose a server entrypoint`) + } catch (error) { + return { + diagnostic: makeDiagnostic(declaration.declarationIndex, declaration.spec, "entry", error), + } + } + + if (declaration.source === "npm" && pkg) { + try { + checkCompatibility(declaration.spec, pkg, compatibilityVersion, satisfies) + } catch (error) { + return { + diagnostic: makeDiagnostic(declaration.declarationIndex, declaration.spec, "compatibility", error), + } + } + } + + return { + prepared: { + ...declaration, + target, + entry, + cache, + package: pkg, + }, + } +} + +async function loadPreparedCandidate(plugin: PreparedPlugin): Promise { + let module: Record + try { + const imported = await import(plugin.entry) + if (!isRecord(imported)) throw new Error(`Plugin ${plugin.spec} module is empty`) + module = imported + } catch (error) { + return { + diagnostic: makeDiagnostic(plugin.declarationIndex, plugin.spec, "load", error), + } + } + + try { + return { + loaded: { + ...plugin, + module, + entrypoints: extractServerEntrypoints({ + module, + spec: plugin.spec, + source: plugin.source, + package: plugin.package, + }), + }, + } + } catch (error) { + return { + diagnostic: makeDiagnostic(plugin.declarationIndex, plugin.spec, "shape", error), + } + } +} + +async function normalizeDeclaration( + input: PluginDeclarationInput, + declarationIndex: number, + defaultBaseDirectory: string, +): Promise { + const declaration = declarationObject(input) + if (typeof declaration.spec !== "string" || !declaration.spec.trim()) { + throw new TypeError("Plugin declaration spec must be a non-empty string") + } + if (declaration.options !== undefined && !isRecord(declaration.options)) { + throw new TypeError("Plugin declaration options must be an object") + } + if (declaration.baseDirectory !== undefined && typeof declaration.baseDirectory !== "string") { + throw new TypeError("Plugin declaration baseDirectory must be a string") + } + + const spec = declaration.spec.trim() + const baseDirectory = path.resolve(declaration.baseDirectory ?? defaultBaseDirectory) + const source = pluginSource(spec) + if (source === "npm") { + const parsed = parseNpmPluginSpecifier(spec, baseDirectory) + return { + declarationIndex, + spec, + resolvedSpec: spec, + identity: `npm:${parsed.identity}`, + source, + packageName: parsed.packageName, + options: declaration.options, + baseDirectory, + } + } + + const file = spec.startsWith("file://") + ? fileURLToPath(spec) + : path.isAbsolute(spec) || isWindowsAbsolutePath(spec) + ? spec + : path.resolve(baseDirectory, spec) + const canonical = await realpath(file).catch(() => path.resolve(file)) + const resolvedSpec = pathToFileURL(canonical).href + return { + declarationIndex, + spec, + resolvedSpec, + identity: `file:${resolvedSpec}`, + source, + options: declaration.options, + baseDirectory, + } +} + +function declarationObject(input: PluginDeclarationInput): PluginDeclaration { + if (typeof input === "string") return { spec: input } + if (Array.isArray(input)) return { spec: input[0], options: input[1] } + if (isRecord(input)) return input as PluginDeclaration + throw new TypeError("Plugin declaration must be a string, tuple, or object") +} + +function declarationSpec(input: PluginDeclarationInput) { + if (typeof input === "string") return input + if (Array.isArray(input)) return typeof input[0] === "string" ? input[0] : "" + if (isRecord(input) && typeof input.spec === "string") return input.spec + return "" +} + +function pluginSource(spec: string): PluginSource { + if (spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || isWindowsAbsolutePath(spec)) { + return "file" + } + return "npm" +} + +async function resolveFileTarget(spec: string) { + const file = fileURLToPath(spec) + const info = await stat(file) + if (!info.isDirectory()) return realpath(file) + if (await exists(path.join(file, "package.json"))) return realpath(file) + + const index = await resolveDirectoryIndex(file) + if (index) return index + throw new Error(`Plugin directory ${file} is missing package.json or index file`) +} + +async function readPluginPackage( + target: string, + required: boolean, + readJson: (file: string) => Promise>, +): Promise { + const info = await stat(target) + const directory = info.isDirectory() ? target : path.dirname(target) + const manifestPath = path.join(directory, "package.json") + if (!(await exists(manifestPath))) { + if (required) throw new Error(`Plugin package ${directory} is missing package.json`) + return + } + + return { + directory: await realpath(directory), + manifestPath, + manifest: await readJson(manifestPath), + } +} + +async function resolveServerEntrypoint(spec: string, source: PluginSource, target: string, pkg?: PluginPackage) { + if (pkg) { + const exports = pkg.manifest.exports + if (isRecord(exports)) { + const server = extractExportValue(exports["./server"]) + if (server) return resolvePackageEntry(spec, server, "server", pkg) + } + + const main = typeof pkg.manifest.main === "string" ? pkg.manifest.main.trim() : "" + if (main) return resolvePackageEntry(spec, main, "main", pkg) + } + + const info = await stat(target) + if (!info.isDirectory()) return pathToFileURL(await realpath(target)).href + if (source === "npm") return + + const index = await resolveDirectoryIndex(target) + return index ? pathToFileURL(index).href : undefined +} + +async function resolvePackageEntry(spec: string, raw: string, kind: string, pkg: PluginPackage) { + const file = raw.startsWith("file://") + ? fileURLToPath(raw) + : path.isAbsolute(raw) || isWindowsAbsolutePath(raw) + ? raw + : path.resolve(pkg.directory, raw) + if (!contains(pkg.directory, path.resolve(file))) { + throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) + } + const [root, entry] = await Promise.all([realpath(pkg.directory), realpath(file)]) + if (!contains(root, entry)) throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) + return pathToFileURL(entry).href +} + +function extractExportValue(value: unknown): string | undefined { + if (typeof value === "string") return value + if (!isRecord(value)) return + if (typeof value.import === "string") return value.import + if (typeof value.default === "string") return value.default +} + +async function resolveDirectoryIndex(directory: string) { + for (const name of INDEX_FILES) { + const file = path.join(directory, name) + if (await exists(file)) return realpath(file) + } +} + +function checkCompatibility( + spec: string, + pkg: PluginPackage, + version: string, + satisfies: (version: string, range: string) => boolean, +) { + const engines = pkg.manifest.engines + if (!isRecord(engines) || typeof engines.opencode !== "string") return + if (satisfies(version, engines.opencode)) return + throw new Error(`Plugin ${spec} requires opencode ${engines.opencode} but running ${version}`) +} + +function preferredServerEntrypoint(input: { + module: Record + spec: string + source: PluginSource + package?: PluginPackage +}) { + const value = input.module.default + if (!isRecord(value)) return + if (!("id" in value) && !("server" in value) && !("tui" in value)) return + + if (value.server !== undefined && typeof value.server !== "function") { + throw new TypeError(`Plugin ${input.spec} has invalid server export`) + } + if (value.tui !== undefined && typeof value.tui !== "function") { + throw new TypeError(`Plugin ${input.spec} has invalid tui export`) + } + if (value.server !== undefined && value.tui !== undefined) { + throw new TypeError(`Plugin ${input.spec} must default export either server() or tui(), not both`) + } + if (value.server === undefined) { + throw new TypeError(`Plugin ${input.spec} must default export an object with server()`) + } + + const declaredID = readPluginID(value.id, input.spec) + if (input.source === "file" && !declaredID) { + throw new TypeError(`Path plugin ${input.spec} must export id`) + } + const packageID = input.source === "npm" && !declaredID ? packageName(input.package, input.spec) : undefined + return { + id: declaredID ?? packageID, + server: value.server as Plugin, + } +} + +function readPluginID(value: unknown, spec: string) { + if (value === undefined) return + if (typeof value !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof value}`) + const id = value.trim() + if (!id) throw new TypeError(`Plugin ${spec} has an empty id`) + return id +} + +function packageName(pkg: PluginPackage | undefined, spec: string) { + const name = pkg?.manifest.name + if (typeof name !== "string" || !name.trim()) { + throw new TypeError(`Plugin package for ${spec} is missing name`) + } + return name.trim() +} + +function serverFunction(value: unknown): Plugin | undefined { + if (typeof value === "function") return value as Plugin + if (!isRecord(value) || typeof value.server !== "function") return + return value.server as Plugin +} + +function legacyPluginID(value: unknown) { + if (!isRecord(value) || typeof value.id !== "string") return + const id = value.id.trim() + return id || undefined +} + +function packageSlug(name: string) { + const slug = name.replaceAll(/[^A-Za-z0-9._-]/g, "-").replaceAll(/^-+|-+$/g, "") + return slug || "plugin" +} + +function contains(root: string, file: string) { + const relative = path.relative(root, file) + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) +} + +function isWindowsAbsolutePath(value: string) { + return /^[A-Za-z]:[\\/]/.test(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +async function exists(file: string) { + return stat(file) + .then(() => true) + .catch(() => false) +} + +async function readNodeJson(file: string) { + const value: unknown = JSON.parse(await readFile(file, "utf8")) + if (!isRecord(value)) throw new TypeError(`${file} must contain a JSON object`) + return value +} + +function unavailableInstaller(input: Parameters[0]): Promise { + return Promise.reject(new Error(`No installer is configured for plugin ${input.spec}`)) +} + +function makeDiagnostic( + declarationIndex: number, + spec: string, + stage: LoaderDiagnosticStage, + error: unknown, +): LoaderDiagnostic { + const detail = errorInfo(error) + return { + level: "error", + declarationIndex, + spec, + stage, + message: detail.message, + error: detail, + } +} + +function errorInfo(error: unknown): LoaderError { + if (!(error instanceof Error)) return { message: String(error) } + return { + name: error.name, + message: error.message, + stack: error.stack, + ...(error.cause === undefined + ? {} + : { cause: error.cause instanceof Error ? errorInfo(error.cause) : String(error.cause) }), + } +} diff --git a/src/apps/extension-host/src/log.ts b/src/apps/extension-host/src/log.ts new file mode 100644 index 0000000000..a96d5051d3 --- /dev/null +++ b/src/apps/extension-host/src/log.ts @@ -0,0 +1,75 @@ +export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "off"] as const +export type LogLevel = (typeof LOG_LEVELS)[number] + +const DEFAULT_LOG_LEVEL: LogLevel = "debug" +const LOG_LEVEL_RANK: Readonly> = { + trace: 0, + debug: 1, + info: 2, + warn: 3, + error: 4, + off: 5, +} + +let currentLogLevel = parseLogLevel(process.env.OPENCODE_EXTENSION_HOST_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL + +export function setLogLevel(level: string): LogLevel { + const parsed = parseLogLevel(level) + if (!parsed) throw new TypeError(`Invalid extension host log level: ${level}`) + currentLogLevel = parsed + return currentLogLevel +} + +export function getLogLevel(): LogLevel { + return currentLogLevel +} + +export function logEvent(event: string, fields: Record = {}, level: LogLevel = "info") { + if (!shouldLog(level)) return + const record = { + timestamp: new Date().toISOString(), + level, + event, + ...fields, + } + console.error(`[extension-host] ${JSON.stringify(record)}`) +} + +function parseLogLevel(value: string | undefined): LogLevel | undefined { + if (!value) return undefined + return LOG_LEVELS.find((candidate) => candidate === value.trim().toLowerCase()) +} + +function shouldLog(level: LogLevel): boolean { + return currentLogLevel !== "off" && LOG_LEVEL_RANK[level] >= LOG_LEVEL_RANK[currentLogLevel] +} + +export function logError(event: string, error: unknown, fields: Record = {}) { + const value = error instanceof Error ? error : new Error(String(error)) + logEvent( + event, + { + ...fields, + error_name: value.name, + error_message: value.message, + }, + "error", + ) +} + +export function rpcMessageSummary(message: unknown) { + if (!message || typeof message !== "object") return { kind: "invalid" } + const value = message as Record + const id = typeof value.id === "string" ? { request_id: value.id } : {} + if (typeof value.method === "string") { + return { + ...id, + kind: value.id === undefined ? "notification" : "request", + method: value.method, + } + } + return { + ...id, + kind: "error" in value ? "error_response" : "response", + } +} diff --git a/src/apps/extension-host/src/loopback.ts b/src/apps/extension-host/src/loopback.ts new file mode 100644 index 0000000000..1a5af8dcd6 --- /dev/null +++ b/src/apps/extension-host/src/loopback.ts @@ -0,0 +1,9 @@ +import { isIP } from "node:net" +import { parseRpcAddress } from "./rpc" + +export function requireLoopbackAddress(address: string) { + const hostname = parseRpcAddress(address).hostname.toLowerCase() + const normalized = hostname.replace(/^\[|\]$/g, "") + if (normalized === "localhost" || normalized === "127.0.0.1" || (isIP(normalized) === 6 && normalized === "::1")) return + throw new Error(`OPENCODE_EXTENSION_HOST_RPC_ADDRESS must be loopback, received ${hostname}`) +} diff --git a/src/apps/extension-host/src/main.ts b/src/apps/extension-host/src/main.ts new file mode 100644 index 0000000000..931de00043 --- /dev/null +++ b/src/apps/extension-host/src/main.ts @@ -0,0 +1,163 @@ +import path from "node:path" +import type { RpcConnection, StreamBridge } from "./backend" +import { ExtensionHostError } from "./errors" +import { ExtensionHost } from "./host" +import { logError, logEvent } from "./log" +import { createGateway } from "./gateway" +import { prepareBunPlugins } from "./bun-loader" +import { + BackendMethodSchemas, + DEFAULT_MAX_FRAME_BYTES, + OPENCODE_VERSION, + PROTOCOL_VERSION, + type BackendMethod, +} from "./protocol" +import { connectRpcPeer } from "./rpc" +import { registerHostMethods } from "./service" +import { remoteReadable, StreamRegistry } from "./streams" +import { registerStreamMethods } from "./stream-methods" +import { requireLoopbackAddress } from "./loopback" + +await main() + +async function main() { + logEvent("startup.begin", { runtime: "bun" }) + configureLoopbackProxyBypass() + const address = requiredEnvironment("OPENCODE_EXTENSION_HOST_RPC_ADDRESS") + const token = requiredEnvironment("OPENCODE_EXTENSION_HOST_RPC_TOKEN") + requireLoopbackAddress(address) + const peer = await connectRpcPeer(address, { + idPrefix: "host", + onError(error) { + logError("rpc.failure", error, { runtime: "bun" }) + }, + }) + logEvent("startup.rpc_connected", { runtime: "bun", address }) + const backend = protocolConnection(peer) + const registry = new StreamRegistry("host") + const owners = new Map() + const deferred = Promise.withResolvers() + void deferred.promise.catch(() => {}) + const streams: StreamBridge = { + register(instanceID, stream, length) { + const descriptor = registry.register(stream, length) + owners.set(descriptor.streamID, instanceID) + return descriptor + }, + remote(methodPrefix, instanceID, descriptor) { + return remoteReadable(backend, methodPrefix, descriptor, { instanceID }) + }, + async cancel(instanceID, descriptor) { + if (owners.get(descriptor.streamID) !== instanceID) return + owners.delete(descriptor.streamID) + await registry.cancel({ streamID: descriptor.streamID, reason: "Stream owner released it" }) + }, + async cancelAll(instanceID) { + await Promise.all( + Array.from(owners, ([streamID, owner]) => { + if (owner !== instanceID) return Promise.resolve() + owners.delete(streamID) + return registry.cancel({ streamID, reason: `Instance ${instanceID} closed` }).then(() => {}) + }), + ) + }, + async cancelRemote(instanceID, descriptor, reason) { + await backend.request("backend.stream.cancel", { + instanceID, + streamID: descriptor.streamID, + ...(reason ? { reason } : {}), + }) + }, + } + + let host: ExtensionHost | undefined + try { + const handshake = BackendMethodSchemas["backend.handshake"].result.parse( + await backend.request("backend.handshake", { + token, + protocolVersion: PROTOCOL_VERSION, + opencodeVersion: OPENCODE_VERSION, + maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + }), + ) + if (!path.isAbsolute(handshake.cacheDirectory)) { + throw new ExtensionHostError(-32001, "backend.handshake returned a relative cacheDirectory", { + kind: "invalid_handshake", + cacheDirectory: handshake.cacheDirectory, + }) + } + peer.setMaxFrameBytes(handshake.maxFrameBytes) + logEvent("startup.handshake_complete", { + runtime: "bun", + max_frame_bytes: handshake.maxFrameBytes, + }) + host = new ExtensionHost({ + rpc: backend, + streams, + cacheDirectory: handshake.cacheDirectory, + gatewayFactory: createGateway, + preparePlugins: prepareBunPlugins, + shell: Bun.$, + }) + deferred.resolve(host) + registerStreamMethods(peer, registry, owners) + registerHostMethods({ + peer, + host: deferred.promise, + shutdown() { + void peer.flushAndClose().catch((error) => logError("shutdown.rpc_close_failed", error, { runtime: "bun" })) + }, + }) + logEvent("startup.ready", { runtime: "bun" }) + await peer.closed + logEvent("rpc.closed", { runtime: "bun", failed: peer.closeError !== undefined }) + if (peer.closeError) throw peer.closeError + } catch (error) { + logError("startup.failed", error, { runtime: "bun" }) + deferred.reject(error) + peer.close(error instanceof Error ? error : new Error(String(error))) + throw error + } finally { + await host?.shutdown().catch((error) => logError("shutdown.failed", error, { runtime: "bun" })) + await registry.cancelAll("Extension host connection closed") + owners.clear() + logEvent("shutdown.complete", { runtime: "bun" }) + } +} + +function protocolConnection(peer: Awaited>): RpcConnection { + return { + async request(method: string, params: unknown, options?: { signal?: AbortSignal }) { + const definition = BackendMethodSchemas[method as BackendMethod] + if (!definition) throw new TypeError(`Unknown backend method ${method}`) + const validated = definition.params.parse(params) + return definition.result.parse(await peer.request(method, validated, options)) as Result + }, + notify(method: string, params: unknown) { + const definition = BackendMethodSchemas[method as BackendMethod] + if (!definition) throw new TypeError(`Unknown backend method ${method}`) + return peer.notify(method, definition.params.parse(params)) + }, + } +} + +function requiredEnvironment(name: string) { + const value = Bun.env[name] + if (value) return value + throw new Error(`Missing required environment variable ${name}`) +} + +function configureLoopbackProxyBypass() { + for (const name of ["NO_PROXY", "no_proxy"] as const) { + const values = new Set( + (Bun.env[name] ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ) + values.add("127.0.0.1") + values.add("localhost") + values.add("::1") + Bun.env[name] = Array.from(values).join(",") + } +} diff --git a/src/apps/extension-host/src/protocol.ts b/src/apps/extension-host/src/protocol.ts new file mode 100644 index 0000000000..749a82f1c5 --- /dev/null +++ b/src/apps/extension-host/src/protocol.ts @@ -0,0 +1,438 @@ +import { z } from "zod" + +export const PROTOCOL_VERSION = 1 +export const OPENCODE_VERSION = "1.17.18" +export const MIN_NEGOTIATED_FRAME_BYTES = 64 * 1024 +export const DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024 +export const MAX_MAX_FRAME_BYTES = 64 * 1024 * 1024 +export const MAX_STREAM_CHUNK_BYTES = 64 * 1024 + +export const JsonValueSchema = z.json() +export const JsonObjectSchema = z.record(z.string(), JsonValueSchema) +export const EmptyResultSchema = z.object({}).strict() +export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "off"]) +export const InstanceParamsSchema = z.object({ instanceID: z.string().min(1) }) +export const HeaderSchema = z.array(z.string()).length(2) +export const HeadersSchema = z.array(HeaderSchema) +export const StreamDescriptorSchema = z.object({ + streamID: z.string().min(1), + length: z.number().int().nonnegative().optional(), +}) +export const StreamReadParamsSchema = z.object({ + instanceID: z.string().min(1), + streamID: z.string().min(1), + maxBytes: z.number().int().positive().max(MAX_STREAM_CHUNK_BYTES).optional(), +}) +export const StreamReadResultSchema = z.object({ + data: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), + eof: z.boolean(), +}) +export const StreamCancelParamsSchema = StreamReadParamsSchema.omit({ maxBytes: true }).extend({ + reason: z.string().optional(), +}) +export const CancelResultSchema = z.object({ cancelled: z.boolean() }) +export const CloseResultSchema = z.object({ closed: z.boolean() }) +export const ReleaseResultSchema = z.object({ released: z.boolean() }) + +export const RpcErrorObjectSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: JsonValueSchema.optional(), +}) +export const RpcRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string().min(1), + method: z.string().min(1), + params: JsonValueSchema.optional(), +}) +export const RpcNotificationSchema = RpcRequestSchema.omit({ id: true }) +export const RpcSuccessResponseSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string().min(1), + result: JsonValueSchema, +}) +export const RpcErrorResponseSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string().min(1), + error: RpcErrorObjectSchema, +}) +export const RpcMessageSchema = z.union([ + RpcRequestSchema, + RpcNotificationSchema, + RpcSuccessResponseSchema, + RpcErrorResponseSchema, +]) + +export const DiagnosticSchema = z.object({ + severity: z.enum(["debug", "info", "warning", "error"]), + code: z.string(), + message: z.string(), + plugin: z.string().optional(), + method: z.string().optional(), + data: JsonValueSchema.optional(), +}) +export const PluginDeclarationSchema = z.object({ + spec: z.string().min(1), + options: JsonObjectSchema.optional(), + baseDirectory: z.string().optional(), +}) +export const PluginPrepareFailureSchema = z.object({ + spec: z.string(), + stage: z.enum(["declaration", "resolve", "install", "entry", "compatibility", "load", "shape"]), + message: z.string(), +}) +export const ToolAttachmentSchema = z.object({ + type: z.literal("file"), + mime: z.string(), + url: z.string(), + filename: z.string().optional(), +}) +export const ToolResultSchema = z.union([ + z.string(), + z.object({ + title: z.string().optional(), + output: z.string(), + metadata: JsonObjectSchema.optional(), + attachments: z.array(ToolAttachmentSchema).optional(), + }), +]) +export const ToolRegistrationSchema = z.object({ + registrationID: z.string().min(1), + id: z.string().min(1), + plugin: JsonObjectSchema.optional(), + description: z.string(), + parameters: JsonValueSchema, +}) +export const AuthRuleSchema = z.object({ + key: z.string(), + op: z.enum(["eq", "neq"]), + value: z.string(), +}) +export const AuthPromptSchema = z.object({ + type: z.enum(["text", "select"]), + promptIndex: z.number().int().nonnegative(), + key: z.string(), + message: z.string(), + placeholder: z.string().optional(), + options: z.array(z.object({ label: z.string(), value: z.string(), hint: z.string().optional() })).optional(), + when: AuthRuleSchema.optional(), + hasValidate: z.boolean(), + hasCondition: z.boolean(), +}) +export const AuthRegistrationSchema = z.object({ + provider: z.string().min(1), + plugin: JsonObjectSchema.optional(), + hasLoader: z.boolean(), + methods: z.array( + z.object({ + type: z.enum(["oauth", "api"]), + label: z.string(), + methodIndex: z.number().int().nonnegative(), + hasAuthorize: z.boolean(), + prompts: z.array(AuthPromptSchema), + }), + ), +}) +export const ProviderRegistrationSchema = z.object({ + provider: z.string().min(1), + plugin: JsonObjectSchema.optional(), + hasModels: z.boolean(), +}) +export const WorkspaceRegistrationSchema = z.object({ + registrationID: z.string().min(1), + type: z.string().min(1), + plugin: JsonObjectSchema.optional(), + name: z.string(), + description: z.string(), +}) +export const AuthSuccessSchema = z.union([ + z.object({ + type: z.literal("success"), + provider: z.string().optional(), + refresh: z.string(), + access: z.string(), + expires: z.number(), + accountId: z.string().optional(), + enterpriseUrl: z.string().optional(), + }), + z.object({ + type: z.literal("success"), + provider: z.string().optional(), + key: z.string(), + metadata: z.record(z.string(), z.string()).optional(), + }), +]) +export const AuthFailedSchema = z.object({ type: z.literal("failed") }) +export const AuthFetchRequestSchema = z.object({ + url: z.string().url(), + method: z.string().min(1).optional(), + headers: HeadersSchema.optional(), + body: StreamDescriptorSchema.optional(), +}) +export const HttpResponseSchema = z.object({ + status: z.number().int().min(100).max(599), + statusText: z.string().optional(), + headers: HeadersSchema, + body: StreamDescriptorSchema.optional(), +}) + +type MethodDefinition = { params: z.ZodType; result: z.ZodType } +type MethodDefinitions = Record + +export const HostMethodSchemas = { + "host.plugins.prepare": { + params: z.object({ + plugins: z.array(PluginDeclarationSchema), + configurationFingerprint: z.string().min(1).optional(), + defaultBaseDirectory: z.string().optional(), + }), + result: z.object({ + configurationFingerprint: z.string().min(1).optional(), + prepared: z.array(z.object({ + spec: z.string(), + source: z.enum(["file", "npm"]), + target: z.string(), + entry: z.string(), + cache: z.enum(["hit", "installed", "validated"]), + version: z.string().optional(), + })), + failed: z.array(PluginPrepareFailureSchema), + diagnostics: z.array(DiagnosticSchema), + }), + }, + "host.instance.open": { + params: z.object({ + instanceID: z.string().min(1), + project: JsonValueSchema, + config: JsonObjectSchema, + directory: z.string(), + worktree: z.string(), + plugins: z.array(PluginDeclarationSchema), + configurationFingerprint: z.string().min(1).optional(), + }), + result: z.object({ + instanceID: z.string().min(1), + config: JsonObjectSchema, + diagnostics: z.array(DiagnosticSchema), + hooks: z.array(z.string()), + tools: z.array(ToolRegistrationSchema), + auth: z.array(AuthRegistrationSchema), + providers: z.array(ProviderRegistrationSchema), + workspaces: z.array(WorkspaceRegistrationSchema), + gatewayURL: z.string().url(), + }), + }, + "host.instance.close": { params: InstanceParamsSchema, result: CloseResultSchema }, + "host.log.setLevel": { params: z.object({ level: LogLevelSchema }), result: z.object({ level: LogLevelSchema }) }, + "host.shutdown": { params: EmptyResultSchema, result: CloseResultSchema }, + "host.hook.call": { + params: z.object({ + instanceID: z.string().min(1), + hook: z.string().min(1), + input: JsonValueSchema, + output: JsonValueSchema, + }), + result: z.object({ input: JsonValueSchema, output: JsonValueSchema }), + }, + "host.event.emit": { + params: z.object({ instanceID: z.string().min(1), event: JsonValueSchema }), + result: z.object({ accepted: z.literal(true) }), + }, + "host.tool.execute": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + registrationID: z.string().min(1), + args: JsonValueSchema, + context: z.object({ + sessionID: z.string(), + messageID: z.string(), + agent: z.string(), + callID: z.string().optional(), + }), + }), + result: ToolResultSchema, + }, + "host.tool.cancel": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + reason: z.string().optional(), + }), + result: CancelResultSchema, + }, + "host.auth.prompt.evaluate": { + params: z.object({ + instanceID: z.string().min(1), + provider: z.string().min(1), + methodIndex: z.number().int().nonnegative(), + promptIndex: z.number().int().nonnegative(), + operation: z.enum(["validate", "condition"]), + value: z.string().optional(), + inputs: z.record(z.string(), z.string()), + }), + result: z.union([ + z.object({ operation: z.literal("validate"), error: z.string().optional() }), + z.object({ operation: z.literal("condition"), active: z.boolean() }), + ]), + }, + "host.auth.authorize": { + params: z.object({ + instanceID: z.string().min(1), + provider: z.string().min(1), + methodIndex: z.number().int().nonnegative(), + inputs: z.record(z.string(), z.string()).optional(), + }), + result: z.union([ + z.object({ + type: z.literal("oauth"), + flowID: z.string().min(1), + url: z.string().url(), + instructions: z.string(), + method: z.enum(["auto", "code"]), + }), + z.object({ type: z.literal("api"), result: z.union([AuthSuccessSchema, AuthFailedSchema]).optional() }), + ]), + }, + "host.auth.callback": { + params: z.object({ instanceID: z.string().min(1), flowID: z.string().min(1), code: z.string().optional() }), + result: z.union([AuthSuccessSchema, AuthFailedSchema]), + }, + "host.auth.flow.cancel": { + params: z.object({ instanceID: z.string().min(1), flowID: z.string().min(1), reason: z.string().optional() }), + result: CancelResultSchema, + }, + "host.auth.loader": { + params: z.object({ + instanceID: z.string().min(1), + provider: z.string().min(1), + providerInfo: JsonValueSchema, + }), + result: z.object({ value: JsonObjectSchema, fetchID: z.string().min(1).optional() }), + }, + "host.auth.fetch": { + params: z.object({ + instanceID: z.string().min(1), + fetchID: z.string().min(1), + requestID: z.string().min(1), + request: AuthFetchRequestSchema, + }), + result: HttpResponseSchema, + }, + "host.auth.fetch.cancel": { + params: z.object({ instanceID: z.string().min(1), requestID: z.string().min(1), reason: z.string().optional() }), + result: CancelResultSchema, + }, + "host.auth.fetch.release": { + params: z.object({ instanceID: z.string().min(1), fetchID: z.string().min(1) }), + result: ReleaseResultSchema, + }, + "host.provider.models": { + params: z.object({ + instanceID: z.string().min(1), + providerID: z.string().min(1), + provider: JsonValueSchema, + auth: JsonValueSchema.optional(), + }), + result: z.object({ models: JsonObjectSchema }), + }, + "host.workspace.configure": { + params: z.object({ instanceID: z.string().min(1), registrationID: z.string().min(1), config: JsonValueSchema }), + result: z.object({ config: JsonValueSchema }), + }, + "host.workspace.create": { + params: z.object({ + instanceID: z.string().min(1), + registrationID: z.string().min(1), + config: JsonValueSchema, + env: z.record(z.string(), z.string().nullable()), + from: JsonValueSchema.optional(), + }), + result: EmptyResultSchema, + }, + "host.workspace.remove": { + params: z.object({ instanceID: z.string().min(1), registrationID: z.string().min(1), config: JsonValueSchema }), + result: EmptyResultSchema, + }, + "host.workspace.target": { + params: z.object({ instanceID: z.string().min(1), registrationID: z.string().min(1), config: JsonValueSchema }), + result: z.object({ + target: z.union([ + z.object({ type: z.literal("local"), directory: z.string() }), + z.object({ type: z.literal("remote"), url: z.string().url(), headers: HeadersSchema.optional() }), + ]), + }), + }, + "host.stream.read": { params: StreamReadParamsSchema, result: StreamReadResultSchema }, + "host.stream.cancel": { params: StreamCancelParamsSchema, result: CancelResultSchema }, +} satisfies MethodDefinitions + +export const BackendMethodSchemas = { + "backend.handshake": { + params: z.object({ + token: z.string().min(1), + protocolVersion: z.literal(PROTOCOL_VERSION), + opencodeVersion: z.literal(OPENCODE_VERSION), + maxFrameBytes: z.number().int().min(MIN_NEGOTIATED_FRAME_BYTES).max(MAX_MAX_FRAME_BYTES), + }), + result: z.object({ + protocolVersion: z.literal(PROTOCOL_VERSION), + maxFrameBytes: z.number().int().min(MIN_NEGOTIATED_FRAME_BYTES).max(MAX_MAX_FRAME_BYTES), + cacheDirectory: z.string(), + }), + }, + "backend.http.request": { + params: z.object({ + instanceID: z.string().min(1), + requestID: z.string().min(1), + method: z.string().min(1), + path: z.string(), + headers: HeadersSchema, + body: StreamDescriptorSchema.optional(), + }), + result: HttpResponseSchema, + }, + "backend.auth.get": { + params: z.object({ instanceID: z.string().min(1), providerID: z.string().min(1) }), + result: z.object({ auth: JsonValueSchema.nullable() }), + }, + "backend.tool.ask": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + permission: z.string(), + patterns: z.array(z.string()), + always: z.array(z.string()), + metadata: JsonObjectSchema, + }), + result: EmptyResultSchema, + }, + "backend.tool.metadata": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + title: z.string().optional(), + metadata: JsonObjectSchema.optional(), + }), + result: EmptyResultSchema, + }, + "backend.diagnostic.publish": { + params: z.object({ instanceID: z.string().min(1).optional(), diagnostic: DiagnosticSchema }), + result: EmptyResultSchema, + }, + "backend.stream.read": { params: StreamReadParamsSchema, result: StreamReadResultSchema }, + "backend.stream.cancel": { params: StreamCancelParamsSchema, result: CancelResultSchema }, +} satisfies MethodDefinitions + +export type HostMethod = keyof typeof HostMethodSchemas +export type BackendMethod = keyof typeof BackendMethodSchemas +export type MethodParams = z.input +export type MethodResult = z.output +export type HostMethodParams = MethodParams +export type HostMethodResult = MethodResult +export type BackendMethodParams = MethodParams +export type BackendMethodResult = MethodResult +export type StreamDescriptor = z.infer +export type StreamReadParams = z.infer +export type StreamReadResult = z.infer +export type RpcErrorObject = z.infer diff --git a/src/apps/extension-host/src/rpc.ts b/src/apps/extension-host/src/rpc.ts new file mode 100644 index 0000000000..6463bf06de --- /dev/null +++ b/src/apps/extension-host/src/rpc.ts @@ -0,0 +1,484 @@ +import { + DEFAULT_MAX_FRAME_BYTES, + JsonValueSchema, + MAX_MAX_FRAME_BYTES, + RpcMessageSchema, + type RpcErrorObject, +} from "./protocol" +import { logEvent, logError, rpcMessageSummary } from "./log" + +export type RpcTransport = { + write(data: Uint8Array): number | void | Promise + end?(): void + terminate?(): void +} + +export type RpcHandler = (params: unknown) => unknown | Promise + +export type RpcPeerOptions = { + idPrefix: string + maxFrameBytes?: number + onEof?: (error?: Error) => void | Promise + onError?: (error: Error) => void +} + +export type RpcRequestOptions = { + signal?: AbortSignal +} + +type PendingRequest = { + resolve(value: unknown): void + reject(error: Error): void + cleanup(): void +} + +export class RpcError extends Error { + readonly code: number + readonly data?: unknown + + constructor(code: number, message: string, data?: unknown) { + super(message) + this.name = "RpcError" + this.code = code + this.data = data + } +} + +export class RpcConnectionClosedError extends Error { + constructor(message = "JSON-RPC connection is closed", options?: ErrorOptions) { + super(message, options) + this.name = "RpcConnectionClosedError" + } +} + +export class RpcProtocolError extends RpcError { + constructor(code: -32700 | -32600, message: string, data?: unknown) { + super(code, message, data) + this.name = "RpcProtocolError" + } +} + +export class RpcPeer { + readonly closed: Promise + readonly #transport: RpcTransport + readonly #idPrefix: string + readonly #handlers = new Map() + readonly #pending = new Map() + readonly #onEof?: RpcPeerOptions["onEof"] + readonly #onError?: RpcPeerOptions["onError"] + readonly #resolveClosed: () => void + #buffer = new Uint8Array() + #sequence = 0 + #ended = false + #closeError?: Error + #maxFrameBytes: number + #writeTail = Promise.resolve() + + constructor(transport: RpcTransport, options: RpcPeerOptions) { + if (!options.idPrefix) throw new TypeError("JSON-RPC ID prefix must not be empty") + this.#transport = transport + this.#idPrefix = options.idPrefix + this.#maxFrameBytes = validateMaxFrameBytes(options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES) + this.#onEof = options.onEof + this.#onError = options.onError + const deferred = Promise.withResolvers() + this.closed = deferred.promise + this.#resolveClosed = deferred.resolve + } + + get maxFrameBytes() { + return this.#maxFrameBytes + } + + get closeError() { + return this.#closeError + } + + setMaxFrameBytes(value: number) { + this.#maxFrameBytes = validateMaxFrameBytes(value) + } + + handle(method: string, handler: (params: Params) => Result | Promise) { + if (!method) throw new TypeError("JSON-RPC method must not be empty") + if (this.#handlers.has(method)) throw new Error(`JSON-RPC handler already registered for ${method}`) + this.#handlers.set(method, handler as RpcHandler) + return () => { + if (this.#handlers.get(method) === handler) this.#handlers.delete(method) + } + } + + async request(method: string, params: unknown = {}, options: RpcRequestOptions = {}) { + this.#assertOpen() + if (options.signal?.aborted) throw abortError(options.signal.reason) + const id = `${this.#idPrefix}:${++this.#sequence}` + const deferred = Promise.withResolvers() + const abort = () => { + this.#pending.delete(id) + deferred.reject(abortError(options.signal?.reason)) + } + const cleanup = () => options.signal?.removeEventListener("abort", abort) + this.#pending.set(id, { resolve: deferred.resolve, reject: deferred.reject, cleanup }) + options.signal?.addEventListener("abort", abort, { once: true }) + + try { + await this.#send({ jsonrpc: "2.0", id, method, params }) + } catch (error) { + const pending = this.#pending.get(id) + if (pending) { + this.#pending.delete(id) + pending.cleanup() + pending.reject(asError(error)) + } + } + return (await deferred.promise) as Result + } + + notify(method: string, params: unknown = {}) { + this.#assertOpen() + return this.#send({ jsonrpc: "2.0", method, params }) + } + + receive(data: Uint8Array) { + if (this.#ended || data.byteLength === 0) return + this.#buffer = concatBytes(this.#buffer, data) + + while (this.#buffer.byteLength >= 4) { + const length = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, 4).getUint32(0, false) + if (length === 0) { + this.#fail(new RpcProtocolError(-32600, "JSON-RPC frame must not be empty")) + return + } + if (length > this.#maxFrameBytes) { + this.#fail( + new RpcProtocolError(-32600, `JSON-RPC frame length ${length} exceeds limit ${this.#maxFrameBytes}`, { + length, + maxFrameBytes: this.#maxFrameBytes, + }), + ) + return + } + if (this.#buffer.byteLength < length + 4) return + const payload = this.#buffer.slice(4, length + 4) + this.#buffer = this.#buffer.slice(length + 4) + this.#receivePayload(payload) + if (this.#ended) return + } + } + + end(error?: Error) { + this.#finish(error) + } + + close(error?: Error) { + if (this.#ended) return + try { + this.#transport.end?.() + } catch (cause) { + error ??= asError(cause) + } + this.#finish(error) + } + + async flushAndClose(error?: Error) { + await this.#writeTail + this.close(error) + } + + #receivePayload(payload: Uint8Array) { + let value: unknown + try { + value = JSON.parse(new TextDecoder().decode(payload)) + } catch (error) { + this.#fail(new RpcProtocolError(-32700, "Invalid JSON-RPC JSON payload", errorDetails(error))) + return + } + + const parsed = RpcMessageSchema.safeParse(value) + if (!parsed.success) { + const id = responseID(value) + if (!id) { + this.#fail(new RpcProtocolError(-32600, "Invalid JSON-RPC message", { issues: parsed.error.issues })) + return + } + void this.#sendError(id, { + code: -32600, + message: "Invalid JSON-RPC message", + data: safeErrorData({ issues: parsed.error.issues }), + }) + return + } + + const message = parsed.data + logEvent("rpc.receive", { ...rpcMessageSummary(message), frame_bytes: payload.byteLength }, "debug") + if ("method" in message) { + void this.#dispatch(message.method, message.params, "id" in message ? message.id : undefined) + return + } + + const pending = this.#pending.get(message.id) + if (!pending) return + this.#pending.delete(message.id) + pending.cleanup() + if ("error" in message) { + pending.reject(new RpcError(message.error.code, message.error.message, message.error.data)) + return + } + pending.resolve(message.result) + } + + async #dispatch(method: string, params: unknown, id?: string) { + const handler = this.#handlers.get(method) + if (!handler) { + if (id) await this.#sendError(id, { code: -32601, message: `Method not found: ${method}` }) + return + } + + let result: unknown + try { + result = await handler(params) + } catch (error) { + if (id) { + await this.#sendError(id, rpcErrorObject(error)) + return + } + this.#reportError(asError(error)) + return + } + if (!id) return + + try { + await this.#send({ jsonrpc: "2.0", id, result: result === undefined ? null : result }) + } catch (error) { + if (error instanceof RangeError) { + await this.#sendError(id, { + code: -32000, + message: "JSON-RPC response exceeds the negotiated frame limit", + data: { kind: "response_too_large", maxFrameBytes: this.#maxFrameBytes }, + }) + } else if (!this.#ended) { + this.#fail(asError(error)) + } + } + } + + async #sendError(id: string, error: RpcErrorObject) { + try { + await this.#send({ jsonrpc: "2.0", id, error }) + } catch (cause) { + this.#reportError(asError(cause)) + } + } + + async #send(message: unknown) { + this.#assertOpen() + const frame = encodeFrame(message, this.#maxFrameBytes) + logEvent("rpc.send", { ...rpcMessageSummary(message), frame_bytes: frame.byteLength - 4 }, "debug") + const write = this.#writeTail.then(async () => { + this.#assertOpen() + const written = await this.#transport.write(frame) + if (typeof written === "number" && written < frame.byteLength) { + throw new Error(`JSON-RPC transport accepted ${written} of ${frame.byteLength} bytes`) + } + }) + this.#writeTail = write.catch(() => {}) + try { + await write + } catch (error) { + this.#fail(asError(error)) + throw error + } + } + + #assertOpen() { + if (this.#ended) throw new RpcConnectionClosedError(undefined, { cause: this.#closeError }) + } + + #fail(error: Error) { + if (this.#ended) return + this.#finish(error) + try { + if (this.#transport.terminate) this.#transport.terminate() + if (!this.#transport.terminate) this.#transport.end?.() + } catch { + // The original protocol or transport failure remains authoritative. + } + this.#reportError(error) + } + + #finish(error?: Error) { + if (this.#ended) return + this.#ended = true + this.#closeError = error + this.#buffer = new Uint8Array() + const reason = new RpcConnectionClosedError(undefined, { cause: error }) + for (const pending of this.#pending.values()) { + pending.cleanup() + pending.reject(reason) + } + this.#pending.clear() + this.#resolveClosed() + if (this.#onEof) void Promise.resolve(this.#onEof(error)).catch((cause) => this.#reportError(asError(cause))) + } + + #reportError(error: Error) { + if (this.#onError) { + this.#onError(error) + return + } + logError("rpc.failure", error) + } +} + +export function encodeFrame(message: unknown, maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) { + const limit = validateMaxFrameBytes(maxFrameBytes) + const text = JSON.stringify(message, (_key, value: unknown) => { + if (typeof value === "bigint") throw new TypeError("JSON-RPC values cannot contain BigInt") + if (typeof value === "function" || typeof value === "symbol") { + throw new TypeError(`JSON-RPC values cannot contain ${typeof value}`) + } + if (typeof value === "number" && !Number.isFinite(value)) { + throw new TypeError("JSON-RPC values cannot contain non-finite numbers") + } + return value + }) + if (text === undefined) throw new TypeError("JSON-RPC message is not serializable") + const payload = new TextEncoder().encode(text) + if (payload.byteLength === 0 || payload.byteLength > limit) { + throw new RangeError(`JSON-RPC payload length ${payload.byteLength} exceeds limit ${limit}`) + } + const frame = new Uint8Array(payload.byteLength + 4) + new DataView(frame.buffer).setUint32(0, payload.byteLength, false) + frame.set(payload, 4) + return frame +} + +export async function connectRpcPeer( + address: string, + options: Omit & { idPrefix?: string } = {}, +) { + const target = parseRpcAddress(address) + let socket: Bun.Socket + let drain: ReturnType> | undefined + const peer = new RpcPeer( + { + async write(data) { + let offset = 0 + while (offset < data.byteLength) { + const written = socket.write(data, offset, data.byteLength - offset) + if (written < 0) throw new RpcConnectionClosedError("JSON-RPC socket closed while writing") + offset += written + if (offset === data.byteLength) return offset + drain ??= Promise.withResolvers() + await drain.promise + } + return offset + }, + end: () => socket.end(), + terminate: () => socket.terminate(), + }, + { ...options, idPrefix: options.idPrefix ?? "host" }, + ) + socket = await Bun.connect({ + hostname: target.hostname, + port: target.port, + socket: { + data(_socket, data) { + peer.receive(data) + }, + drain() { + drain?.resolve() + drain = undefined + }, + close() { + drain?.reject(new RpcConnectionClosedError()) + drain = undefined + peer.end() + }, + error(_socket, error) { + drain?.reject(error) + drain = undefined + peer.end(error) + }, + }, + }) + return peer +} + +export function parseRpcAddress(address: string) { + const url = new URL(address.includes("://") ? address : `tcp://${address}`) + if (url.protocol !== "tcp:") throw new TypeError(`Unsupported RPC address protocol: ${url.protocol}`) + if (!url.hostname || !url.port) throw new TypeError(`RPC address must include a host and port: ${address}`) + const port = Number(url.port) + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TypeError(`Invalid RPC port: ${url.port}`) + return { hostname: url.hostname, port } +} + +function validateMaxFrameBytes(value: number) { + if (!Number.isInteger(value) || value < 1 || value > MAX_MAX_FRAME_BYTES) { + throw new RangeError(`maxFrameBytes must be an integer between 1 and ${MAX_MAX_FRAME_BYTES}`) + } + return value +} + +function rpcErrorObject(error: unknown): RpcErrorObject { + if (hasNumericCode(error)) { + return { + code: error.code, + message: error instanceof Error ? error.message : String(Reflect.get(error, "message") ?? "JSON-RPC error"), + ...(error.data === undefined ? {} : { data: safeErrorData(error.data) }), + } + } + const value = asError(error) + return { + code: -32603, + message: value.message || "Internal error", + data: { + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + }, + } +} + +function hasNumericCode(value: unknown): value is { code: number; data?: unknown } { + return ( + typeof value === "object" && + value !== null && + typeof Reflect.get(value, "code") === "number" && + Number.isInteger(Reflect.get(value, "code")) + ) +} + +function safeErrorData(value: unknown) { + const parsed = JsonValueSchema.safeParse(value) + if (parsed.success) return parsed.data + return { kind: "invalid_error_data", message: "Thrown JSON-RPC error data was not JSON-compatible" } +} + +function responseID(value: unknown) { + if (typeof value !== "object" || value === null) return undefined + const id = Reflect.get(value, "id") + return typeof id === "string" && id ? id : undefined +} + +function errorDetails(error: unknown) { + const value = asError(error) + return { name: value.name, message: value.message } +} + +function abortError(reason: unknown) { + if (reason instanceof Error) return reason + return new DOMException(typeof reason === "string" ? reason : "The operation was aborted", "AbortError") +} + +function asError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)) +} + +function concatBytes(left: Uint8Array, right: Uint8Array) { + if (left.byteLength === 0) return right.slice() + const result = new Uint8Array(left.byteLength + right.byteLength) + result.set(left) + result.set(right, left.byteLength) + return result +} diff --git a/src/apps/extension-host/src/semver.d.ts b/src/apps/extension-host/src/semver.d.ts new file mode 100644 index 0000000000..406263719d --- /dev/null +++ b/src/apps/extension-host/src/semver.d.ts @@ -0,0 +1,3 @@ +declare module "semver" { + export function satisfies(version: string, range: string): boolean +} diff --git a/src/apps/extension-host/src/service.ts b/src/apps/extension-host/src/service.ts new file mode 100644 index 0000000000..6a4e443df2 --- /dev/null +++ b/src/apps/extension-host/src/service.ts @@ -0,0 +1,102 @@ +import type { RpcConnection } from "./backend" +import { ExtensionHostError } from "./errors" +import { ExtensionHost, type InstanceOpenInput, type PluginsPrepareInput } from "./host" +import { logEvent, setLogLevel } from "./log" +import { HostMethodSchemas, type HostMethod } from "./protocol" +import type { WireValue } from "./wire" + +export type HandlerPeer = RpcConnection & { + handle(method: string, handler: (params: unknown) => unknown | Promise): void +} + +export function registerHostMethods(input: { + peer: HandlerPeer + host: ExtensionHost | Promise + shutdown(): void +}) { + const host = () => Promise.resolve(input.host) + const register = (method: HostMethod, handler: (params: unknown) => unknown | Promise) => { + input.peer.handle(method, async (params) => { + const parsed = HostMethodSchemas[method].params.safeParse(params) + if (!parsed.success) { + throw new ExtensionHostError(-32602, `Invalid parameters for ${method}`, { + kind: "invalid_params", + method, + issues: parsed.error.issues, + }) + } + return HostMethodSchemas[method].result.parse(await handler(parsed.data)) + }) + } + + register("host.plugins.prepare", async (params) => (await host()).prepare(params as PluginsPrepareInput)) + register("host.instance.open", async (params) => (await host()).open(params as InstanceOpenInput)) + register("host.instance.close", async (params) => (await host()).close(params as { instanceID: string })) + register("host.log.setLevel", (params) => { + const value = HostMethodSchemas["host.log.setLevel"].params.parse(params) + return { level: setLogLevel(value.level) } + }) + register("host.hook.call", async (params) => + (await host()).callHook( + (() => { + const value = params as { instanceID: string; hook: string; input: WireValue; output: WireValue } + return { ...value, name: value.hook } + })(), + ), + ) + register("host.event.emit", async (params) => + (await host()).emitEvent(params as { instanceID: string; event: WireValue }), + ) + register("host.tool.execute", async (params) => + (await host()).executeTool(params as Parameters[0]), + ) + register("host.tool.cancel", async (params) => + (await host()).cancelTool(params as Parameters[0]), + ) + register("host.auth.prompt.evaluate", async (params) => + (await host()).evaluateAuthPrompt(params as Parameters[0]), + ) + register("host.auth.authorize", async (params) => + (await host()).authorize(params as Parameters[0]), + ) + register("host.auth.callback", async (params) => + (await host()).authCallback(params as Parameters[0]), + ) + register("host.auth.flow.cancel", async (params) => + (await host()).cancelAuthFlow(params as Parameters[0]), + ) + register("host.auth.loader", async (params) => + (await host()).loadAuth(params as Parameters[0]), + ) + register("host.auth.fetch", async (params) => + (await host()).authFetch(params as Parameters[0]), + ) + register("host.auth.fetch.cancel", async (params) => + (await host()).cancelAuthFetch(params as Parameters[0]), + ) + register("host.auth.fetch.release", async (params) => + (await host()).releaseAuthFetch(params as Parameters[0]), + ) + register("host.provider.models", async (params) => + (await host()).providerModels(params as Parameters[0]), + ) + register("host.workspace.configure", async (params) => + (await host()).workspaceConfigure(params as Parameters[0]), + ) + register("host.workspace.create", async (params) => + (await host()).workspaceCreate(params as Parameters[0]), + ) + register("host.workspace.remove", async (params) => + (await host()).workspaceRemove(params as Parameters[0]), + ) + register("host.workspace.target", async (params) => + (await host()).workspaceTarget(params as Parameters[0]), + ) + register("host.shutdown", async () => { + logEvent("shutdown.requested") + const result = await (await host()).shutdown() + logEvent("shutdown.instances_closed") + setTimeout(() => input.shutdown(), 0) + return result + }) +} diff --git a/src/apps/extension-host/src/stream-methods.ts b/src/apps/extension-host/src/stream-methods.ts new file mode 100644 index 0000000000..d7f2adcb9d --- /dev/null +++ b/src/apps/extension-host/src/stream-methods.ts @@ -0,0 +1,53 @@ +import { ExtensionHostError } from "./errors" +import { HostMethodSchemas } from "./protocol" +import { StreamRegistry } from "./streams" + +type StreamMethodPeer = { + handle(method: string, handler: (value: unknown) => unknown | Promise): unknown +} + +export function registerStreamMethods( + peer: StreamMethodPeer, + registry: StreamRegistry, + owners: Map, +) { + peer.handle("host.stream.read", async (value) => { + const input = parseStreamParams("host.stream.read", value) + requireStreamOwner(owners, input.instanceID, input.streamID) + try { + const result = await registry.read(input) + if (result.eof) owners.delete(input.streamID) + return HostMethodSchemas["host.stream.read"].result.parse(result) + } catch (error) { + owners.delete(input.streamID) + throw error + } + }) + peer.handle("host.stream.cancel", async (value) => { + const input = parseStreamParams("host.stream.cancel", value) + if (!owners.has(input.streamID)) return { cancelled: false } + requireStreamOwner(owners, input.instanceID, input.streamID) + owners.delete(input.streamID) + return HostMethodSchemas["host.stream.cancel"].result.parse(await registry.cancel(input)) + }) +} + +function parseStreamParams(method: "host.stream.read" | "host.stream.cancel", value: unknown) { + const parsed = HostMethodSchemas[method].params.safeParse(value) + if (parsed.success) return parsed.data + throw new ExtensionHostError(-32602, `Invalid parameters for ${method}`, { + kind: "invalid_params", + method, + issues: parsed.error.issues, + }) +} + +function requireStreamOwner(owners: Map, instanceID: string, streamID: string) { + if (owners.get(streamID) === instanceID) return + throw new ExtensionHostError(-32002, `Unknown stream ${streamID} for instance ${instanceID}`, { + kind: "missing_handle", + type: "stream", + id: streamID, + instanceID, + }) +} diff --git a/src/apps/extension-host/src/streams.ts b/src/apps/extension-host/src/streams.ts new file mode 100644 index 0000000000..9438bc067b --- /dev/null +++ b/src/apps/extension-host/src/streams.ts @@ -0,0 +1,175 @@ +import { + MAX_STREAM_CHUNK_BYTES, + StreamReadResultSchema, + type StreamDescriptor, + type StreamReadResult, +} from "./protocol" + +type StreamEntry = { + reader: { + read(): Promise<{ done: boolean; value?: Uint8Array }> + cancel(reason?: unknown): Promise + releaseLock(): void + } + remainder?: Uint8Array + tail: Promise + done: boolean + released: boolean +} + +export type StreamRpcPeer = { + request(method: string, params: unknown): Promise +} + +export class StreamRegistry { + readonly #prefix: string + readonly #streams = new Map() + #sequence = 0 + + constructor(prefix = "host") { + if (!prefix) throw new TypeError("Stream ID prefix must not be empty") + this.#prefix = prefix + } + + get size() { + return this.#streams.size + } + + add(stream: ReadableStream, length?: number): StreamDescriptor { + if (length !== undefined && (!Number.isSafeInteger(length) || length < 0)) { + throw new RangeError("Stream length must be a non-negative safe integer") + } + const streamID = `${this.#prefix}-stream:${++this.#sequence}` + this.#streams.set(streamID, { reader: stream.getReader(), tail: Promise.resolve(), done: false, released: false }) + return { streamID, ...(length === undefined ? {} : { length }) } + } + + register(stream: ReadableStream, length?: number) { + return this.add(stream, length) + } + + async read(input: { streamID: string; maxBytes?: number }): Promise { + const maxBytes = input.maxBytes ?? MAX_STREAM_CHUNK_BYTES + if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_STREAM_CHUNK_BYTES) { + throw new RangeError(`maxBytes must be an integer between 1 and ${MAX_STREAM_CHUNK_BYTES}`) + } + const entry = this.#streams.get(input.streamID) + if (!entry) return { data: "", eof: true } + + return this.#serialized(entry, async () => { + if (entry.done) return { data: "", eof: true } + if (entry.remainder?.byteLength) return this.#take(entry, maxBytes) + + while (true) { + let result: { done: boolean; value?: Uint8Array } + try { + result = await entry.reader.read() + } catch (error) { + entry.done = true + this.#streams.delete(input.streamID) + throw error + } + if (result.done) { + entry.done = true + this.#streams.delete(input.streamID) + this.#release(entry) + return { data: "", eof: true } + } + if (!result.value || result.value.byteLength === 0) continue + entry.remainder = result.value + return this.#take(entry, maxBytes) + } + }) + } + + async cancel(input: { streamID: string; reason?: string }) { + const entry = this.#streams.get(input.streamID) + if (!entry) return { cancelled: false } + this.#streams.delete(input.streamID) + if (entry.done) return { cancelled: false } + entry.done = true + entry.remainder = undefined + try { + await entry.reader.cancel(input.reason) + await entry.tail + } finally { + this.#release(entry) + } + return { cancelled: true } + } + + async cancelAll(reason = "Stream registry closed") { + await Promise.all(Array.from(this.#streams, ([streamID]) => this.cancel({ streamID, reason }))) + } + + #take(entry: StreamEntry, maxBytes: number): StreamReadResult { + const value = entry.remainder! + const data = value.byteLength <= maxBytes ? value : value.subarray(0, maxBytes) + entry.remainder = value.byteLength <= maxBytes ? undefined : value.subarray(maxBytes) + return { data: Buffer.from(data).toString("base64"), eof: false } + } + + #release(entry: StreamEntry) { + if (entry.released) return + entry.released = true + entry.reader.releaseLock() + } + + async #serialized(entry: StreamEntry, operation: () => Promise) { + const previous = entry.tail + const deferred = Promise.withResolvers() + entry.tail = deferred.promise + await previous + try { + return await operation() + } finally { + deferred.resolve() + } + } +} + +export function remoteReadable( + peer: StreamRpcPeer, + methodPrefix: "backend" | "host", + descriptor: StreamDescriptor, + params: Record = {}, +) { + let released = false + return new ReadableStream({ + async pull(controller) { + try { + const result = StreamReadResultSchema.parse( + await peer.request(`${methodPrefix}.stream.read`, { + ...params, + streamID: descriptor.streamID, + maxBytes: MAX_STREAM_CHUNK_BYTES, + }), + ) + if (result.data) controller.enqueue(Buffer.from(result.data, "base64")) + if (!result.eof) return + released = true + controller.close() + } catch (error) { + controller.error(error) + if (released) return + released = true + void peer + .request(`${methodPrefix}.stream.cancel`, { + ...params, + streamID: descriptor.streamID, + reason: error instanceof Error ? error.message : String(error), + }) + .catch(() => {}) + } + }, + async cancel(reason) { + if (released) return + released = true + await peer.request(`${methodPrefix}.stream.cancel`, { + ...params, + streamID: descriptor.streamID, + ...(reason === undefined ? {} : { reason: reason instanceof Error ? reason.message : String(reason) }), + }) + }, + }) +} diff --git a/src/apps/extension-host/src/tool-schema.ts b/src/apps/extension-host/src/tool-schema.ts new file mode 100644 index 0000000000..87b39deee0 --- /dev/null +++ b/src/apps/extension-host/src/tool-schema.ts @@ -0,0 +1,94 @@ +import { z } from "zod" + +export type ToolJsonSchema = boolean | Record + +/** Convert the public plugin tool argument map to the schema sent to Rust. */ +export function toolParametersToJsonSchema(args: unknown): ToolJsonSchema { + const entries = Object.entries(isRecord(args) ? args : {}) + const zodParameters = entries.every((entry) => isZodType(entry[1])) + ? z.object(Object.fromEntries(entries) as z.ZodRawShape) + : undefined + if (!zodParameters) return legacyJsonSchema(entries) + + const result = normalizeZodJsonSchema( + z.toJSONSchema(zodParameters, { io: "input", metadata: zodMetadataRegistry(zodParameters) }), + ) + if (!isRecord(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") + const { $defs, ...rest } = result + return $defs && isRecord($defs) ? { ...rest, definitions: $defs } : rest +} + +export const toolArgsToJsonSchema = toolParametersToJsonSchema + +/** + * Match OpenCode's registry boundary: Zod argument maps parse before execute, + * while legacy JSON Schema maps remain advisory and pass through unchanged. + */ +export function validateToolArguments(argsDefinition: unknown, value: unknown) { + const entries = Object.entries(isRecord(argsDefinition) ? argsDefinition : {}) + if (!entries.every((entry) => isZodType(entry[1]))) return value + return z.object(Object.fromEntries(entries) as z.ZodRawShape).parse(value) +} + +function isZodType(value: unknown): value is z.ZodType { + return typeof value === "object" && value !== null && "_zod" in value +} + +function isJsonSchemaDefinition(value: unknown): value is boolean | Record { + return typeof value === "boolean" || isRecord(value) +} + +function legacyJsonSchema(entries: [string, unknown][]): Record { + const properties = Object.fromEntries( + entries.filter((entry): entry is [string, boolean | Record] => isJsonSchemaDefinition(entry[1])), + ) + return { + type: "object", + properties, + required: Object.keys(properties), + } +} + +function zodMetadataRegistry(schema: z.ZodType) { + const registry = z.registry>() + const seen = new WeakSet() + const collect = (value: unknown) => { + if (typeof value !== "object" || value === null) return + if (seen.has(value)) return + seen.add(value) + + if (isZodType(value)) { + const metadata = typeof value.meta === "function" ? value.meta() : undefined + const description = typeof value.description === "string" ? value.description : undefined + const merged = { + ...(metadata && typeof metadata === "object" ? metadata : {}), + ...(description ? { description } : {}), + } + if (Object.keys(merged).length) registry.add(value, merged) + collect(value._zod.def) + return + } + + for (const item of Object.values(value)) collect(item) + } + collect(schema) + return registry +} + +function normalizeZodJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item)) + if (!isRecord(value)) return value + return Object.fromEntries( + Object.entries(value) + .filter((entry) => + (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean" + ? false + : true, + ) + .map(([key, item]) => [key, normalizeZodJsonSchema(item)]), + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/apps/extension-host/src/wire.ts b/src/apps/extension-host/src/wire.ts new file mode 100644 index 0000000000..0040a2fd60 --- /dev/null +++ b/src/apps/extension-host/src/wire.ts @@ -0,0 +1,79 @@ +export type WireValue = null | boolean | number | string | WireValue[] | { [key: string]: WireValue } + +export class WireValueError extends TypeError { + readonly path: string + + constructor(path: string, message: string) { + super(`Wire value at ${path} ${message}`) + this.name = "WireValueError" + this.path = path + } +} + +/** + * Validate and detach a value before it crosses the RPC boundary. This is + * deliberately stricter than JSON.stringify, which otherwise drops functions + * and undefined values silently and converts non-finite numbers to null. + */ +export function cloneWireValue(value: unknown, path = "$"): WireValue { + return clone(value, path, new Map()) +} + +export function assertWireValue(value: unknown, path = "$"): asserts value is WireValue { + cloneWireValue(value, path) +} + +export function isWireValue(value: unknown): value is WireValue { + try { + cloneWireValue(value) + return true + } catch { + return false + } +} + +function clone(value: unknown, path: string, ancestors: Map): WireValue { + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new WireValueError(path, "cannot contain a non-finite number") + return value + } + if (typeof value === "function") throw new WireValueError(path, "cannot contain a function") + if (typeof value === "bigint") throw new WireValueError(path, "cannot contain a BigInt") + if (typeof value === "undefined") throw new WireValueError(path, "cannot contain undefined") + if (typeof value === "symbol") throw new WireValueError(path, "cannot contain a symbol") + + if (typeof value !== "object") throw new WireValueError(path, `has unsupported type ${typeof value}`) + const previous = ancestors.get(value) + if (previous !== undefined) throw new WireValueError(path, `contains a cycle referencing ${previous}`) + ancestors.set(value, path) + + try { + if (Array.isArray(value)) { + return Array.from(value, (item, index) => + item === undefined ? null : clone(item, `${path}[${index}]`, ancestors), + ) + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new WireValueError(path, `must be a plain object, received ${objectName(value)}`) + } + + return Object.fromEntries( + Object.entries(value) + .filter((entry) => entry[1] !== undefined) + .map(([key, item]) => [key, clone(item, propertyPath(path, key), ancestors)]), + ) + } finally { + ancestors.delete(value) + } +} + +function propertyPath(parent: string, key: string) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]` +} + +function objectName(value: object) { + return Object.prototype.toString.call(value) +} diff --git a/src/apps/extension-host/test/boundary.test.ts b/src/apps/extension-host/test/boundary.test.ts new file mode 100644 index 0000000000..5fbd70bdde --- /dev/null +++ b/src/apps/extension-host/test/boundary.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "node:os" + +const extensionHostDirectory = path.resolve(import.meta.dir, "..") + +describe("standalone package boundary", () => { + test("imports only public OpenCode packages", async () => { + const packageJson = await Bun.file(path.join(extensionHostDirectory, "package.json")).json() + const files = [ + ...new Bun.Glob("src/**/*.ts").scanSync({ cwd: extensionHostDirectory }), + ...new Bun.Glob("script/**/*.ts").scanSync({ cwd: extensionHostDirectory }), + ] + const resolvedImports = ( + await Promise.all( + files.map(async (file) => { + const source = await Bun.file(path.join(extensionHostDirectory, file)).text() + return Array.from( + source.matchAll(/(?:from\s+|import\s*\(\s*|import\s+)["']([^"']+)["']/g), + (match) => match[1], + ).flatMap((specifier) => (specifier ? [{ file, specifier }] : [])) + }), + ) + ).flat() + const invalidOpenCodeImports = resolvedImports.filter( + (item) => + item.specifier.startsWith("@opencode-ai/") && + item.specifier !== "@opencode-ai/plugin" && + item.specifier !== "@opencode-ai/sdk" && + !item.specifier.startsWith("@opencode-ai/sdk/"), + ) + const escapedRelativeImports = resolvedImports.filter( + (item) => + item.specifier.startsWith(".") && + !path + .resolve(path.dirname(path.join(extensionHostDirectory, item.file)), item.specifier) + .startsWith(`${extensionHostDirectory}${path.sep}`), + ) + + expect(invalidOpenCodeImports).toEqual([]) + expect(escapedRelativeImports).toEqual([]) + expect(packageJson.dependencies["@opencode-ai/plugin"]).toBe("1.17.18") + expect(packageJson.dependencies["@opencode-ai/sdk"]).toBe("1.17.18") + expect(Object.values({ ...packageJson.dependencies, ...packageJson.devDependencies })).not.toContain( + expect.stringContaining("workspace:"), + ) + }) + + test("packs, installs, and builds outside the repository", async () => { + const root = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-pack-")) + const archiveDirectory = path.join(root, "archive") + const extractedDirectory = path.join(root, "extracted") + await Promise.all([mkdir(archiveDirectory), mkdir(extractedDirectory)]) + + try { + await command( + [process.execPath, "pm", "pack", "--destination", archiveDirectory, "--ignore-scripts", "--quiet"], + extensionHostDirectory, + ) + const archive = path.join( + archiveDirectory, + (await readdir(archiveDirectory)).find((file) => file.endsWith(".tgz")) ?? "missing.tgz", + ) + expect(await Bun.file(archive).exists()).toBe(true) + await command(["tar", "-xzf", archive, "-C", extractedDirectory], extensionHostDirectory) + + const standaloneDirectory = path.join(extractedDirectory, "package") + expect(await Bun.file(path.join(standaloneDirectory, "protocol.schema.json")).exists()).toBe(true) + await command([process.execPath, "install", "--ignore-scripts"], standaloneDirectory) + await command([process.execPath, "run", "build"], standaloneDirectory) + expect(await Bun.file(path.join(standaloneDirectory, "dist", "extension-host.js")).exists()).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }, 30_000) +}) + +async function command(cmd: string[], cwd: string) { + const child = Bun.spawn({ cmd, cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" }) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (code === 0) return stdout + throw new Error(`${cmd.join(" ")} failed with status ${code}\n${stderr || stdout}`) +} diff --git a/src/apps/extension-host/test/fixtures/gateway/injected.ts b/src/apps/extension-host/test/fixtures/gateway/injected.ts new file mode 100644 index 0000000000..0a423ddc7d --- /dev/null +++ b/src/apps/extension-host/test/fixtures/gateway/injected.ts @@ -0,0 +1,50 @@ +import type { PluginModule } from "@opencode-ai/plugin" + +const server: PluginModule["server"] = async (input) => { + const project = await input.client.project.current() + const raw = await fetch(new URL("/raw?fixture=1", input.serverUrl)).then((response) => response.text()) + const shell = (await input.$`printf injected-shell`.text()).trim() + + input.experimental_workspace.register("fixture-remote", { + name: "Fixture remote", + description: "Workspace registered by the injected API fixture", + configure(config) { + return { + ...config, + name: `${config.name}-configured`, + } + }, + async create() {}, + async remove() {}, + target() { + return { + type: "remote", + url: new URL("https://workspace.example.test/root"), + headers: new Headers([ + ["x-fixture", "yes"], + ["x-second", "two"], + ]), + } + }, + }) + + return { + async config(config) { + Object.assign(config, { + injectedFixture: { + projectID: project.data?.id, + raw, + shell, + serverURL: input.serverUrl.href, + directory: input.directory, + worktree: input.worktree, + }, + }) + }, + } +} + +export default { + id: "gateway-injected-fixture", + server, +} satisfies PluginModule diff --git a/src/apps/extension-host/test/fixtures/loader/legacy.ts b/src/apps/extension-host/test/fixtures/loader/legacy.ts new file mode 100644 index 0000000000..c89bee2757 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/loader/legacy.ts @@ -0,0 +1,4 @@ +const shared = async () => ({ legacy: true }) + +export { shared as named } +export default shared diff --git a/src/apps/extension-host/test/fixtures/loader/preferred.ts b/src/apps/extension-host/test/fixtures/loader/preferred.ts new file mode 100644 index 0000000000..cf74b2b51f --- /dev/null +++ b/src/apps/extension-host/test/fixtures/loader/preferred.ts @@ -0,0 +1,6 @@ +export const ignored = async () => ({ ignored: true }) + +export default { + id: "fixture.preferred", + server: async () => ({ preferred: true }), +} diff --git a/src/apps/extension-host/test/fixtures/runtime/full.js b/src/apps/extension-host/test/fixtures/runtime/full.js new file mode 100644 index 0000000000..e6a975dc95 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/full.js @@ -0,0 +1,152 @@ +import { appendFile } from "node:fs/promises" +import { z } from "zod" + +const moduleToken = crypto.randomUUID() +let entrypointRuns = 0 + +async function record(file, value) { + if (!file) return + await appendFile(file, `${value}\n`) +} + +export default { + id: "fixture.full", + server: async (input, options = {}) => { + entrypointRuns += 1 + const run = entrypointRuns + + input.experimental_workspace.register("fixture-remote", { + name: "Fixture Remote", + description: "Runtime test workspace", + configure: async (config) => ({ ...config, name: `configured:${config.name}` }), + create: async (config, env, from) => { + await record(options.workspaceMarker, `create:${config.id}:${env.FIXTURE ?? "missing"}:${from?.id ?? "none"}`) + }, + remove: async (config) => { + await record(options.workspaceMarker, `remove:${config.id}`) + }, + target: async (config) => ({ + type: "remote", + url: new URL(`https://workspace.example/${config.id}?branch=${config.branch ?? "none"}`), + headers: new Headers({ authorization: "Bearer fixture", "x-workspace": config.id }), + }), + }) + + return { + config: async (config) => { + config.runtime = { moduleToken, run, directory: input.directory } + }, + "chat.message": async (hookInput, output) => { + hookInput.trace ??= [] + output.trace ??= [] + hookInput.trace.push("full") + output.trace.push("full") + }, + dispose: async () => { + await record(options.disposeMarker, `full:${run}`) + }, + tool: { + "fixture.echo": { + description: "Exercise the tool bridge", + args: { + value: z.string().describe("Value to echo"), + waitForAbort: z.boolean().optional(), + }, + execute: async (args, context) => { + context.metadata({ title: `metadata:${args.value}`, metadata: { phase: "before-ask" } }) + await context.ask({ + permission: "fixture.execute", + patterns: [args.value], + always: [], + metadata: { value: args.value }, + }) + if (args.waitForAbort) { + await new Promise((resolve, reject) => { + if (context.abort.aborted) return reject(new Error("fixture aborted")) + context.abort.addEventListener("abort", () => reject(new Error("fixture aborted")), { once: true }) + }) + } + return { + title: `echo:${args.value}`, + output: `${args.value}:${context.directory}:${context.worktree}`, + metadata: { sessionID: context.sessionID, callID: context.callID ?? null }, + attachments: [ + { + type: "file", + mime: "text/plain", + url: "data:text/plain,fixture", + filename: "fixture.txt", + }, + ], + } + }, + }, + }, + auth: { + provider: "fixture-auth", + loader: async (getAuth, provider) => { + const auth = await getAuth() + return { + credential: auth.key, + providerID: provider.id, + fetch: async (request, init) => { + if (new URL(request).pathname === "/wait") { + await new Promise((resolve, reject) => { + if (init?.signal?.aborted) return reject(init.signal.reason) + init?.signal?.addEventListener("abort", () => reject(init.signal.reason), { once: true }) + }) + } + const body = init?.body ? await new Response(init.body).text() : "" + return new Response(`${init?.method ?? "GET"}:${request.toString()}:${body}`, { + status: 201, + headers: { "content-type": "text/plain", "x-fixture-fetch": "yes" }, + }) + }, + } + }, + methods: [ + { + type: "api", + label: "Fixture key", + prompts: [ + { + type: "text", + key: "token", + message: "Token", + validate: (value) => (value.startsWith("ok-") ? undefined : "Token must start with ok-"), + condition: (inputs) => inputs.enabled === "yes", + }, + ], + authorize: async (inputs) => + inputs?.token + ? { type: "success", key: inputs.token, provider: "fixture-auth", metadata: { source: "fixture" } } + : { type: "failed" }, + }, + { + type: "oauth", + label: "Fixture OAuth", + authorize: async () => ({ + url: "https://auth.example/authorize", + instructions: "Paste the fixture code", + method: "code", + callback: async (code) => + code === "good" + ? { type: "success", key: "oauth-key", provider: "fixture-auth", metadata: { code } } + : { type: "failed" }, + }), + }, + ], + }, + provider: { + id: "fixture-provider", + models: async (provider, context) => ({ + "fixture-model": { + id: "fixture-model", + providerID: provider.id, + name: `Fixture ${context.auth?.type ?? "anonymous"}`, + }, + }), + }, + } + }, +} diff --git a/src/apps/extension-host/test/fixtures/runtime/sequence-a.js b/src/apps/extension-host/test/fixtures/runtime/sequence-a.js new file mode 100644 index 0000000000..be7a45856b --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/sequence-a.js @@ -0,0 +1,33 @@ +import { appendFile } from "node:fs/promises" + +async function record(file, value) { + if (!file) return + await appendFile(file, `${value}\n`) +} + +export default { + id: "fixture.sequence-a", + server: async (_input, options = {}) => ({ + config: async (config) => { + config.order ??= [] + config.order.push("a") + if (options.configFails) throw new Error("a config failed") + }, + "chat.message": async (input, output) => { + input.order ??= [] + output.order ??= [] + input.order.push("a") + output.order.push("a") + await record(options.hookMarker, "a") + if (options.hookFails) throw new Error("a hook failed") + }, + event: async () => { + await record(options.eventMarker, "a") + if (options.eventFails) throw new Error("a event failed") + }, + dispose: async () => { + await record(options.disposeMarker, "a") + if (options.disposeFails) throw new Error("a dispose failed") + }, + }), +} diff --git a/src/apps/extension-host/test/fixtures/runtime/sequence-b.js b/src/apps/extension-host/test/fixtures/runtime/sequence-b.js new file mode 100644 index 0000000000..e22490c62f --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/sequence-b.js @@ -0,0 +1,29 @@ +import { appendFile } from "node:fs/promises" + +async function record(file, value) { + if (!file) return + await appendFile(file, `${value}\n`) +} + +export default { + id: "fixture.sequence-b", + server: async (_input, options = {}) => ({ + config: async (config) => { + config.order ??= [] + config.order.push("b") + }, + "chat.message": async (input, output) => { + input.order ??= [] + output.order ??= [] + input.order.push("b") + output.order.push("b") + await record(options.hookMarker, "b") + }, + event: async () => { + await record(options.eventMarker, "b") + }, + dispose: async () => { + await record(options.disposeMarker, "b") + }, + }), +} diff --git a/src/apps/extension-host/test/gateway.test.ts b/src/apps/extension-host/test/gateway.test.ts new file mode 100644 index 0000000000..d95d142ccc --- /dev/null +++ b/src/apps/extension-host/test/gateway.test.ts @@ -0,0 +1,429 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { createConnection } from "node:net" +import path from "node:path" +import { tmpdir } from "node:os" +import type { RpcConnection, StreamBridge, StreamDescriptor } from "../src/backend" +import { createGateway } from "../src/gateway" +import { ExtensionHost } from "../src/host" +import { preparePlugins } from "../src/loader" +import { StreamRegistry, remoteReadable } from "../src/streams" + +const temporaryDirectories: string[] = [] +const noProxy = process.env.NO_PROXY +const noProxyLowercase = process.env.no_proxy + +beforeAll(() => { + process.env.NO_PROXY = [process.env.NO_PROXY, "127.0.0.1", "localhost"].filter(Boolean).join(",") + process.env.no_proxy = [process.env.no_proxy, "127.0.0.1", "localhost"].filter(Boolean).join(",") +}) + +afterAll(async () => { + await Promise.all(temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true }))) + restoreEnvironment("NO_PROXY", noProxy) + restoreEnvironment("no_proxy", noProxyLowercase) +}) + +describe("per-instance HTTP gateway", () => { + test("forwards method, path, headers, and streaming request and response bodies", async () => { + const streams = new TestStreams() + const requests: BackendRequest[] = [] + const rpc = createRpc(async (method, params) => { + expect(method).toBe("backend.http.request") + const request = params as BackendRequest + requests.push(request) + expect(await streams.readHost(request.body)).toBe("request-one-request-two") + return { + status: 207, + headers: [ + ["content-type", "text/plain"], + ["x-backend", "forwarded"], + ], + body: streams.addBackend( + new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from("response-one-")) + controller.enqueue(Buffer.from("response-two")) + controller.close() + }, + }), + 25, + ), + } + }) + const gateway = createGateway({ instanceID: "instance-http", rpc, streams }) + + try { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from("request-one-")) + controller.enqueue(Buffer.from("request-two")) + controller.close() + }, + }) + const response = await fetch(new URL("/api/items?limit=2&tag=a", gateway.url), { + method: "POST", + headers: { + "content-length": "23", + "content-type": "application/octet-stream", + "x-plugin": "fixture", + }, + body, + duplex: "half", + }) + + expect(response.status).toBe(207) + expect(response.headers.get("x-backend")).toBe("forwarded") + expect(await response.text()).toBe("response-one-response-two") + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + instanceID: "instance-http", + method: "POST", + path: "/api/items?limit=2&tag=a", + }) + expect(requests[0]?.requestID).toMatch(/^[0-9a-f-]{36}$/) + expect(new Headers(requests[0]?.headers).get("x-plugin")).toBe("fixture") + expect(requests[0]?.body?.length).toBe(23) + expect(streams.backendReadCount).toBeGreaterThanOrEqual(2) + } finally { + await gateway.close() + } + }) + + test("streams SSE incrementally and cancels the backend body when the client stops reading", async () => { + const streams = new TestStreams() + const next = Promise.withResolvers() + const never = Promise.withResolvers() + const cancelled = Promise.withResolvers() + streams.onBackendCancel = () => cancelled.resolve() + const rpc = createRpc(async () => ({ + status: 200, + headers: [["content-type", "text/event-stream"]], + body: streams.addBackend( + new ReadableStream({ + async pull(controller) { + if (!streams.backendProduced) { + streams.backendProduced = 1 + controller.enqueue(Buffer.from("data: first\n\n")) + return + } + if (streams.backendProduced === 1) { + await next.promise + streams.backendProduced = 2 + controller.enqueue(Buffer.alloc(256 * 1024, 120)) + return + } + await never.promise + }, + }), + ), + })) + const gateway = createGateway({ instanceID: "instance-sse", rpc, streams }) + + try { + const controller = new AbortController() + const response = await fetch(new URL("/event", gateway.url), { signal: controller.signal }) + expect(response.headers.get("content-type")).toBe("text/event-stream") + const reader = response.body!.getReader() + expect(Buffer.from((await reader.read()).value!).toString()).toBe("data: first\n\n") + expect(streams.backendProduced).toBe(1) + controller.abort("fixture finished") + next.resolve() + await Promise.race([ + cancelled.promise, + Bun.sleep(1_000).then(() => { + throw new Error("Backend stream cancellation was not forwarded") + }), + ]) + } finally { + next.resolve() + await gateway.close() + } + }) + + test("rejects WebSocket upgrades without forwarding them to Rust", async () => { + const streams = new TestStreams() + const methods: string[] = [] + const rpc = createRpc(async (method) => { + methods.push(method) + throw new Error("WebSocket request should not be forwarded") + }) + const gateway = createGateway({ instanceID: "instance-websocket", rpc, streams }) + + try { + const response = await rawHttp( + gateway.url, + [ + "GET /socket HTTP/1.1", + `Host: ${gateway.url.host}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "", + "", + ].join("\r\n"), + ) + + expect(response).toContain(" 426 ") + expect(response).toContain("WebSocket forwarding is not supported") + expect(methods).toEqual([]) + } finally { + await gateway.close() + } + }) + + test("releases request bodies when backend forwarding fails", async () => { + const streams = new TestStreams() + const gateway = createGateway({ + instanceID: "instance-failure", + streams, + rpc: createRpc(async () => { + throw new Error("backend unavailable") + }), + }) + + try { + const response = await fetch(new URL("/failure", gateway.url), { method: "POST", body: "request body" }) + expect(response.status).toBe(502) + expect(streams.hostSize).toBe(0) + } finally { + await gateway.close() + } + }) +}) + +describe("plugin injected API", () => { + test("supports the SDK, raw serverUrl, Bun shell, and workspace registration during initialization", async () => { + const directory = await temporaryDirectory() + const streams = new TestStreams() + const paths: string[] = [] + const rpc = createRpc(async (method, params) => { + expect(method).toBe("backend.http.request") + const request = params as BackendRequest + paths.push(request.path) + if (request.path.startsWith("/project/current")) { + return jsonResponse(streams, { + id: "project-injected", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 2 }, + }) + } + if (request.path === "/raw?fixture=1") { + return textResponse(streams, "raw-gateway-ok") + } + throw new Error(`Unexpected gateway path ${request.path}`) + }) + const host = new ExtensionHost({ + rpc, + streams, + cacheDirectory: path.join(directory, "cache"), + gatewayFactory: createGateway, + preparePlugins, + shell: Bun.$, + }) + const fixture = path.join(import.meta.dir, "fixtures/gateway/injected.ts") + + try { + const opened = await host.open({ + instanceID: "instance-injected", + project: { id: "project-injected" }, + directory, + worktree: directory, + config: {}, + plugins: [{ spec: fixture }], + }) + + expect(opened.diagnostics).toEqual([]) + expect(paths).toHaveLength(2) + expect(paths[0]).toBe(`/project/current?directory=${encodeURIComponent(directory)}`) + expect(paths[1]).toBe("/raw?fixture=1") + expect(opened.config).toMatchObject({ + injectedFixture: { + projectID: "project-injected", + raw: "raw-gateway-ok", + shell: "injected-shell", + serverURL: opened.gatewayURL, + directory, + worktree: directory, + }, + }) + expect(opened.workspaces).toEqual([ + expect.objectContaining({ + type: "fixture-remote", + name: "Fixture remote", + description: "Workspace registered by the injected API fixture", + }), + ]) + + const registrationID = opened.workspaces[0]!.registrationID + const config = { + id: "workspace-1", + type: "fixture-remote", + name: "demo", + branch: null, + directory: null, + extra: null, + projectID: "project-injected", + } + expect(await host.workspaceConfigure({ instanceID: opened.instanceID, registrationID, config })).toEqual({ + config: { ...config, name: "demo-configured" }, + }) + expect(await host.workspaceTarget({ instanceID: opened.instanceID, registrationID, config })).toEqual({ + target: { + type: "remote", + url: "https://workspace.example.test/root", + headers: [ + ["x-fixture", "yes"], + ["x-second", "two"], + ], + }, + }) + } finally { + await host.shutdown() + } + }) +}) + +type BackendRequest = { + instanceID: string + requestID: string + method: string + path: string + headers: Array<[string, string]> + body?: StreamDescriptor +} + +class TestStreams implements StreamBridge { + readonly #host = new StreamRegistry("test-host") + readonly #backend = new StreamRegistry("test-backend") + backendReadCount = 0 + backendProduced = 0 + backendCancelReasons: string[] = [] + onBackendCancel?: () => void + + get hostSize() { + return this.#host.size + } + + register(_instanceID: string, stream: ReadableStream, length?: number) { + return this.#host.add(stream, length) + } + + remote(methodPrefix: "backend" | "host", instanceID: string, descriptor: StreamDescriptor) { + expect(methodPrefix).toBe("backend") + return remoteReadable( + { + request: async (method: string, params: unknown) => { + const input = params as { streamID: string; maxBytes?: number; reason?: string } + if (method === "backend.stream.read") { + this.backendReadCount += 1 + return this.#backend.read(input) as Promise + } + if (method === "backend.stream.cancel") { + if (input.reason) this.backendCancelReasons.push(input.reason) + const result = await this.#backend.cancel(input) + this.onBackendCancel?.() + return result as Result + } + throw new Error(`Unexpected stream method ${method}`) + }, + }, + "backend", + descriptor, + { instanceID }, + ) + } + + async cancel(_instanceID: string, descriptor: StreamDescriptor) { + await this.#host.cancel(descriptor) + } + + async cancelAll(_instanceID: string) { + await this.#host.cancelAll() + } + + addBackend(stream: ReadableStream, length?: number) { + return this.#backend.add(stream, length) + } + + async readHost(descriptor?: StreamDescriptor) { + if (!descriptor) return "" + const chunks: Uint8Array[] = [] + while (true) { + const result = await this.#host.read({ streamID: descriptor.streamID }) + if (result.data) chunks.push(Buffer.from(result.data, "base64")) + if (result.eof) return Buffer.concat(chunks).toString() + } + } +} + +function createRpc(request: (method: string, params: unknown) => Promise): RpcConnection { + return { + request(method: string, params: unknown) { + return request(method, params) as Promise + }, + notify() {}, + } +} + +function jsonResponse(streams: TestStreams, value: unknown) { + const body = JSON.stringify(value) + return { + status: 200, + headers: [ + ["content-type", "application/json"], + ["content-length", String(Buffer.byteLength(body))], + ], + body: streams.addBackend(new Blob([body]).stream(), Buffer.byteLength(body)), + } +} + +function textResponse(streams: TestStreams, value: string) { + return { + status: 200, + headers: [ + ["content-type", "text/plain"], + ["content-length", String(Buffer.byteLength(value))], + ], + body: streams.addBackend(new Blob([value]).stream(), Buffer.byteLength(value)), + } +} + +async function temporaryDirectory() { + const directory = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-gateway-")) + temporaryDirectories.push(directory) + return directory +} + +function rawHttp(url: URL, request: string) { + const deferred = Promise.withResolvers() + const chunks: Buffer[] = [] + const socket = createConnection({ host: url.hostname, port: Number(url.port) }) + socket.on("connect", () => socket.write(request)) + socket.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + const response = Buffer.concat(chunks) + const boundary = response.indexOf("\r\n\r\n") + if (boundary < 0) return + const match = response + .subarray(0, boundary) + .toString() + .match(/content-length:\s*(\d+)/i) + if (!match || response.byteLength < boundary + 4 + Number(match[1])) return + socket.destroy() + deferred.resolve(response.toString()) + }) + socket.on("error", deferred.reject) + socket.on("end", () => deferred.resolve(Buffer.concat(chunks).toString())) + return deferred.promise +} + +function restoreEnvironment(key: string, value?: string) { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value +} diff --git a/src/apps/extension-host/test/helpers/process-host.ts b/src/apps/extension-host/test/helpers/process-host.ts new file mode 100644 index 0000000000..16b4ddd96e --- /dev/null +++ b/src/apps/extension-host/test/helpers/process-host.ts @@ -0,0 +1,153 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "node:os" +import { DEFAULT_MAX_FRAME_BYTES, OPENCODE_VERSION, PROTOCOL_VERSION } from "../../src/protocol" +import { RpcPeer } from "../../src/rpc" + +type Handshake = { + token: string + protocolVersion: number + opencodeVersion: string + maxFrameBytes: number +} + +export async function launchExtensionHost( + input: { + token?: string + acceptedToken?: string + maxFrameBytes?: number + logLevel?: string + handshakeGate?: Promise + } = {}, +) { + const root = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-process-")) + const cacheDirectory = path.join(root, "cache") + await mkdir(cacheDirectory, { recursive: true }) + + const accepted = Promise.withResolvers<{ + peer: RpcPeer + write(data: Uint8Array): void + }>() + const handshake = Promise.withResolvers() + const peers = new WeakMap() + const maxFrameBytes = input.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + const peer = new RpcPeer(socket, { idPrefix: "backend" }) + peers.set(socket, peer) + peer.handle("backend.handshake", async (value) => { + const params = value as Handshake + handshake.resolve(params) + if (params.token !== (input.acceptedToken ?? "test-rpc-token")) { + throw Object.assign(new Error("Invalid extension host RPC token"), { + code: -32001, + data: { kind: "authentication_failed" }, + }) + } + await input.handshakeGate + peer.setMaxFrameBytes(maxFrameBytes) + return { protocolVersion: PROTOCOL_VERSION, maxFrameBytes, cacheDirectory } + }) + accepted.resolve({ + peer, + write(data) { + socket.write(data) + }, + }) + }, + data(socket, data) { + peers.get(socket)?.receive(data) + }, + close(socket) { + peers.get(socket)?.end() + }, + error(socket, error) { + peers.get(socket)?.end(error) + }, + }, + }) + const extensionHostDirectory = path.resolve(import.meta.dir, "..", "..") + const child = Bun.spawn({ + cmd: [process.execPath, path.join(extensionHostDirectory, "src", "main.ts")], + cwd: extensionHostDirectory, + env: { + ...process.env, + OPENCODE_EXTENSION_HOST_RPC_ADDRESS: `127.0.0.1:${server.port}`, + OPENCODE_EXTENSION_HOST_RPC_TOKEN: input.token ?? "test-rpc-token", + OPENCODE_EXTENSION_HOST_LOG_LEVEL: input.logLevel ?? "debug", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + + try { + const connection = await deadline(accepted.promise, 5_000, "extension host did not connect") + const seenHandshake = await deadline(handshake.promise, 5_000, "extension host did not handshake") + return { + root, + cacheDirectory, + peer: connection.peer, + write: connection.write, + handshake: seenHandshake, + child, + stdout, + stderr, + waitForExit(timeout = 5_000) { + return deadline(child.exited, timeout, "extension host did not exit") + }, + async cleanup() { + connection.peer.close() + const exited = await Promise.race([child.exited.then(() => true), Bun.sleep(250).then(() => false)]) + if (!exited) child.kill() + await child.exited + server.stop(true) + await rm(root, { recursive: true, force: true }) + }, + } + } catch (error) { + child.kill() + await child.exited + server.stop(true) + await rm(root, { recursive: true, force: true }) + throw error + } +} + +export function rawFrame(payload: string) { + const bytes = new TextEncoder().encode(payload) + const frame = new Uint8Array(bytes.byteLength + 4) + new DataView(frame.buffer).setUint32(0, bytes.byteLength, false) + frame.set(bytes, 4) + return frame +} + +export function oversizedFrameHeader(length: number) { + const frame = new Uint8Array(4) + new DataView(frame.buffer).setUint32(0, length, false) + return frame +} + +export function expectedHandshake(token = "test-rpc-token") { + return { + token, + protocolVersion: PROTOCOL_VERSION, + opencodeVersion: OPENCODE_VERSION, + maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + } +} + +async function deadline(promise: Promise, milliseconds: number, message: string) { + const timeout = Promise.withResolvers() + const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds) + try { + return await Promise.race([promise, timeout.promise]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/apps/extension-host/test/host.test.ts b/src/apps/extension-host/test/host.test.ts new file mode 100644 index 0000000000..54cd370281 --- /dev/null +++ b/src/apps/extension-host/test/host.test.ts @@ -0,0 +1,667 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, mkdir, rm } from "node:fs/promises" +import path from "node:path" +import type { RpcConnection, StreamBridge, StreamDescriptor } from "../src/backend" +import { ExtensionHost } from "../src/host" +import { createGateway } from "../src/gateway" +import { preparePlugins, type LoadPluginsInput, type PreparePluginsResult } from "../src/loader" +import { HostMethodSchemas } from "../src/protocol" +import type { WireValue } from "../src/wire" + +const temporaryDirectories: string[] = [] +const hosts: ExtensionHost[] = [] +const fixtures = path.join(import.meta.dir, "fixtures", "runtime") + +afterEach(async () => { + await Promise.all(hosts.splice(0).map((host) => host.shutdown())) + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +describe("ExtensionHost lifecycle and hooks", () => { + test("shares an in-flight preparation between prepare and instance open", async () => { + const gate = Promise.withResolvers() + let prepareCalls = 0 + const harness = await createHarness(async (input) => { + prepareCalls += 1 + await gate.promise + return preparePlugins(input) + }) + const directory = await projectDirectory(harness.root, "prewarm") + const plugin = path.join(harness.root, "prewarm.ts") + await Bun.write(plugin, 'export default { id: "fixture.prewarm", server: async () => ({}) }\n') + const configurationFingerprint = "fixture-prewarm" + const plugins = [{ spec: plugin }] + + const preparing = harness.host.prepare({ plugins, configurationFingerprint }) + const opening = harness.host.open({ + instanceID: "prewarm", + project: {}, + directory, + worktree: directory, + config: {}, + plugins, + configurationFingerprint, + }) + await waitFor(() => prepareCalls === 1) + expect(prepareCalls).toBe(1) + + gate.resolve() + const [prepared, opened] = await Promise.all([preparing, opening]) + expect(prepared.prepared).toHaveLength(1) + expect(opened.instanceID).toBe("prewarm") + expect(prepareCalls).toBe(1) + }) + + test("isolates config and dispose failures while preserving shared sequential mutations", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + const disposeMarker = path.join(harness.root, "dispose.txt") + const opened = await harness.host.open({ + instanceID: "lifecycle", + project: { id: "project" }, + directory, + worktree: directory, + config: { order: [] }, + plugins: [ + { + spec: path.join(fixtures, "sequence-a.js"), + options: { configFails: true, disposeFails: true, disposeMarker }, + }, + { spec: path.join(fixtures, "sequence-b.js"), options: { disposeMarker } }, + ], + }) + + expect(opened.config).toMatchObject({ order: ["a", "b"] }) + expect(opened.diagnostics).toHaveLength(1) + expect(opened.diagnostics[0]).toMatchObject({ code: "runtime", method: "runtime" }) + expect(opened.hooks).toContain("chat.message") + + const called = await harness.host.callHook({ + instanceID: "lifecycle", + name: "chat.message", + input: { order: [] }, + output: { order: [] }, + }) + expect(called).toEqual({ input: { order: ["a", "b"] }, output: { order: ["a", "b"] } }) + + expect(await harness.host.close({ instanceID: "lifecycle" })).toEqual({ closed: true }) + expect(await Bun.file(disposeMarker).text()).toBe("a\nb\n") + expect(harness.rpc.notifications).toContainEqual( + expect.objectContaining({ + method: "backend.diagnostic.publish", + params: expect.objectContaining({ instanceID: "lifecycle" }), + }), + ) + }) + + test("stops an operational hook at the first failure and dispatches events independently", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + const hookMarker = path.join(harness.root, "hook.txt") + const eventMarker = path.join(harness.root, "event.txt") + await harness.host.open({ + instanceID: "failures", + project: {}, + directory, + worktree: directory, + config: {}, + plugins: [ + { + spec: path.join(fixtures, "sequence-a.js"), + options: { hookFails: true, hookMarker, eventFails: true, eventMarker }, + }, + { spec: path.join(fixtures, "sequence-b.js"), options: { hookMarker, eventMarker } }, + ], + }) + + await expect( + harness.host.callHook({ + instanceID: "failures", + name: "chat.message", + input: { order: [] }, + output: { order: [] }, + }), + ).rejects.toMatchObject({ + code: -32003, + data: expect.objectContaining({ operation: "chat.message" }), + }) + expect(await Bun.file(hookMarker).text()).toBe("a\n") + + expect(harness.host.emitEvent({ instanceID: "failures", event: { type: "fixture" } })).toEqual({ accepted: true }) + await waitFor( + async () => + (await Bun.file(eventMarker) + .text() + .catch(() => "")) === "a\nb\n", + ) + expect(harness.rpc.notifications).toContainEqual( + expect.objectContaining({ + method: "backend.diagnostic.publish", + params: expect.objectContaining({ instanceID: "failures" }), + }), + ) + }) +}) + +describe("ExtensionHost tools", () => { + test("projects schemas and preserves metadata, permission, results, attachments, and cancellation", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + harness.rpc.onRequest("backend.tool.ask", async () => { + await Bun.sleep(5) + return {} + }) + const opened = await openFull(harness, "tools", directory) + const registration = opened.tools.find((tool) => tool.id === "fixture.echo") + expect(registration?.parameters).toMatchObject({ + type: "object", + properties: { value: { type: "string", description: "Value to echo" } }, + required: ["value"], + }) + + const result = await harness.host.executeTool({ + instanceID: "tools", + registrationID: registration!.registrationID, + executionID: "execute-1", + args: { value: "hello" }, + context: { sessionID: "session", messageID: "message", agent: "agent", callID: "call" }, + }) + expect(result).toEqual({ + title: "echo:hello", + output: `hello:${directory}:${directory}`, + metadata: { sessionID: "session", callID: "call" }, + attachments: [{ type: "file", mime: "text/plain", url: "data:text/plain,fixture", filename: "fixture.txt" }], + }) + expect(harness.rpc.notifications).toContainEqual({ + method: "backend.tool.metadata", + params: { + instanceID: "tools", + executionID: "execute-1", + title: "metadata:hello", + metadata: { phase: "before-ask" }, + }, + }) + expect(harness.rpc.requests).toContainEqual( + expect.objectContaining({ + method: "backend.tool.ask", + params: expect.objectContaining({ + instanceID: "tools", + executionID: "execute-1", + permission: "fixture.execute", + patterns: ["hello"], + }), + }), + ) + + const pending = harness.host.executeTool({ + instanceID: "tools", + registrationID: registration!.registrationID, + executionID: "execute-2", + args: { value: "wait", waitForAbort: true }, + context: { sessionID: "session", messageID: "message", agent: "agent" }, + }) + await waitFor(() => harness.rpc.requests.some((request) => request.params.executionID === "execute-2")) + expect(harness.host.cancelTool({ instanceID: "tools", executionID: "execute-2" })).toEqual({ cancelled: true }) + await expect(pending).rejects.toMatchObject({ + code: -32003, + data: expect.objectContaining({ operation: "tool:fixture.echo" }), + }) + }) + + test("keeps legacy function registration metadata JSON-compatible", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "legacy-tool") + const plugin = path.join(harness.root, "legacy-tool.ts") + await Bun.write( + plugin, + `export default async () => ({ + tool: { + legacy: { description: "legacy", args: {}, execute: async () => "ok" }, + }, + })\n`, + ) + const opened = await harness.host.open({ + instanceID: "legacy-tool", + project: {}, + directory, + worktree: directory, + config: {}, + plugins: [{ spec: plugin }], + }) + + expect(opened.tools[0]!.plugin).not.toHaveProperty("id") + expect(HostMethodSchemas["host.instance.open"].result.safeParse(opened).success).toBe(true) + }) +}) + +describe("ExtensionHost auth", () => { + test("keeps auth getters live and supports prompts, API and OAuth authorization, and streaming fetch handles", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + let authRead = 0 + harness.rpc.onRequest("backend.auth.get", () => ({ auth: { type: "api", key: `key-${++authRead}` } })) + const opened = await openFull(harness, "auth", directory) + expect(opened.auth[0]).toMatchObject({ + provider: "fixture-auth", + hasLoader: true, + methods: [ + { type: "api", methodIndex: 0, hasAuthorize: true }, + { type: "oauth", methodIndex: 1, hasAuthorize: true }, + ], + }) + + expect( + harness.host.evaluateAuthPrompt({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 0, + promptIndex: 0, + operation: "validate", + value: "bad", + inputs: {}, + }), + ).toEqual({ operation: "validate", error: "Token must start with ok-" }) + expect( + harness.host.evaluateAuthPrompt({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 0, + promptIndex: 0, + operation: "condition", + inputs: { enabled: "yes" }, + }), + ).toEqual({ operation: "condition", active: true }) + + const first = await harness.host.loadAuth({ + instanceID: "auth", + provider: "fixture-auth", + providerInfo: { id: "fixture-auth" }, + }) + const second = await harness.host.loadAuth({ + instanceID: "auth", + provider: "fixture-auth", + providerInfo: { id: "fixture-auth" }, + }) + expect(first).toMatchObject({ value: { credential: "key-1", providerID: "fixture-auth" } }) + expect(second).toMatchObject({ value: { credential: "key-2", providerID: "fixture-auth" } }) + expect(first.fetchID).not.toBe(second.fetchID) + + expect( + await harness.host.authorize({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 0, + inputs: { token: "ok-secret" }, + }), + ).toEqual({ + type: "api", + result: { type: "success", key: "ok-secret", provider: "fixture-auth", metadata: { source: "fixture" } }, + }) + const oauth = await harness.host.authorize({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 1, + }) + if (oauth.type !== "oauth") throw new Error("Expected an OAuth flow") + if (!oauth.flowID) throw new Error("Expected an OAuth flow handle") + const flowID = oauth.flowID + expect(oauth).toMatchObject({ type: "oauth", method: "code", url: "https://auth.example/authorize" }) + expect(await harness.host.authCallback({ instanceID: "auth", flowID, code: "good" })).toEqual({ + type: "success", + key: "oauth-key", + provider: "fixture-auth", + metadata: { code: "good" }, + }) + expect(harness.host.cancelAuthFlow({ instanceID: "auth", flowID })).toEqual({ cancelled: false }) + + const requestBody = harness.streams.remoteDescriptor("auth", "request-body") + const fetched = await harness.host.authFetch({ + instanceID: "auth", + fetchID: first.fetchID!, + requestID: "fetch-request", + request: { + url: "https://api.example/resource", + method: "POST", + body: requestBody, + }, + }) + expect(fetched).toMatchObject({ status: 201, headers: expect.arrayContaining([["x-fixture-fetch", "yes"]]) }) + expect(await harness.streams.text(fetched.body!)).toBe("POST:https://api.example/resource:request-body") + + const pending = harness.host.authFetch({ + instanceID: "auth", + fetchID: first.fetchID!, + requestID: "fetch-cancel", + request: { url: "https://api.example/wait" }, + }) + expect(harness.host.cancelAuthFetch({ instanceID: "auth", requestID: "fetch-cancel", reason: "stop" })).toEqual({ + cancelled: true, + }) + await expect(pending).rejects.toMatchObject({ code: -32003 }) + expect(harness.host.cancelAuthFetch({ instanceID: "auth", requestID: "fetch-cancel" })).toEqual({ + cancelled: false, + }) + expect(harness.host.releaseAuthFetch({ instanceID: "auth", fetchID: first.fetchID! })).toEqual({ released: true }) + }) +}) + +describe("ExtensionHost providers and workspaces", () => { + test("dispatches provider models and normalizes workspace operations at the wire boundary", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + const workspaceMarker = path.join(harness.root, "workspace.txt") + const opened = await openFull(harness, "adapters", directory, { workspaceMarker }) + expect(opened.providers).toContainEqual(expect.objectContaining({ provider: "fixture-provider", hasModels: true })) + expect( + await harness.host.providerModels({ + instanceID: "adapters", + providerID: "fixture-provider", + provider: { id: "fixture-provider" }, + auth: { type: "api", key: "secret" }, + }), + ).toEqual({ + models: { + "fixture-model": { id: "fixture-model", providerID: "fixture-provider", name: "Fixture api" }, + }, + }) + + const registration = opened.workspaces.find((workspace) => workspace.type === "fixture-remote")! + const config = workspaceConfig("workspace") + expect( + await harness.host.workspaceConfigure({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + }), + ).toEqual({ config: { ...config, name: "configured:workspace" } }) + await harness.host.workspaceCreate({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + env: { FIXTURE: "present", OMITTED: null }, + from: { ...config, id: "source" }, + }) + await harness.host.workspaceRemove({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + }) + expect(await Bun.file(workspaceMarker).text()).toBe("create:workspace:present:source\nremove:workspace\n") + expect( + await harness.host.workspaceTarget({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + }), + ).toEqual({ + target: { + type: "remote", + url: "https://workspace.example/workspace?branch=dev", + headers: [ + ["authorization", "Bearer fixture"], + ["x-workspace", "workspace"], + ], + }, + }) + }) +}) + +describe("ExtensionHost instance isolation", () => { + test("supports independent directories, process-wide module caching, reopen, and one-shot disposal", async () => { + const harness = await createHarness() + const firstDirectory = await projectDirectory(harness.root, "first") + const secondDirectory = await projectDirectory(harness.root, "second") + const marker = path.join(harness.root, "dispose.txt") + const first = await openFull(harness, "first", firstDirectory, { disposeMarker: marker }) + const second = await openFull(harness, "second", secondDirectory, { disposeMarker: marker }) + const firstRuntime = (first.config as { runtime: { moduleToken: string; run: number } }).runtime + const secondRuntime = (second.config as { runtime: { moduleToken: string; run: number } }).runtime + expect(secondRuntime.moduleToken).toBe(firstRuntime.moduleToken) + expect(secondRuntime.run).toBe(firstRuntime.run + 1) + + await expect(openFull(harness, "duplicate", firstDirectory)).rejects.toMatchObject({ code: -32002 }) + expect(await harness.host.close({ instanceID: "first" })).toEqual({ closed: true }) + expect(await harness.host.close({ instanceID: "first" })).toEqual({ closed: false }) + const remaining = await harness.host.callHook({ + instanceID: "second", + name: "chat.message", + input: { trace: [] }, + output: { trace: [] }, + }) + expect(remaining.output).toEqual({ trace: ["full"] }) + + const reopened = await openFull(harness, "reopened", firstDirectory, { disposeMarker: marker }) + const reopenedRuntime = (reopened.config as { runtime: { moduleToken: string; run: number } }).runtime + expect(reopenedRuntime.moduleToken).toBe(firstRuntime.moduleToken) + expect(reopenedRuntime.run).toBe(secondRuntime.run + 1) + expect(await Bun.file(marker).text()).toBe(`full:${firstRuntime.run}\n`) + }) + + test("reserves instance IDs and waits for opening plugins during shutdown", async () => { + const harness = await createHarness() + const firstDirectory = await projectDirectory(harness.root, "first-race") + const secondDirectory = await projectDirectory(harness.root, "second-race") + const plugin = path.join(harness.root, "opening-plugin.ts") + const started = path.join(harness.root, "started.txt") + const disposed = path.join(harness.root, "disposed.txt") + await Bun.write( + plugin, + `export default { + id: "fixture.opening", + server: async (_input, options) => { + await Bun.write(options.started, "started") + await Bun.sleep(30) + return { async dispose() { await Bun.write(options.disposed, "disposed") } } + }, + }\n`, + ) + + const cancelledBeforeReady = harness.host.open({ + instanceID: "cancel-before-ready", + project: {}, + directory: secondDirectory, + worktree: secondDirectory, + config: {}, + plugins: [], + }) + const cancelledResult = cancelledBeforeReady.then( + () => undefined, + (error) => error, + ) + expect(await harness.host.close({ instanceID: "cancel-before-ready" })).toEqual({ closed: true }) + expect(await cancelledResult).toMatchObject({ code: -32004 }) + + const opening = harness.host.open({ + instanceID: "opening", + project: {}, + directory: firstDirectory, + worktree: firstDirectory, + config: {}, + plugins: [{ spec: plugin, options: { started, disposed } }], + }) + await expect( + harness.host.open({ + instanceID: "opening", + project: {}, + directory: secondDirectory, + worktree: secondDirectory, + config: {}, + plugins: [], + }), + ).rejects.toMatchObject({ code: -32002 }) + await waitFor(() => Bun.file(started).exists()) + + const shutdown = harness.host.shutdown() + await expect(opening).rejects.toMatchObject({ code: -32004 }) + await shutdown + expect(await Bun.file(disposed).text()).toBe("disposed") + await expect( + harness.host.open({ + instanceID: "after-shutdown", + project: {}, + directory: secondDirectory, + worktree: secondDirectory, + config: {}, + plugins: [], + }), + ).rejects.toMatchObject({ code: -32004 }) + }) + + test("concurrent closes join one disposer run", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "close-race") + const marker = path.join(harness.root, "close-race.txt") + await openFull(harness, "close-race", directory, { disposeMarker: marker }) + + const first = harness.host.close({ instanceID: "close-race" }) + const second = harness.host.close({ instanceID: "close-race" }) + expect(await Promise.all([first, second])).toEqual([{ closed: true }, { closed: false }]) + expect((await Bun.file(marker).text()).trim().split("\n")).toHaveLength(1) + }) + + test("continues gateway, hook, and registry cleanup when stream cancellation fails", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "cancel-failure") + const marker = path.join(harness.root, "cancel-failure.txt") + await openFull(harness, "cancel-failure", directory, { disposeMarker: marker }) + harness.streams.failCancelAll = true + + expect(await harness.host.close({ instanceID: "cancel-failure" })).toEqual({ closed: true }) + expect(await Bun.file(marker).text()).toBe("full:1\n") + expect(await harness.host.close({ instanceID: "cancel-failure" })).toEqual({ closed: false }) + }) +}) + +class FakeRpc implements RpcConnection { + readonly requests: Array<{ method: string; params: Record }> = [] + readonly notifications: Array<{ method: string; params: unknown }> = [] + readonly #handlers = new Map) => unknown | Promise>() + + onRequest(method: string, handler: (params: Record) => unknown | Promise) { + this.#handlers.set(method, handler) + } + + async request(method: string, params: unknown): Promise { + this.requests.push({ method, params: params as Record }) + const handler = this.#handlers.get(method) + return (handler ? await handler(params as Record) : {}) as Result + } + + notify(method: string, params: unknown) { + this.notifications.push({ method, params }) + } +} + +class FakeStreams implements StreamBridge { + readonly #streams = new Map>() + #counter = 0 + failCancelAll = false + + register(instanceID: string, stream: ReadableStream, length?: number) { + const descriptor = { + streamID: `${instanceID}:stream:${++this.#counter}`, + ...(length === undefined ? {} : { length }), + } + this.#streams.set(descriptor.streamID, stream) + return descriptor + } + + remote(_methodPrefix: "backend" | "host", _instanceID: string, descriptor: StreamDescriptor) { + const stream = this.#streams.get(descriptor.streamID) + if (!stream) throw new Error(`Unknown test stream ${descriptor.streamID}`) + return stream + } + + async cancel(_instanceID: string, descriptor: StreamDescriptor) { + await this.#streams.get(descriptor.streamID)?.cancel() + this.#streams.delete(descriptor.streamID) + } + + async cancelAll(instanceID: string) { + if (this.failCancelAll) throw new Error("cancelAll fixture failure") + await Promise.all( + Array.from(this.#streams) + .filter(([streamID]) => streamID.startsWith(`${instanceID}:`)) + .map(async ([streamID, stream]) => { + await stream.cancel().catch(() => {}) + this.#streams.delete(streamID) + }), + ) + } + + remoteDescriptor(instanceID: string, body: string) { + return this.register(instanceID, new Blob([body]).stream(), body.length) + } + + async text(descriptor: StreamDescriptor) { + const stream = this.#streams.get(descriptor.streamID) + if (!stream) throw new Error(`Unknown test stream ${descriptor.streamID}`) + return new Response(stream).text() + } +} + +async function createHarness( + prepare: (input: LoadPluginsInput) => Promise = preparePlugins, +) { + const root = await temporaryDirectory() + const rpc = new FakeRpc() + const streams = new FakeStreams() + const host = new ExtensionHost({ + rpc, + streams, + cacheDirectory: path.join(root, "cache"), + gatewayFactory: createGateway, + preparePlugins: prepare, + shell: Bun.$, + }) + hosts.push(host) + return { root, rpc, streams, host } +} + +async function openFull( + harness: Awaited>, + instanceID: string, + directory: string, + options: Record = {}, +) { + return harness.host.open({ + instanceID, + project: { id: "project" }, + directory, + worktree: directory, + config: {}, + plugins: [{ spec: path.join(fixtures, "full.js"), options }], + }) +} + +async function projectDirectory(root: string, name: string) { + const directory = path.join(root, name) + await mkdir(directory) + return directory +} + +async function temporaryDirectory() { + const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-runtime-")) + temporaryDirectories.push(directory) + return directory +} + +async function waitFor(predicate: () => boolean | Promise) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await predicate()) return + await Bun.sleep(5) + } + throw new Error("Timed out waiting for runtime fixture") +} + +function workspaceConfig(id: string) { + return { + id, + type: "fixture-remote", + name: id, + branch: "dev", + directory: null, + extra: null, + projectID: "project", + } +} diff --git a/src/apps/extension-host/test/loader.test.ts b/src/apps/extension-host/test/loader.test.ts new file mode 100644 index 0000000000..b80bd4ad13 --- /dev/null +++ b/src/apps/extension-host/test/loader.test.ts @@ -0,0 +1,426 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { z } from "zod" +import { + extractServerEntrypoints, + loadPreparedPlugins, + loadPlugins, + normalizePluginDeclarations, + parseNpmPluginSpecifier, + preparePlugins, +} from "../src/loader" +import { bunAddArguments, installNpmPlugin } from "../src/bun-loader" +import { toolParametersToJsonSchema, validateToolArguments } from "../src/tool-schema" +import { WireValueError, cloneWireValue } from "../src/wire" + +const temporaryDirectories: string[] = [] +const fixtures = path.join(import.meta.dir, "fixtures", "loader") + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +describe("plugin loader", () => { + test("terminates bun add options before an untrusted package spec", () => { + expect(bunAddArguments("-malicious-spec")).toEqual(["add", "--ignore-scripts", "--exact", "--", "-malicious-spec"]) + }) + + test("normalizes relative specs and keeps the final declaration for each identity", async () => { + const directory = await temporaryDirectory() + const plugin = path.join(directory, "plugin.ts") + await Bun.write(plugin, 'export default { id: "fixture.dedupe", server: async () => ({}) }\n') + + const result = await normalizePluginDeclarations([ + { spec: pathToFileURL(plugin).href, options: { order: 1 } }, + { spec: "./plugin.ts", options: { order: 2 }, baseDirectory: directory }, + ]) + + expect(result.diagnostics).toEqual([]) + expect(result.declarations).toHaveLength(1) + expect(result.declarations[0]?.options).toEqual({ order: 2 }) + expect(result.declarations[0]?.resolvedSpec).toBe(pathToFileURL(await realpath(plugin)).href) + }) + + test("prefers the default object-form plugin and exposes entrypoints without executing them", async () => { + const result = await loadPlugins({ + declarations: [path.join(fixtures, "preferred.ts")], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded).toHaveLength(1) + expect(result.loaded[0]?.entrypoints).toHaveLength(1) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.preferred") + expect(result.loaded[0]?.entrypoints[0]?.index).toBe(0) + }) + + test("prepares plugin entrypoints without importing their modules", async () => { + const directory = await temporaryDirectory() + const marker = path.join(directory, "imported.txt") + const plugin = path.join(directory, "plugin.ts") + await Bun.write( + plugin, + `await Bun.write(${JSON.stringify(marker)}, "imported")\nexport default { id: "fixture.prepared", server: async () => ({}) }\n`, + ) + + const prepared = await preparePlugins({ declarations: [plugin], cacheDirectory: directory }) + + expect(prepared.diagnostics).toEqual([]) + expect(prepared.prepared).toHaveLength(1) + expect(await Bun.file(marker).exists()).toBe(false) + + const loaded = await loadPreparedPlugins(prepared) + expect(loaded.loaded[0]?.entrypoints[0]?.id).toBe("fixture.prepared") + expect(await Bun.file(marker).exists()).toBe(true) + }) + + test("deduplicates legacy exports by exported value identity", async () => { + const module = await import(pathToFileURL(path.join(fixtures, "legacy.ts")).href) + const entrypoints = extractServerEntrypoints({ + module, + source: "file", + spec: path.join(fixtures, "legacy.ts"), + }) + + expect(entrypoints).toHaveLength(1) + expect(entrypoints[0]?.index).toBe(0) + }) + + test("resolves package ./server import before default and main", async () => { + const directory = await temporaryDirectory() + await Bun.write( + path.join(directory, "package.json"), + JSON.stringify({ + name: "fixture-entry", + exports: { "./server": { import: "./import.ts", default: "./default.ts" } }, + main: "./main.ts", + }), + ) + await Bun.write( + path.join(directory, "import.ts"), + 'export default { id: "fixture.import", server: async () => ({}) }\n', + ) + await Bun.write(path.join(directory, "default.ts"), 'throw new Error("default entry loaded")\n') + await Bun.write(path.join(directory, "main.ts"), 'throw new Error("main entry loaded")\n') + + const result = await loadPlugins({ + declarations: [directory], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.import") + }) + + test("uses a local directory index when no package manifest exists", async () => { + const directory = await temporaryDirectory() + await Bun.write( + path.join(directory, "index.ts"), + 'export default { id: "fixture.index", server: async () => ({}) }\n', + ) + + const result = await loadPlugins({ + declarations: [directory], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.index") + }) + + test("falls back to package main when no server export exists", async () => { + const directory = await temporaryDirectory() + await Bun.write(path.join(directory, "package.json"), JSON.stringify({ name: "fixture-main", main: "main.ts" })) + await Bun.write( + path.join(directory, "main.ts"), + 'export default { id: "fixture.main", server: async () => ({}) }\n', + ) + + const result = await loadPlugins({ declarations: [directory], cacheDirectory: await temporaryDirectory() }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.main") + }) + + test("isolates import and module-shape failures from successful neighbors", async () => { + const directory = await temporaryDirectory() + await Bun.write(path.join(directory, "bad-import.ts"), 'throw new Error("bad import")\n') + await Bun.write(path.join(directory, "bad-shape.ts"), "export const value = 1\n") + await Bun.write( + path.join(directory, "good.ts"), + 'export default { id: "fixture.good", server: async () => ({}) }\n', + ) + + const result = await loadPlugins({ + declarations: [ + path.join(directory, "bad-import.ts"), + path.join(directory, "bad-shape.ts"), + path.join(directory, "good.ts"), + ], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.loaded.flatMap((plugin) => plugin.entrypoints.map((entrypoint) => entrypoint.id))).toEqual([ + "fixture.good", + ]) + expect(result.diagnostics.map((diagnostic) => diagnostic.stage)).toEqual(["load", "shape"]) + }) + + test("rejects invalid object-form path plugins and TUI/server hybrids", () => { + expect(() => + extractServerEntrypoints({ + module: { default: { server: async () => ({}) } }, + source: "file", + spec: "missing-id.ts", + }), + ).toThrow("must export id") + expect(() => + extractServerEntrypoints({ + module: { default: { id: "fixture.hybrid", server: async () => ({}), tui: async () => ({}) } }, + source: "file", + spec: "hybrid.ts", + }), + ).toThrow("either server() or tui()") + }) + + test("imports candidates concurrently but returns them in declaration order", async () => { + const directory = await temporaryDirectory() + const marker = path.join(directory, "imports.txt") + await Bun.write( + path.join(directory, "slow.ts"), + [ + "await Bun.sleep(20)", + `await Bun.write(${JSON.stringify(marker)}, (await Bun.file(${JSON.stringify(marker)}).text().catch(() => "")) + "slow\\n")`, + 'export default { id: "fixture.slow", server: async () => ({}) }', + ].join("\n"), + ) + await Bun.write( + path.join(directory, "fast.ts"), + [ + `await Bun.write(${JSON.stringify(marker)}, (await Bun.file(${JSON.stringify(marker)}).text().catch(() => "")) + "fast\\n")`, + 'export default { id: "fixture.fast", server: async () => ({}) }', + ].join("\n"), + ) + + const result = await loadPlugins({ + declarations: [path.join(directory, "slow.ts"), path.join(directory, "fast.ts")], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.loaded.flatMap((plugin) => plugin.entrypoints.map((entrypoint) => entrypoint.id))).toEqual([ + "fixture.slow", + "fixture.fast", + ]) + expect(await Bun.file(marker).text()).toBe("fast\nslow\n") + }) + + test("rejects a package entry that escapes through a symlink", async () => { + const directory = await temporaryDirectory() + const plugin = path.join(directory, "plugin") + const outside = path.join(directory, "outside") + await mkdir(plugin) + await mkdir(outside) + await Bun.write( + path.join(plugin, "package.json"), + JSON.stringify({ exports: { "./server": "./escape/server.ts" } }), + ) + await Bun.write( + path.join(outside, "server.ts"), + 'export default { id: "fixture.escape", server: async () => ({}) }\n', + ) + await symlink(outside, path.join(plugin, "escape"), "dir") + + const result = await loadPlugins({ + declarations: [plugin], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.loaded).toEqual([]) + expect(result.diagnostics[0]?.stage).toBe("entry") + expect(result.diagnostics[0]?.message).toContain("outside plugin directory") + }) + + test("checks npm engines against OpenCode 1.17.18 and isolates failures", async () => { + const directory = await temporaryDirectory() + const incompatible = path.join(directory, "incompatible") + const compatible = path.join(directory, "compatible") + await Promise.all([mkdir(incompatible), mkdir(compatible)]) + await Bun.write( + path.join(incompatible, "package.json"), + JSON.stringify({ name: "incompatible", engines: { opencode: ">=2" }, main: "./index.ts" }), + ) + await Bun.write(path.join(incompatible, "index.ts"), "export default { server: async () => ({}) }\n") + await Bun.write( + path.join(compatible, "package.json"), + JSON.stringify({ name: "compatible", engines: { opencode: "^1.17.0" }, main: "./index.ts" }), + ) + await Bun.write(path.join(compatible, "index.ts"), "export default { server: async () => ({}) }\n") + + const result = await loadPlugins({ + declarations: ["incompatible@1.0.0", "compatible@1.0.0"], + cacheDirectory: await temporaryDirectory(), + install: async (input) => (input.packageName === "incompatible" ? incompatible : compatible), + }) + + expect(result.loaded.map((plugin) => plugin.spec)).toEqual(["compatible@1.0.0"]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("compatible") + expect(result.diagnostics).toHaveLength(1) + expect(result.diagnostics[0]?.stage).toBe("compatibility") + }) + + test("parses scoped, alias, tarball, and git npm specs without inventing filesystem package names", () => { + expect(parseNpmPluginSpecifier("@scope/plugin@2.3.4")).toMatchObject({ + packageName: "@scope/plugin", + identity: "@scope/plugin", + }) + expect(parseNpmPluginSpecifier("alias-plugin@npm:@scope/plugin@2.3.4")).toMatchObject({ + packageName: "alias-plugin", + identity: "alias-plugin", + }) + expect(parseNpmPluginSpecifier("https://example.com/plugin.tgz")).toMatchObject({ + packageName: undefined, + type: "remote", + }) + expect(parseNpmPluginSpecifier("github:example/plugin#main")).toMatchObject({ + packageName: undefined, + type: "git", + }) + expect(parseNpmPluginSpecifier("file:./plugin.tgz", "/tmp/extension-host-base").installSpec).toBe( + "file:/tmp/extension-host-base/plugin.tgz", + ) + }) + + test("installs npm packages with lifecycle scripts disabled", async () => { + const directory = await temporaryDirectory() + const source = path.join(directory, "source") + const marker = path.join(directory, "postinstall.txt") + await mkdir(source) + await Bun.write( + path.join(source, "package.json"), + JSON.stringify({ + name: "fixture-install", + version: "1.0.0", + main: "./index.js", + scripts: { postinstall: `bun -e 'Bun.write(${JSON.stringify(marker)}, "ran")'` }, + }), + ) + await Bun.write(path.join(source, "index.js"), "export default { server: async () => ({}) }\n") + + const target = await installNpmPlugin({ + spec: `file:${source}`, + packageName: "fixture-install", + cacheDirectory: path.join(directory, "cache"), + }) + + expect(target.cache).toBe("installed") + expect(await Bun.file(path.join(target.target, "package.json")).exists()).toBe(true) + expect(await Bun.file(marker).exists()).toBe(false) + }) + + test("reuses an installed npm package directory without reinstalling it", async () => { + const directory = await temporaryDirectory() + const cacheDirectory = path.join(directory, "cache") + const spec = "fixture-cache-hit@1.0.0" + const installDirectory = path.join( + cacheDirectory, + "plugins", + `fixture-cache-hit-${Bun.hash(spec).toString(16)}`, + ) + const target = path.join(installDirectory, "node_modules", "fixture-cache-hit") + await mkdir(target, { recursive: true }) + await Bun.write( + path.join(installDirectory, "package.json"), + JSON.stringify({ dependencies: { "fixture-cache-hit": "1.0.0" } }), + ) + await Bun.write(path.join(target, "package.json"), JSON.stringify({ name: "fixture-cache-hit", version: "1.0.0" })) + + const resolved = await installNpmPlugin({ + spec, + packageName: "fixture-cache-hit", + cacheDirectory, + }) + + expect(resolved).toEqual({ target: await realpath(target), cache: "hit" }) + }) +}) + +describe("wire values", () => { + test("clones plain JSON values without retaining aliases", () => { + const child = { value: 1 } + const input = { left: child, right: child } + const cloned = cloneWireValue(input) as { left: { value: number }; right: { value: number } } + + expect(cloned).toEqual(input) + expect(cloned).not.toBe(input) + expect(cloned.left).not.toBe(child) + expect(cloned.left).not.toBe(cloned.right) + }) + + test("matches JSON omission semantics for nested undefined values", () => { + expect(cloneWireValue({ missing: undefined, values: [undefined, 1] })).toEqual({ values: [null, 1] }) + expect(() => cloneWireValue(undefined)).toThrow("Wire value at $ cannot contain undefined") + }) + + test.each([ + [{ auth: { fetch: () => {} } }, "$.auth.fetch", "function"], + [{ count: 1n }, "$.count", "BigInt"], + [{ values: [Number.NaN] }, "$.values[0]", "non-finite"], + ])("rejects unsupported values with their path", (value, location, kind) => { + expect(() => cloneWireValue(value)).toThrow(location) + expect(() => cloneWireValue(value)).toThrow(kind) + }) + + test("reports the source path of a cycle", () => { + const value: { child?: unknown } = {} + value.child = value + + expect(() => cloneWireValue(value)).toThrow("$.child contains a cycle referencing $") + expect(() => cloneWireValue(value)).toThrow(WireValueError) + }) +}) + +describe("plugin tool schemas", () => { + test("converts Zod argument maps and preserves metadata", () => { + const schema = toolParametersToJsonSchema({ + query: z.string().describe("Search query"), + limit: z.number().int().optional(), + }) as Record + + expect(schema.type).toBe("object") + expect(schema.properties).toEqual({ + query: { type: "string", description: "Search query" }, + limit: { type: "integer", minimum: -9007199254740991, maximum: 9007199254740991 }, + }) + expect(schema.required).toEqual(["query"]) + }) + + test("projects legacy definitions and only requires valid schema entries", () => { + expect( + toolParametersToJsonSchema({ + query: { type: "string" }, + enabled: true, + ignored: "not-json-schema", + }), + ).toEqual({ + type: "object", + properties: { query: { type: "string" }, enabled: true }, + required: ["query", "enabled"], + }) + }) + + test("validates Zod argument maps and passes legacy values through", () => { + expect(validateToolArguments({ count: z.number().int() }, { count: 2, ignored: true })).toEqual({ count: 2 }) + expect(() => validateToolArguments({ count: z.number().int() }, { count: 2.5 })).toThrow() + + const legacy = { count: 2.5 } + expect(validateToolArguments({ count: { type: "integer" } }, legacy)).toBe(legacy) + }) +}) + +async function temporaryDirectory() { + const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-")) + temporaryDirectories.push(directory) + return directory +} diff --git a/src/apps/extension-host/test/loopback.test.ts b/src/apps/extension-host/test/loopback.test.ts new file mode 100644 index 0000000000..c045e6a8de --- /dev/null +++ b/src/apps/extension-host/test/loopback.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" +import { requireLoopbackAddress } from "../src/loopback" + +describe("RPC loopback validation", () => { + test.each(["127.0.0.1:1234", "localhost:1234", "[::1]:1234"])("accepts %s", (address) => { + expect(() => requireLoopbackAddress(address)).not.toThrow() + }) + + test.each(["127.0.0.2:1234", "127.evil:1234", "192.168.1.5:1234"])("rejects %s", (address) => { + expect(() => requireLoopbackAddress(address)).toThrow("must be loopback") + }) +}) diff --git a/src/apps/extension-host/test/process.test.ts b/src/apps/extension-host/test/process.test.ts new file mode 100644 index 0000000000..9fb7fa8706 --- /dev/null +++ b/src/apps/extension-host/test/process.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { RpcError } from "../src/rpc" +import { expectedHandshake, launchExtensionHost, oversizedFrameHeader, rawFrame } from "./helpers/process-host" + +type Harness = Awaited> + +const running = new Set() + +afterEach(async () => { + await Promise.all(Array.from(running, (harness) => harness.cleanup())) + running.clear() +}) + +describe("extension host process boundary", () => { + test("authenticates its handshake and shuts down over the control socket", async () => { + const harness = await launch() + expect(harness.handshake).toEqual(expectedHandshake()) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + expect(await harness.stdout).toBe("") + const stderr = await harness.stderr + expect(stderr).toContain('"event":"startup.begin"') + expect(stderr).toContain('"event":"rpc.send"') + expect(stderr).toContain('"method":"backend.handshake"') + expect(stderr).toContain('"event":"rpc.receive"') + expect(stderr).toContain('"method":"host.shutdown"') + expect(stderr).toContain('"event":"shutdown.requested"') + expect(stderr).toContain('"event":"shutdown.instances_closed"') + expect(stderr).toContain('"event":"shutdown.complete"') + expect(stderr).not.toContain("test-rpc-token") + }) + + test("logs the plugin names activated during instance open", async () => { + const harness = await launch() + const directory = path.join(harness.root, "activation-project") + const plugin = path.join(harness.root, "activation-plugin.ts") + await mkdir(directory) + await Bun.write(plugin, "export default async () => ({})\n") + + expect( + await harness.peer.request<{ diagnostics: unknown[] }>("host.instance.open", { + instanceID: "activation-instance", + project: {}, + config: {}, + directory, + worktree: directory, + plugins: [{ spec: plugin }], + }), + ).toMatchObject({ diagnostics: [] }) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + const stderr = await harness.stderr + expect(stderr).toContain('"event":"plugin.activation.begin"') + expect(stderr).toContain('"event":"plugin.activation.completed"') + expect(stderr).toContain('"event":"plugin.activation.complete"') + expect(stderr).toContain(JSON.stringify([plugin])) + }) + + test("filters debug diagnostics when the configured log level is info", async () => { + const harness = await launchExtensionHost({ logLevel: "info" }) + running.add(harness) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + const stderr = await harness.stderr + expect(stderr).toContain('"event":"startup.begin"') + expect(stderr).toContain('"event":"shutdown.complete"') + expect(stderr).not.toContain('"event":"rpc.send"') + expect(stderr).not.toContain('"event":"rpc.receive"') + }) + + test("disables structured diagnostics when the configured log level is off", async () => { + const harness = await launchExtensionHost({ logLevel: "off" }) + running.add(harness) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + expect(await harness.stderr).toBe("") + }) + + test("updates the structured log threshold without restarting the host", async () => { + const harness = await launchExtensionHost({ logLevel: "debug" }) + running.add(harness) + + expect(await harness.peer.request<{ level: string }>("host.log.setLevel", { level: "off" })).toEqual({ + level: "off", + }) + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + const stderr = await harness.stderr + expect(stderr).toContain('"method":"host.log.setLevel"') + expect(stderr).not.toContain('"method":"host.shutdown"') + expect(stderr).not.toContain('"event":"shutdown.complete"') + }) + + test("exits when the Rust peer rejects its handshake token", async () => { + const harness = await launchExtensionHost({ token: "wrong-token", acceptedToken: "expected-token" }) + running.add(harness) + + expect(harness.handshake).toEqual(expectedHandshake("wrong-token")) + expect(await harness.waitForExit()).toBe(1) + expect(await harness.stderr).toContain("Invalid extension host RPC token") + }) + + test("rejects host requests before the handshake completes", async () => { + const gate = Promise.withResolvers() + const harness = await launchExtensionHost({ handshakeGate: gate.promise }) + running.add(harness) + + await expect(harness.peer.request("host.stream.cancel", {})).rejects.toMatchObject({ code: -32601 }) + gate.resolve() + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + }) + + test("returns structured invalid-parameter errors without dropping the connection", async () => { + const harness = await launch() + const error = (await harness.peer + .request("host.instance.close", { wrong: true }) + .catch((value) => value)) as RpcError + + expect(error).toBeInstanceOf(RpcError) + expect(error).toMatchObject({ + code: -32602, + data: { kind: "invalid_params", method: "host.instance.close" }, + }) + expect(await harness.peer.request<{ closed: boolean }>("host.instance.close", { instanceID: "missing" })).toEqual({ + closed: false, + }) + expect( + await harness.peer.request<{ cancelled: boolean }>("host.stream.cancel", { + instanceID: "missing", + streamID: "missing-stream", + }), + ).toEqual({ cancelled: false }) + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + }) + + test("terminates cleanly after a malformed JSON frame", async () => { + const harness = await launch() + await harness.peer.request("host.instance.close", { instanceID: "ready" }) + harness.write(rawFrame("{")) + + expect(await harness.waitForExit()).toBe(1) + expect(await harness.stderr).toContain("Invalid JSON-RPC JSON payload") + }) + + test("rejects an oversized frame from the negotiated limit", async () => { + const harness = await launchExtensionHost({ maxFrameBytes: 64 * 1024 }) + running.add(harness) + await harness.peer.request("host.instance.close", { instanceID: "ready" }) + harness.write(oversizedFrameHeader(64 * 1024 + 1)) + + expect(await harness.waitForExit()).toBe(1) + expect(await harness.stderr).toContain("frame length 65537 exceeds limit 65536") + }) + + test("disposes open instances when the Rust-owned socket reaches EOF", async () => { + const harness = await launch() + const directory = path.join(harness.root, "project") + const marker = path.join(harness.root, "disposed.txt") + const plugin = path.join(harness.root, "dispose-plugin.ts") + await mkdir(directory) + await Bun.write( + plugin, + `export default async (_input, options) => ({ + async dispose() { + await Bun.write(options.marker, "disposed") + }, + })\n`, + ) + const result = await harness.peer.request<{ diagnostics: unknown[] }>("host.instance.open", { + instanceID: "eof-instance", + project: {}, + config: {}, + directory, + worktree: directory, + plugins: [{ spec: plugin, options: { marker } }], + }) + expect(result.diagnostics).toEqual([]) + + harness.peer.close() + expect(await harness.waitForExit()).toBe(0) + expect(await Bun.file(marker).text()).toBe("disposed") + }) + + test("allows concurrent out-of-order opens with reentrant backend HTTP", async () => { + const harness = await launch() + const plugin = path.join(harness.root, "initializing-plugin.ts") + const slowDirectory = path.join(harness.root, "slow") + const fastDirectory = path.join(harness.root, "fast") + await Promise.all([mkdir(slowDirectory), mkdir(fastDirectory)]) + await Bun.write( + plugin, + `export default async (input, options) => { + await Bun.sleep(options.delay) + const response = await fetch(new URL("/initialize?name=" + options.name, input.serverUrl)) + return { + config(config) { + config.initialized = { name: options.name, status: response.status } + }, + } + }\n`, + ) + const forwarded: string[] = [] + harness.peer.handle("backend.http.request", (value) => { + forwarded.push((value as { path: string }).path) + return { status: 204, headers: [] } + }) + + const slow = harness.peer.request("host.instance.open", { + instanceID: "slow-instance", + project: {}, + config: {}, + directory: slowDirectory, + worktree: slowDirectory, + plugins: [{ spec: plugin, options: { delay: 150, name: "slow" } }], + }) + await Bun.sleep(10) + const fast = harness.peer.request("host.instance.open", { + instanceID: "fast-instance", + project: {}, + config: {}, + directory: fastDirectory, + worktree: fastDirectory, + plugins: [{ spec: plugin, options: { delay: 0, name: "fast" } }], + }) + + expect(await Promise.race([slow.then(() => "slow"), fast.then(() => "fast")])).toBe("fast") + expect((await fast).config).toEqual({ initialized: { name: "fast", status: 204 } }) + expect((await slow).config).toEqual({ initialized: { name: "slow", status: 204 } }) + expect(forwarded).toEqual(["/initialize?name=fast", "/initialize?name=slow"]) + + await Promise.all([ + harness.peer.request("host.instance.close", { instanceID: "slow-instance" }), + harness.peer.request("host.instance.close", { instanceID: "fast-instance" }), + ]) + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + }, 10_000) +}) + +type OpenResult = { + config: Record +} + +async function launch() { + const harness = await launchExtensionHost() + running.add(harness) + return harness +} diff --git a/src/apps/extension-host/test/rpc.test.ts b/src/apps/extension-host/test/rpc.test.ts new file mode 100644 index 0000000000..128d7d13ae --- /dev/null +++ b/src/apps/extension-host/test/rpc.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, test } from "bun:test" +import { RpcError, RpcPeer, RpcProtocolError, connectRpcPeer, encodeFrame } from "../src/rpc" +import { HeaderSchema, MAX_MAX_FRAME_BYTES, MAX_STREAM_CHUNK_BYTES } from "../src/protocol" +import { StreamRegistry, remoteReadable } from "../src/streams" +import { registerStreamMethods } from "../src/stream-methods" + +describe("protocol schema", () => { + test("keeps header pairs exactly two strings at runtime and in generated JSON Schema", async () => { + expect(HeaderSchema.safeParse(["name", "value"]).success).toBe(true) + expect(HeaderSchema.safeParse(["name"]).success).toBe(false) + expect(HeaderSchema.safeParse(["name", "value", "extra"]).success).toBe(false) + + const schema = await Bun.file(new URL("../protocol.schema.json", import.meta.url)).json() + expect(schema.$defs.BackendHttpRequestParams.properties.headers.items).toMatchObject({ + minItems: 2, + maxItems: 2, + type: "array", + items: { type: "string" }, + }) + }) +}) + +describe("RpcPeer", () => { + test("frames JSON with a four-byte big-endian length", () => { + const frame = encodeFrame({ jsonrpc: "2.0", method: "ping", params: {} }) + expect(new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false)).toBe(frame.byteLength - 4) + expect(JSON.parse(new TextDecoder().decode(frame.subarray(4)))).toEqual({ + jsonrpc: "2.0", + method: "ping", + params: {}, + }) + }) + + test("supports concurrent, out-of-order, and reentrant requests", async () => { + const { host, backend } = peerPair() + host.handle("host.decorate", ({ value }: { value: string }) => ({ value: `${value}:host` })) + backend.handle("backend.work", async ({ value, wait }: { value: string; wait: boolean }) => { + if (wait) await new Promise((resolve) => setTimeout(resolve, 10)) + return hostResult(await backend.request<{ value: string }>("host.decorate", { value })) + }) + + const slow = host.request<{ value: string }>("backend.work", { value: "slow", wait: true }) + const fast = host.request<{ value: string }>("backend.work", { value: "fast", wait: false }) + expect(await fast).toEqual({ value: "fast:host:backend" }) + expect(await slow).toEqual({ value: "slow:host:backend" }) + host.close() + backend.close() + }) + + test("parses frames split at arbitrary byte boundaries", async () => { + let right: RpcPeer + const left = new RpcPeer({ write: (data) => deliver(right, data, 1) }, { idPrefix: "host" }) + right = new RpcPeer({ write: (data) => deliver(left, data, 2) }, { idPrefix: "backend" }) + right.handle("backend.echo", (params) => params) + expect( + await left.request<{ unicode: string; list: number[] }>("backend.echo", { unicode: "你好", list: [1, 2, 3] }), + ).toEqual({ + unicode: "你好", + list: [1, 2, 3], + }) + }) + + test("preserves numeric error code and JSON-compatible data", async () => { + const { host, backend } = peerPair() + backend.handle("backend.fail", () => { + throw Object.assign(new Error("not ready"), { code: -32042, data: { kind: "not_ready", retry: true } }) + }) + const error = (await host.request("backend.fail").catch((value) => value)) as RpcError + expect(error).toBeInstanceOf(RpcError) + expect(error).toMatchObject({ code: -32042, message: "not ready", data: { kind: "not_ready", retry: true } }) + }) + + test("serializes unexpected errors as internal errors with diagnostics", async () => { + const { host, backend } = peerPair() + backend.handle("backend.fail", () => { + throw new TypeError("broken plugin") + }) + const error = (await host.request("backend.fail").catch((value) => value)) as RpcError + expect(error).toBeInstanceOf(RpcError) + expect(error.code).toBe(-32603) + expect(error.data).toMatchObject({ name: "TypeError", message: "broken plugin" }) + expect((error.data as { stack: string }).stack).toContain("broken plugin") + }) + + test("returns method-not-found without closing the connection", async () => { + const { host, backend } = peerPair() + const error = await host.request("backend.missing").catch((value) => value) + expect(error).toMatchObject({ code: -32601 }) + backend.handle("backend.ok", () => "ok") + expect(await host.request("backend.ok")).toBe("ok") + }) + + test("returns a compact error for an oversized response and keeps the connection open", async () => { + const { host, backend } = peerPair({ maxFrameBytes: 256 }) + backend.handle("backend.large", () => ({ value: "x".repeat(1_024) })) + backend.handle("backend.ok", () => "ok") + + const error = await host.request("backend.large").catch((value) => value) + + expect(error).toMatchObject({ code: -32000, data: { kind: "response_too_large", maxFrameBytes: 256 } }) + expect(await host.request("backend.ok")).toBe("ok") + }) + + test("rejects oversized frames before waiting for their payload", async () => { + const errors: Error[] = [] + const peer = new RpcPeer( + { write() {}, terminate() {} }, + { idPrefix: "host", maxFrameBytes: 32, onError: (error) => errors.push(error) }, + ) + const header = new Uint8Array(4) + new DataView(header.buffer).setUint32(0, 33, false) + peer.receive(header) + await peer.closed + expect(peer.closeError).toBeInstanceOf(RpcProtocolError) + expect(errors).toHaveLength(1) + }) + + test("rejects malformed JSON and calls the EOF callback once", async () => { + let eof = 0 + const peer = new RpcPeer( + { write() {}, terminate() {} }, + { idPrefix: "host", onEof: () => void eof++, onError() {} }, + ) + const payload = new TextEncoder().encode("{") + const frame = new Uint8Array(5) + new DataView(frame.buffer).setUint32(0, 1, false) + frame.set(payload, 4) + peer.receive(frame) + peer.end() + await peer.closed + await Promise.resolve() + expect(peer.closeError).toMatchObject({ code: -32700 }) + expect(eof).toBe(1) + }) + + test("rejects pending requests when the transport reaches EOF", async () => { + const peer = new RpcPeer({ write() {} }, { idPrefix: "host" }) + const request = peer.request("backend.never") + peer.end() + expect(((await request.catch((error) => error)) as Error).name).toBe("RpcConnectionClosedError") + }) + + test("validates negotiated and outbound frame limits", () => { + const peer = new RpcPeer({ write() {} }, { idPrefix: "host" }) + expect(() => peer.setMaxFrameBytes(MAX_MAX_FRAME_BYTES + 1)).toThrow(RangeError) + expect(() => encodeFrame({ value: "too large" }, 4)).toThrow(RangeError) + expect(() => encodeFrame({ value: Number.NaN })).toThrow(TypeError) + }) + + test("connects over a real Bun TCP socket", async () => { + const accepted = Promise.withResolvers() + const peers = new WeakMap() + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + const peer = new RpcPeer(socket, { idPrefix: "backend" }) + peers.set(socket, peer) + peer.handle("backend.ping", ({ value }: { value: number }) => ({ value })) + accepted.resolve(peer) + }, + data(socket, data) { + peers.get(socket)?.receive(data) + }, + close(socket) { + peers.get(socket)?.end() + }, + error(socket, error) { + peers.get(socket)?.end(error) + }, + }, + }) + const client = await connectRpcPeer(`127.0.0.1:${server.port}`) + const backend = await accepted.promise + expect(await client.request<{ value: number }>("backend.ping", { value: 42 })).toEqual({ value: 42 }) + client.close() + backend.close() + server.stop(true) + }) +}) + +describe("stream method ownership", () => { + test("removes the owner mapping when a stream read fails", async () => { + const handlers = new Map unknown | Promise>() + const registry = new StreamRegistry("host") + const owners = new Map() + registerStreamMethods({ handle: (method, handler) => handlers.set(method, handler) }, registry, owners) + const descriptor = registry.register( + new ReadableStream({ + pull(controller) { + controller.error(new Error("stream failed")) + }, + }), + ) + owners.set(descriptor.streamID, "instance") + + await expect(handlers.get("host.stream.read")!({ + instanceID: "instance", + streamID: descriptor.streamID, + maxBytes: 1, + })).rejects.toThrow("stream failed") + expect(owners.has(descriptor.streamID)).toBe(false) + }) +}) + +describe("StreamRegistry", () => { + test("pulls base64 chunks no larger than 64 KiB and releases at EOF", async () => { + const registry = new StreamRegistry() + const bytes = new Uint8Array(MAX_STREAM_CHUNK_BYTES + 7).map((_, index) => index % 251) + const descriptor = registry.add( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }), + bytes.byteLength, + ) + const first = await registry.read({ streamID: descriptor.streamID }) + const second = await registry.read({ streamID: descriptor.streamID }) + const third = await registry.read({ streamID: descriptor.streamID }) + expect(Buffer.from(first.data, "base64").byteLength).toBe(MAX_STREAM_CHUNK_BYTES) + expect(Buffer.from(second.data, "base64").byteLength).toBe(7) + expect(third).toEqual({ data: "", eof: true }) + expect(registry.size).toBe(0) + }) + + test("cancels registered readers idempotently", async () => { + let reason: unknown + const registry = new StreamRegistry() + const descriptor = registry.add( + new ReadableStream({ + cancel(value) { + reason = value + }, + }), + ) + expect(await registry.cancel({ streamID: descriptor.streamID, reason: "closed" })).toEqual({ cancelled: true }) + expect(await registry.cancel({ streamID: descriptor.streamID })).toEqual({ cancelled: false }) + expect(reason).toBe("closed") + }) + + test("cancellation interrupts a pending read", async () => { + const registry = new StreamRegistry() + const descriptor = registry.add(new ReadableStream({ pull() {} })) + const read = registry.read({ streamID: descriptor.streamID }) + await Promise.resolve() + expect(await registry.cancel({ streamID: descriptor.streamID, reason: "stop" })).toEqual({ cancelled: true }) + expect(await read).toEqual({ data: "", eof: true }) + }) + + test("turns a remote descriptor into a pull-based ReadableStream", async () => { + const registry = new StreamRegistry("backend") + const descriptor = registry.add(new Blob(["hello world"]).stream() as ReadableStream) + const calls: string[] = [] + const stream = remoteReadable( + { + async request(method: string, params: unknown) { + calls.push(method) + const input = params as { streamID: string; maxBytes?: number } + if (method.endsWith(".read")) return (await registry.read(input)) as Result + return (await registry.cancel(input)) as Result + }, + }, + "backend", + descriptor, + { instanceID: "instance-1" }, + ) + expect(await new Response(stream).text()).toBe("hello world") + expect(calls).toEqual(["backend.stream.read", "backend.stream.read"]) + }) +}) + +function peerPair(options: { maxFrameBytes?: number } = {}) { + let host: RpcPeer + let backend: RpcPeer + host = new RpcPeer( + { write: (data) => deliver(backend, data, 7) }, + { idPrefix: "host", maxFrameBytes: options.maxFrameBytes }, + ) + backend = new RpcPeer( + { write: (data) => deliver(host, data, 11) }, + { idPrefix: "backend", maxFrameBytes: options.maxFrameBytes }, + ) + return { host, backend } +} + +async function deliver(peer: RpcPeer, data: Uint8Array, size: number) { + for (let offset = 0; offset < data.byteLength; offset += size) { + peer.receive(data.subarray(offset, Math.min(offset + size, data.byteLength))) + await Promise.resolve() + } +} + +function hostResult(input: { value: string }) { + return { value: `${input.value}:backend` } +} diff --git a/src/apps/extension-host/tsconfig.json b/src/apps/extension-host/tsconfig.json new file mode 100644 index 0000000000..4c0340f9b5 --- /dev/null +++ b/src/apps/extension-host/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "script/**/*.ts", "test/**/*.ts"] +} diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index 1599f9a128..e7fe0b1c85 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-relay-server" -version = "0.2.18" # x-release-please-version +version = "0.2.19" # x-release-please-version authors = ["BitFun Team"] edition = "2021" description = "BitFun standalone Remote Connect relay server" diff --git a/src/apps/sdk-host/Cargo.toml b/src/apps/sdk-host/Cargo.toml index 220ba0f1d3..f81fd94606 100644 --- a/src/apps/sdk-host/Cargo.toml +++ b/src/apps/sdk-host/Cargo.toml @@ -26,8 +26,6 @@ bitfun-core = { path = "../../crates/assembly/core", features = [ "tools-browser-web", "tools-computer-use", "tools-image-analysis", - "tools-miniapp", - "tools-canvas", "tools-agent-control", ] } bitfun-sdk-host = { path = "../../crates/interfaces/sdk-host" } @@ -42,6 +40,7 @@ tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi tokio-util = { workspace = true, features = ["codec"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +url = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/src/apps/sdk-host/src/main.rs b/src/apps/sdk-host/src/main.rs index 3e02d28870..e993af5117 100644 --- a/src/apps/sdk-host/src/main.rs +++ b/src/apps/sdk-host/src/main.rs @@ -1,6 +1,8 @@ +mod model_source; mod runtime; use anyhow::{Context, Result}; +use std::sync::Arc; async fn run_host() -> Result<()> { tracing_subscriber::fmt() @@ -20,6 +22,10 @@ async fn run_host() -> Result<()> { bitfun_core::infrastructure::ai::AIClientFactory::initialize_global() .await .context("Failed to initialize global AI client factory")?; + let config_service = bitfun_core::service::config::get_global_config_service().await?; + let installer = Arc::new(model_source::ConfigTemporaryModelInstaller::new( + config_service, + )); let host = runtime::SdkHostRuntime::build(&workspace_root) .await @@ -27,6 +33,7 @@ async fn run_host() -> Result<()> { bitfun_sdk_host_app::transport::serve_stdio( host.agent_runtime().clone(), host.workspace_root().to_string_lossy().into_owned(), + installer, ) .await .context("Agent SDK Host transport failed") diff --git a/src/apps/sdk-host/src/model_source.rs b/src/apps/sdk-host/src/model_source.rs new file mode 100644 index 0000000000..d6bca74eae --- /dev/null +++ b/src/apps/sdk-host/src/model_source.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use bitfun_core::infrastructure::ai::AIClientFactory; +use bitfun_core::service::config::{ + model_runtime_binding_fingerprint, AIModelConfig, ConfigService, ModelCapability, ModelCategory, +}; +use bitfun_sdk_host::host::{TemporaryModelInstallError, TemporaryModelInstaller}; +use bitfun_sdk_host::protocol::{TemporaryModelConfig, TemporaryModelProvider}; + +pub(crate) struct ConfigTemporaryModelInstaller { + config: Arc, +} + +impl ConfigTemporaryModelInstaller { + pub(crate) fn new(config: Arc) -> Self { + Self { config } + } +} + +fn resolve_temporary_model( + model: TemporaryModelConfig, +) -> Result { + let model_name = model.model.trim().to_string(); + if model_name.is_empty() || model.api_key.trim().is_empty() { + return Err(TemporaryModelInstallError::InvalidModel); + } + + let (provider_id, default_base_url) = match model.provider { + TemporaryModelProvider::Openai => ("openai", "https://api.openai.com/v1"), + TemporaryModelProvider::Responses => ("responses", "https://api.openai.com/v1"), + TemporaryModelProvider::Anthropic => ("anthropic", "https://api.anthropic.com"), + TemporaryModelProvider::Gemini => { + ("gemini", "https://generativelanguage.googleapis.com/v1beta") + } + }; + let base_url = model + .base_url + .unwrap_or_else(|| default_base_url.to_string()); + let parsed = + url::Url::parse(&base_url).map_err(|_| TemporaryModelInstallError::InvalidBaseUrl)?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(TemporaryModelInstallError::InvalidBaseUrl); + } + + let mut config = AIModelConfig { + id: String::new(), + name: model_name.clone(), + provider: provider_id.to_string(), + model_name, + base_url, + api_key: model.api_key, + enabled: true, + category: ModelCategory::GeneralChat, + capabilities: vec![ModelCapability::TextChat], + ..AIModelConfig::default() + }; + let fingerprint = model_runtime_binding_fingerprint(&config); + config.id = format!("sdk:{provider_id}:{}", &fingerprint[..24]); + Ok(config) +} + +#[async_trait::async_trait] +impl TemporaryModelInstaller for ConfigTemporaryModelInstaller { + async fn install( + &self, + model: TemporaryModelConfig, + ) -> Result { + let config = resolve_temporary_model(model)?; + let model_id = config.id.clone(); + self.config + .install_runtime_ai_model(config) + .await + .map_err(|_| TemporaryModelInstallError::Internal)?; + Ok(model_id) + } + + async fn remove(&self, model_id: &str) { + self.config.remove_runtime_ai_model(model_id).await; + if let Ok(factory) = AIClientFactory::get_global().await { + factory.invalidate_model(model_id); + } + } +} + +#[cfg(test)] +mod tests { + use bitfun_core::service::config::{ModelCapability, ModelCategory}; + use bitfun_sdk_host::host::TemporaryModelInstallError; + use bitfun_sdk_host::protocol::{TemporaryModelConfig, TemporaryModelProvider}; + + use super::resolve_temporary_model; + + fn temporary_model( + provider: TemporaryModelProvider, + api_key: &str, + base_url: Option<&str>, + ) -> TemporaryModelConfig { + TemporaryModelConfig { + provider, + model: "fixture-model".to_string(), + api_key: api_key.to_string(), + base_url: base_url.map(str::to_string), + } + } + + #[test] + fn provider_defaults_and_minimal_model_fields_are_resolved() { + let cases = [ + ( + TemporaryModelProvider::Openai, + "openai", + "https://api.openai.com/v1", + ), + ( + TemporaryModelProvider::Responses, + "responses", + "https://api.openai.com/v1", + ), + ( + TemporaryModelProvider::Anthropic, + "anthropic", + "https://api.anthropic.com", + ), + ( + TemporaryModelProvider::Gemini, + "gemini", + "https://generativelanguage.googleapis.com/v1beta", + ), + ]; + + for (provider, provider_id, default_url) in cases { + let model = + resolve_temporary_model(temporary_model(provider, "fixture-secret", None)).unwrap(); + assert!(model.id.starts_with(&format!("sdk:{provider_id}:"))); + assert_eq!(model.id.len(), "sdk::".len() + provider_id.len() + 24); + assert_eq!(model.name, "fixture-model"); + assert_eq!(model.provider, provider_id); + assert_eq!(model.model_name, "fixture-model"); + assert_eq!(model.base_url, default_url); + assert_eq!(model.api_key, "fixture-secret"); + assert!(model.enabled); + assert!(matches!(model.category, ModelCategory::GeneralChat)); + assert_eq!(model.capabilities, vec![ModelCapability::TextChat]); + assert!(model.request_url.is_none()); + assert!(model.context_window.is_none()); + assert!(model.custom_headers.is_none()); + assert!(model.custom_request_body.is_none()); + } + } + + #[test] + fn model_id_is_deterministic_across_api_key_rotation() { + let first = resolve_temporary_model(temporary_model( + TemporaryModelProvider::Openai, + "fixture-secret-one", + Some("http://127.0.0.1:43123/v1"), + )) + .unwrap(); + let second = resolve_temporary_model(temporary_model( + TemporaryModelProvider::Openai, + "fixture-secret-two", + Some("http://127.0.0.1:43123/v1"), + )) + .unwrap(); + + assert_eq!(first.id, second.id); + assert!(!first.id.contains("fixture-secret")); + } + + #[test] + fn invalid_model_values_fail_without_echoing_input() { + for base_url in [ + "not-a-url", + "ftp://example.com/v1", + "https://user:password@example.com/v1", + "https://example.com/v1?secret=value", + "https://example.com/v1#fragment", + ] { + assert!(matches!( + resolve_temporary_model(temporary_model( + TemporaryModelProvider::Openai, + "fixture-secret", + Some(base_url), + )), + Err(TemporaryModelInstallError::InvalidBaseUrl) + )); + } + + for (model, api_key) in [("", "fixture-secret"), ("fixture-model", " ")] { + let mut temporary = temporary_model( + TemporaryModelProvider::Openai, + api_key, + Some("https://example.com/v1"), + ); + temporary.model = model.to_string(); + assert!(matches!( + resolve_temporary_model(temporary), + Err(TemporaryModelInstallError::InvalidModel) + )); + } + } +} diff --git a/src/apps/sdk-host/src/transport.rs b/src/apps/sdk-host/src/transport.rs index cb1002d48a..cc23f929a8 100644 --- a/src/apps/sdk-host/src/transport.rs +++ b/src/apps/sdk-host/src/transport.rs @@ -13,10 +13,12 @@ use tokio::time::{timeout, Instant}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; use tokio_util::sync::CancellationToken; -use bitfun_sdk_host::host::{ConnectionControl, HostOutput, SdkHostConfig, SdkHostConnection}; +use bitfun_sdk_host::host::{ + ConnectionControl, HostOutput, SdkHostConfig, SdkHostConnection, TemporaryModelInstaller, +}; use bitfun_sdk_host::protocol::{ - JsonRpcErrorResponse, JsonRpcRequest, RequestId, METHOD_INITIALIZE, METHOD_QUERY_CANCEL, - METHOD_SESSION_CLOSE, METHOD_SHUTDOWN, + JsonRpcErrorResponse, JsonRpcRequest, RequestId, METHOD_INITIALIZE, METHOD_PERMISSION_RESPOND, + METHOD_QUERY_CANCEL, METHOD_SESSION_CLOSE, METHOD_SHUTDOWN, }; #[derive(Debug, Clone)] @@ -91,6 +93,7 @@ where pub async fn serve_streams( runtime: AgentRuntime, default_cwd: impl Into, + temporary_model_installer: Arc, reader: Reader, writer: Writer, config: SdkHostTransportConfig, @@ -108,8 +111,13 @@ where config.max_output_line_bytes, )); let output_failed = output.failure_token(); - let connection = - SdkHostConnection::with_output(runtime, default_cwd, output.clone(), config.host); + let connection = SdkHostConnection::with_output( + runtime, + default_cwd, + output.clone(), + config.host, + temporary_model_installer, + ); let connection_failed = connection.connection_failed_token(); let mut lines = FramedRead::new( reader, @@ -270,7 +278,7 @@ where let is_control_request = matches!( request.method.as_str(), - METHOD_QUERY_CANCEL | METHOD_SESSION_CLOSE + METHOD_PERMISSION_RESPOND | METHOD_QUERY_CANCEL | METHOD_SESSION_CLOSE ); let request_set = if is_control_request { &mut control_requests @@ -372,10 +380,12 @@ async fn drain_requests(requests: &mut JoinSet, drain_timeout pub async fn serve_stdio( runtime: AgentRuntime, default_cwd: impl Into, + temporary_model_installer: Arc, ) -> Result<(), std::io::Error> { serve_streams( runtime, default_cwd, + temporary_model_installer, tokio::io::stdin(), tokio::io::stdout(), SdkHostTransportConfig::default(), diff --git a/src/apps/sdk-host/tests/stdio_process.rs b/src/apps/sdk-host/tests/stdio_process.rs index 9130c157ea..329d38f29b 100644 --- a/src/apps/sdk-host/tests/stdio_process.rs +++ b/src/apps/sdk-host/tests/stdio_process.rs @@ -6,6 +6,7 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; #[tokio::test] async fn standalone_sdk_host_negotiates_and_shuts_down_without_cli() { + const FIXTURE_KEY: &str = "bitfun-sdk-fixture-key-7f6b1d"; let temp = tempfile::tempdir().expect("isolated SDK Host environment"); let workspace = temp.path().join("workspace"); let user_root = temp.path().join("user-root"); @@ -44,15 +45,27 @@ async fn standalone_sdk_host_negotiates_and_shuts_down_without_cli() { 1, "initialize", json!({ - "protocolVersion": 1, + "protocolVersion": 5, "clientInfo": { "name": "standalone-process-fixture", "version": "0.1.0" }, - "capabilities": { "serverNotifications": true } + "capabilities": { + "serverNotifications": true, + "permissionResponses": true + }, + "model": { + "provider": "openai", + "model": "fixture-model", + "apiKey": FIXTURE_KEY, + "baseUrl": "http://127.0.0.1:43123/v1" + } }), ) .await; let initialized = read_response(&mut stdout, "initialize").await; assert_eq!(initialized["id"], 1); - assert_eq!(initialized["result"]["protocolVersion"], 1); + assert_eq!(initialized["result"]["protocolVersion"], 5); + assert!(initialized["result"]["modelId"] + .as_str() + .is_some_and(|model_id| model_id.starts_with("sdk:openai:"))); send_request(&mut stdin, 2, "shutdown", json!({})).await; let shutdown = read_response(&mut stdout, "shutdown").await; @@ -69,11 +82,54 @@ async fn standalone_sdk_host_negotiates_and_shuts_down_without_cli() { .read_to_end(&mut stderr_output) .await .expect("read SDK Host stderr"); + let mut stdout_remainder = Vec::new(); + stdout + .read_to_end(&mut stdout_remainder) + .await + .expect("read remaining SDK Host stdout"); assert!( status.success(), "SDK Host failed: {}", String::from_utf8_lossy(&stderr_output) ); + + let mut captured = serde_json::to_vec(&initialized).unwrap(); + captured.extend(serde_json::to_vec(&shutdown).unwrap()); + captured.extend(stdout_remainder); + captured.extend(stderr_output); + assert!(!contains_bytes(&captured, FIXTURE_KEY.as_bytes())); + for root in [&user_root, &home_root, &config_root] { + for contents in read_regular_files_recursively(root) { + assert!( + !contains_bytes(&contents, FIXTURE_KEY.as_bytes()), + "isolated SDK Host storage contained fixture credentials" + ); + } + } +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn read_regular_files_recursively(root: &std::path::Path) -> Vec> { + let mut contents = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(path) = pending.pop() { + for entry in std::fs::read_dir(&path).expect("read isolated SDK Host storage directory") { + let entry = entry.expect("read isolated SDK Host storage entry"); + let file_type = entry.file_type().expect("read SDK Host storage file type"); + if file_type.is_dir() { + pending.push(entry.path()); + } else if file_type.is_file() { + contents.push(std::fs::read(entry.path()).expect("read SDK Host storage file")); + } + } + } + contents } async fn send_request( @@ -108,5 +164,5 @@ async fn read_response( .expect("read SDK Host stdout"); assert_ne!(bytes, 0, "SDK Host stdout closed during {operation}"); serde_json::from_str(&line) - .unwrap_or_else(|error| panic!("SDK Host stdout was not JSON: {error}: {line}")) + .unwrap_or_else(|error| panic!("SDK Host stdout was not JSON: {error}")) } diff --git a/src/apps/sdk-host/tests/stdio_transport.rs b/src/apps/sdk-host/tests/stdio_transport.rs index b30a8c5625..3f1842adf8 100644 --- a/src/apps/sdk-host/tests/stdio_transport.rs +++ b/src/apps/sdk-host/tests/stdio_transport.rs @@ -1,5 +1,5 @@ use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use bitfun_agent_runtime::sdk::{ @@ -9,6 +9,8 @@ use bitfun_agent_runtime::sdk::{ AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentTransientSessionDiscardRequest, PortResult, }; +use bitfun_sdk_host::host::{TemporaryModelInstallError, TemporaryModelInstaller}; +use bitfun_sdk_host::protocol::TemporaryModelConfig; use bitfun_sdk_host_app::transport::{serve_streams, SdkHostTransportConfig}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Notify; @@ -16,6 +18,29 @@ use tokio::time::{timeout, Duration}; struct MinimalOwner; +#[derive(Default)] +struct FakeTemporaryModelInstaller { + removed: Mutex>, +} + +#[async_trait] +impl TemporaryModelInstaller for FakeTemporaryModelInstaller { + async fn install( + &self, + _model: TemporaryModelConfig, + ) -> Result { + Ok("sdk:openai:transport".to_string()) + } + + async fn remove(&self, model_id: &str) { + self.removed.lock().unwrap().push(model_id.to_string()); + } +} + +fn fake_installer() -> Arc { + Arc::new(FakeTemporaryModelInstaller::default()) +} + fn created_session_result( session_id: impl Into, request: AgentSessionCreateRequest, @@ -93,6 +118,13 @@ impl AgentSessionClosePort for MinimalOwner { ) -> PortResult { Ok(false) } + + async fn unload_persisted_session( + &self, + _request: AgentTransientSessionDiscardRequest, + ) -> PortResult { + Ok(false) + } } #[async_trait] @@ -150,6 +182,13 @@ impl AgentSessionClosePort for BlockingCreateOwner { self.deleted.fetch_add(1, Ordering::AcqRel); Ok(true) } + + async fn unload_persisted_session( + &self, + request: AgentTransientSessionDiscardRequest, + ) -> PortResult { + self.discard_transient_session(request).await + } } #[async_trait] @@ -185,9 +224,11 @@ async fn stdio_transport_serves_initialize_and_shutdown_without_non_protocol_std let (client, server) = tokio::io::duplex(16 * 1024); let (client_read, mut client_write) = tokio::io::split(client); let (server_read, server_write) = tokio::io::split(server); + let installer = Arc::new(FakeTemporaryModelInstaller::default()); let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + installer.clone(), server_read, server_write, SdkHostTransportConfig::default(), @@ -195,7 +236,7 @@ async fn stdio_transport_serves_initialize_and_shutdown_without_non_protocol_std client_write .write_all( concat!( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"shutdown\",\"params\":{}}\n" ) .as_bytes(), @@ -211,11 +252,16 @@ async fn stdio_transport_serves_initialize_and_shutdown_without_non_protocol_std serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); assert_eq!(initialized["id"], 1); - assert_eq!(initialized["result"]["protocolVersion"], 1); + assert_eq!(initialized["result"]["protocolVersion"], 5); + assert_eq!(initialized["result"]["modelId"], "sdk:openai:transport"); assert_eq!(shutdown["id"], 2); assert_eq!(shutdown["result"]["accepted"], true); assert!(lines.next_line().await.unwrap().is_none()); task.await.unwrap().unwrap(); + assert_eq!( + installer.removed.lock().unwrap().as_slice(), + &["sdk:openai:transport".to_string()] + ); } #[tokio::test] @@ -232,6 +278,7 @@ async fn stdio_transport_executes_json_rpc_notifications_without_replying() { let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + fake_installer(), server_read, server_write, SdkHostTransportConfig::default(), @@ -239,7 +286,7 @@ async fn stdio_transport_executes_json_rpc_notifications_without_replying() { client_write .write_all( concat!( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", "{\"jsonrpc\":\"2.0\",\"method\":\"shutdown\",\"params\":{}}\n" ) .as_bytes(), @@ -274,6 +321,7 @@ async fn malformed_and_oversized_lines_fail_closed_with_standard_parse_errors() let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + fake_installer(), server_read, server_write, SdkHostTransportConfig { @@ -324,6 +372,7 @@ async fn transport_accepts_input_while_an_owner_call_is_pending_and_bounds_reque let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + fake_installer(), server_read, server_write, SdkHostTransportConfig { @@ -337,7 +386,7 @@ async fn transport_accepts_input_while_an_owner_call_is_pending_and_bounds_reque client_write .write_all( concat!( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/create\",\"params\":{}}\n", "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"session/create\",\"params\":{}}\n" ) @@ -395,6 +444,7 @@ async fn shutdown_remains_available_when_the_data_request_budget_is_exhausted() let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + fake_installer(), server_read, server_write, SdkHostTransportConfig { @@ -408,7 +458,7 @@ async fn shutdown_remains_available_when_the_data_request_budget_is_exhausted() )); client_write .write_all( - b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", ) .await .unwrap(); @@ -430,7 +480,24 @@ async fn shutdown_remains_available_when_the_data_request_budget_is_exhausted() .expect("blocking data request must start"); client_write - .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"shutdown\",\"params\":{}}\n") + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"permission/respond\",\"params\":{\"queryId\":\"missing-query\",\"sessionId\":\"missing-session\",\"turnId\":\"missing-turn\",\"operationId\":\"missing-operation\",\"requestId\":\"missing-permission\",\"decision\":\"reject\"}}\n", + ) + .await + .unwrap(); + let permission_response: serde_json::Value = serde_json::from_str( + &timeout(Duration::from_secs(1), lines.next_line()) + .await + .expect("permission response must use control capacity") + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(permission_response["id"], 3); + assert_eq!(permission_response["error"]["data"]["code"], "not_found"); + + client_write + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"shutdown\",\"params\":{}}\n") .await .unwrap(); client_write.shutdown().await.unwrap(); @@ -443,7 +510,7 @@ async fn shutdown_remains_available_when_the_data_request_budget_is_exhausted() .unwrap(), ) .unwrap(); - assert_eq!(shutdown["id"], 3); + assert_eq!(shutdown["id"], 4); assert_eq!(shutdown["result"]["accepted"], true); task.await.unwrap().unwrap(); } @@ -463,6 +530,7 @@ async fn duplicate_initialize_does_not_abort_an_in_flight_request() { let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + fake_installer(), server_read, server_write, SdkHostTransportConfig::default(), @@ -470,9 +538,9 @@ async fn duplicate_initialize_does_not_abort_an_in_flight_request() { client_write .write_all( concat!( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/create\",\"params\":{}}\n", - "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n" ) .as_bytes(), ) @@ -515,9 +583,11 @@ async fn connection_eof_cleans_a_session_created_after_its_request_is_aborted() let (client, server) = tokio::io::duplex(16 * 1024); let (client_read, mut client_write) = tokio::io::split(client); let (server_read, server_write) = tokio::io::split(server); + let installer = Arc::new(FakeTemporaryModelInstaller::default()); let mut task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + installer.clone(), server_read, server_write, SdkHostTransportConfig { @@ -528,7 +598,7 @@ async fn connection_eof_cleans_a_session_created_after_its_request_is_aborted() client_write .write_all( concat!( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/create\",\"params\":{}}\n" ) .as_bytes(), @@ -550,15 +620,19 @@ async fn connection_eof_cleans_a_session_created_after_its_request_is_aborted() client_write.shutdown().await.unwrap(); timeout(Duration::from_secs(1), &mut task) .await - .expect("transient Session cleanup must stay within the Host deadline") + .expect("Session cleanup must stay within the Host deadline") .unwrap() .unwrap(); assert_eq!(owner.deleted.load(Ordering::Acquire), 1); assert!(lines.next_line().await.unwrap().is_none()); + assert_eq!( + installer.removed.lock().unwrap().as_slice(), + &["sdk:openai:transport".to_string()] + ); } #[tokio::test] -async fn explicit_shutdown_bounds_request_drain_and_transient_cleanup_together() { +async fn explicit_shutdown_bounds_request_drain_and_session_cleanup_together() { let owner = Arc::new(BlockingCreateOwner::new()); let runtime = AgentRuntimeBuilder::new() .with_submission_port(owner.clone()) @@ -572,6 +646,7 @@ async fn explicit_shutdown_bounds_request_drain_and_transient_cleanup_together() let mut task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + fake_installer(), server_read, server_write, SdkHostTransportConfig { @@ -582,7 +657,7 @@ async fn explicit_shutdown_bounds_request_drain_and_transient_cleanup_together() client_write .write_all( concat!( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/create\",\"params\":{}}\n" ) .as_bytes(), @@ -610,7 +685,7 @@ async fn explicit_shutdown_bounds_request_drain_and_transient_cleanup_together() assert_eq!(shutdown["id"], 3); timeout(Duration::from_secs(1), &mut task) .await - .expect("request drain and transient cleanup must share one total deadline") + .expect("request drain and Session cleanup must share one total deadline") .unwrap() .unwrap(); assert_eq!(owner.deleted.load(Ordering::Acquire), 1); @@ -630,6 +705,7 @@ async fn requests_before_a_successful_initialize_cannot_cross_the_handshake() { let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + fake_installer(), server_read, server_write, SdkHostTransportConfig::default(), @@ -637,9 +713,9 @@ async fn requests_before_a_successful_initialize_cannot_cross_the_handshake() { client_write .write_all( concat!( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":999,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":999,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/create\",\"params\":{}}\n", - "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n" ) .as_bytes(), ) @@ -658,7 +734,8 @@ async fn requests_before_a_successful_initialize_cannot_cross_the_handshake() { assert_eq!(pre_initialize["id"], 2); assert_eq!(pre_initialize["error"]["data"]["code"], "not_initialized"); assert_eq!(initialized["id"], 3); - assert_eq!(initialized["result"]["protocolVersion"], 1); + assert_eq!(initialized["result"]["protocolVersion"], 5); + assert_eq!(initialized["result"]["modelId"], "sdk:openai:transport"); client_write .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"shutdown\",\"params\":{}}\n") @@ -684,9 +761,11 @@ async fn blocked_output_times_out_and_ends_the_connection() { let (client, server) = tokio::io::duplex(64); let (_client_read, mut client_write) = tokio::io::split(client); let (server_read, server_write) = tokio::io::split(server); + let installer = Arc::new(FakeTemporaryModelInstaller::default()); let task = tokio::spawn(serve_streams( runtime, "D:/workspace/project", + installer.clone(), server_read, server_write, SdkHostTransportConfig { @@ -696,7 +775,7 @@ async fn blocked_output_times_out_and_ends_the_connection() { )); client_write .write_all( - b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true}}}\n", + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":5,\"clientInfo\":{\"name\":\"fixture\",\"version\":\"0.1\"},\"capabilities\":{\"serverNotifications\":true,\"permissionResponses\":true},\"model\":{\"provider\":\"openai\",\"model\":\"fixture-model\",\"apiKey\":\"fixture-secret\"}}}\n", ) .await .unwrap(); @@ -706,4 +785,8 @@ async fn blocked_output_times_out_and_ends_the_connection() { .expect("blocked SDK Host output must have a deadline") .unwrap(); assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe); + assert_eq!( + installer.removed.lock().unwrap().as_slice(), + &["sdk:openai:transport".to_string()] + ); } diff --git a/src/apps/server/Cargo.toml b/src/apps/server/Cargo.toml index fb8b2bb2e2..8e9fd4ad16 100644 --- a/src/apps/server/Cargo.toml +++ b/src/apps/server/Cargo.toml @@ -22,8 +22,8 @@ bitfun-events = { path = "../../crates/contracts/events" } agent-client-protocol = { workspace = true } # Web framework -axum = { workspace = true } -tower-http = { workspace = true } +axum = { workspace = true, features = ["ws"] } +tower-http = { workspace = true, features = ["cors"] } # Inherited from workspace tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync"] } diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index 0885447772..86329e95fc 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -10,12 +10,11 @@ use bitfun_core::external_sources::{ use bitfun_core::service::dispatch::{ answer_dispatch, append_dispatch, cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, - probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, - sync_dispatch_model_config, sync_dispatch_result, DispatchAnswerRequest, - DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, - DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, - DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, - DispatchSubmitRequest, DispatchSyncResultRequest, OutboundDispatchStore, + probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, sync_dispatch_model_config, + sync_dispatch_result, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, + DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, + DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, OutboundDispatchStore, }; use serde::de::DeserializeOwned; diff --git a/src/apps/skin-market-server/Dockerfile b/src/apps/skin-market-server/Dockerfile index fe90a46c9d..f31f7871bd 100644 --- a/src/apps/skin-market-server/Dockerfile +++ b/src/apps/skin-market-server/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /build RUN corepack enable && corepack prepare pnpm@10.15.0 --activate COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY patches/ patches/ COPY src/skin-market-web/package.json src/skin-market-web/package.json RUN --mount=type=cache,id=bitfun-skin-market-pnpm,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile --ignore-scripts --filter bitfun-skin-market-web diff --git a/src/apps/skin-market-server/Dockerfile.dockerignore b/src/apps/skin-market-server/Dockerfile.dockerignore index a76076aec2..5837344ff4 100644 --- a/src/apps/skin-market-server/Dockerfile.dockerignore +++ b/src/apps/skin-market-server/Dockerfile.dockerignore @@ -6,6 +6,8 @@ !pnpm-lock.yaml !pnpm-workspace.yaml !.npmrc +!patches/ +!patches/** !src/ !src/**/ diff --git a/src/crates/adapters/opencode-plugin-host/AGENTS.md b/src/crates/adapters/opencode-plugin-host/AGENTS.md new file mode 100644 index 0000000000..89683eeced --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/AGENTS.md @@ -0,0 +1,15 @@ +# OpenCode Plugin Host Adapter + +This private adapter owns the framed loopback JSON-RPC transport and maps the +OpenCode extension-host process onto BitFun lifecycle operations. It may use the +managed process-tree primitive from `services-core`, but it must not own product +configuration selection, workspace/session policy, or plugin trust decisions. + +It also owns OpenCode Client route/method/query matching, wire DTOs, +serialization, and protocol error mapping. Product Assembly may register the +adapter, keep opaque logical instance bindings, and call existing BitFun owner +ports to satisfy a matched route; it must not duplicate these wire semantics or +implement physical process-tree supervision. + +The backend always binds the loopback listener before spawning the child. The +first accepted frame must be an authenticated `backend.handshake` request. diff --git a/src/crates/adapters/opencode-plugin-host/Cargo.toml b/src/crates/adapters/opencode-plugin-host/Cargo.toml new file mode 100644 index 0000000000..54b1cc6e07 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bitfun-opencode-plugin-host" +version.workspace = true +authors.workspace = true +edition.workspace = true +publish = false +description = "Private OpenCode plugin host process and IPC adapter" + +[lib] +name = "bitfun_opencode_plugin_host" +crate-type = ["rlib"] + +[dependencies] +base64 = { workspace = true } +bitfun-services-core = { path = "../../services/services-core", features = ["process-runtime"] } +log = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt", "sync", "time"] } +url = { workspace = true } +urlencoding = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/adapters/opencode-plugin-host/src/frame.rs b/src/crates/adapters/opencode-plugin-host/src/frame.rs new file mode 100644 index 0000000000..0459063b0d --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/frame.rs @@ -0,0 +1,54 @@ +use crate::PluginHostError; +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub(super) async fn read_frame(stream: &mut R, limit: usize) -> Result +where + R: AsyncRead + Unpin, +{ + let length = stream.read_u32().await.map_err(PluginHostError::Io)?; + let length = usize::try_from(length).map_err(|_| { + PluginHostError::InvalidHandshake("frame length does not fit usize".to_string()) + })?; + if length == 0 || length > limit { + return Err(PluginHostError::InvalidHandshake(format!( + "frame length {length} exceeds limit {limit}" + ))); + } + let mut payload = vec![0; length]; + stream + .read_exact(&mut payload) + .await + .map_err(PluginHostError::Io)?; + serde_json::from_slice(&payload) + .map_err(|error| PluginHostError::InvalidHandshake(error.to_string())) +} + +pub(super) async fn write_frame( + stream: &mut W, + value: &Value, + limit: usize, +) -> Result<(), PluginHostError> +where + W: AsyncWrite + Unpin, +{ + let payload = serde_json::to_vec(value) + .map_err(|error| PluginHostError::InvalidHandshake(error.to_string()))?; + if payload.is_empty() || payload.len() > limit { + return Err(PluginHostError::InvalidHandshake(format!( + "response length {} exceeds limit {limit}", + payload.len() + ))); + } + let length = u32::try_from(payload.len()).map_err(|_| { + PluginHostError::InvalidHandshake("response length exceeds u32".to_string()) + })?; + stream + .write_u32(length) + .await + .map_err(PluginHostError::Io)?; + stream + .write_all(&payload) + .await + .map_err(PluginHostError::Io) +} diff --git a/src/crates/adapters/opencode-plugin-host/src/host_log.rs b/src/crates/adapters/opencode-plugin-host/src/host_log.rs new file mode 100644 index 0000000000..c799321ed3 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/host_log.rs @@ -0,0 +1,191 @@ +use bitfun_services_core::process_tree::ProcessTreeChild; +use std::io; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +const LOG_CHANNEL_CAPACITY: usize = 256; +const READ_BUFFER_BYTES: usize = 4096; +const MAX_LOG_LINE_BYTES: usize = 32 * 1024; + +struct HostLogLine { + source: &'static str, + bytes: Vec, + truncated: bool, +} + +pub(crate) struct HostLogDrain { + readers: [JoinHandle<()>; 2], + writer: JoinHandle<()>, +} + +impl HostLogDrain { + pub(crate) async fn flush(self, deadline: std::time::Duration) -> bool { + tokio::time::timeout(deadline, async move { + for reader in self.readers { + let _ = reader.await; + } + let _ = self.writer.await; + }) + .await + .is_ok() + } +} + +#[derive(Default)] +struct DroppedLogLines { + stdout: AtomicU64, + stderr: AtomicU64, +} + +impl DroppedLogLines { + fn counter(&self, source: &str) -> &AtomicU64 { + match source { + "stdout" => &self.stdout, + "stderr" => &self.stderr, + _ => &self.stderr, + } + } +} + +pub(crate) async fn attach_host_log( + child: &mut ProcessTreeChild, + log_file: &Path, +) -> io::Result { + let parent = log_file + .parent() + .ok_or_else(|| io::Error::other("plugin host log file has no parent directory"))?; + tokio::fs::create_dir_all(parent).await?; + let file = OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + .await?; + let stdout = child + .take_stdout() + .ok_or_else(|| io::Error::other("plugin host stdout is not piped"))?; + let stderr = child + .take_stderr() + .ok_or_else(|| io::Error::other("plugin host stderr is not piped"))?; + let (sender, receiver) = mpsc::channel(LOG_CHANNEL_CAPACITY); + let dropped = Arc::new(DroppedLogLines::default()); + let writer = tokio::spawn(write_log(file, receiver, dropped.clone())); + let stdout_reader = tokio::spawn(read_log(stdout, "stdout", sender.clone(), dropped.clone())); + let stderr_reader = tokio::spawn(read_log(stderr, "stderr", sender, dropped)); + Ok(HostLogDrain { + readers: [stdout_reader, stderr_reader], + writer, + }) +} + +async fn read_log( + mut reader: R, + source: &'static str, + sender: mpsc::Sender, + dropped: Arc, +) where + R: AsyncRead + Unpin, +{ + let mut buffer = [0_u8; READ_BUFFER_BYTES]; + let mut line = Vec::with_capacity(READ_BUFFER_BYTES); + let mut truncated = false; + loop { + let read = match reader.read(&mut buffer).await { + Ok(0) => { + enqueue_line(&sender, &dropped, source, &mut line, truncated); + return; + } + Ok(read) => read, + Err(_) => return, + }; + for byte in &buffer[..read] { + if *byte == b'\n' { + enqueue_line(&sender, &dropped, source, &mut line, truncated); + truncated = false; + } else if line.len() < MAX_LOG_LINE_BYTES { + line.push(*byte); + } else { + truncated = true; + } + } + } +} + +fn enqueue_line( + sender: &mpsc::Sender, + dropped: &DroppedLogLines, + source: &'static str, + line: &mut Vec, + truncated: bool, +) { + if line.is_empty() && !truncated { + return; + } + let bytes = std::mem::take(line); + let message = HostLogLine { + source, + bytes, + truncated, + }; + if let Err(mpsc::error::TrySendError::Full(_)) = sender.try_send(message) { + dropped.counter(source).fetch_add(1, Ordering::Relaxed); + } +} + +async fn write_log( + mut file: File, + mut receiver: mpsc::Receiver, + dropped: Arc, +) { + let mut flush_dropped = tokio::time::interval(std::time::Duration::from_secs(1)); + flush_dropped.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + line = receiver.recv() => { + let Some(line) = line else { + break; + }; + if write_dropped_lines(&mut file, &dropped).await.is_err() + || write_line(&mut file, &line).await.is_err() + { + return; + } + } + _ = flush_dropped.tick() => { + if write_dropped_lines(&mut file, &dropped).await.is_err() { + return; + } + } + } + } + let _ = write_dropped_lines(&mut file, &dropped).await; + let _ = file.flush().await; +} + +async fn write_line(file: &mut File, line: &HostLogLine) -> io::Result<()> { + file.write_all(b"[").await?; + file.write_all(line.source.as_bytes()).await?; + file.write_all(b"] ").await?; + file.write_all(&line.bytes).await?; + if line.truncated { + file.write_all(b" [truncated]").await?; + } + file.write_all(b"\n").await +} + +async fn write_dropped_lines(file: &mut File, dropped: &DroppedLogLines) -> io::Result<()> { + for source in ["stdout", "stderr"] { + let count = dropped.counter(source).swap(0, Ordering::Relaxed); + if count > 0 { + file.write_all( + format!("[plugin-host] dropped_lines={count}, source={source}\n").as_bytes(), + ) + .await?; + } + } + Ok(()) +} diff --git a/src/crates/adapters/opencode-plugin-host/src/http.rs b/src/crates/adapters/opencode-plugin-host/src/http.rs new file mode 100644 index 0000000000..7bd5b6f952 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/http.rs @@ -0,0 +1,762 @@ +use crate::{PluginHostClient, PluginHostError}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::Duration; +use thiserror::Error; +use url::Url; + +pub const MAX_HTTP_BODY_BYTES: usize = 1024 * 1024; +pub const MAX_STREAM_CHUNK_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackendHttpRequest { + #[serde(rename = "instanceID")] + pub instance_id: String, + #[serde(rename = "requestID")] + pub request_id: String, + pub method: String, + pub path: String, + pub headers: Vec<(String, String)>, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StreamDescriptor { + #[serde(rename = "streamID")] + pub stream_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub length: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BackendHttpResponse { + pub status: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_text: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub headers: Vec<(String, String)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenCodeClientRoute { + ProjectList, + ProjectCurrent, + PathGet, + VcsGet, + ConfigGet, + ConfigProviders, + ToolIds, + ToolList, + ProviderList, + AppLog, + AgentList, + CommandList, + SessionList, + SessionCreate, + SessionStatus, + SessionDelete { + session_id: String, + }, + SessionGet { + session_id: String, + }, + SessionUpdate { + session_id: String, + }, + SessionChildren { + session_id: String, + }, + SessionTodo { + session_id: String, + }, + SessionFork { + session_id: String, + }, + SessionAbort { + session_id: String, + }, + SessionDiff { + session_id: String, + }, + SessionMessages { + session_id: String, + }, + SessionMessage { + session_id: String, + message_id: String, + }, + PtyList, + PtyCreate, + PtyDelete { + pty_id: String, + }, + PtyGet { + pty_id: String, + }, + PtyUpdate { + pty_id: String, + }, + FindText, + FindFiles, + FileList, + FileRead, + FileStatus, + McpStatus, + LspStatus, +} + +impl OpenCodeClientRoute { + pub fn operation(&self) -> &'static str { + match self { + Self::ProjectList => "project.list", + Self::ProjectCurrent => "project.current", + Self::PathGet => "path.get", + Self::VcsGet => "vcs.get", + Self::ConfigGet => "config.get", + Self::ConfigProviders => "config.providers", + Self::ToolIds => "tool.ids", + Self::ToolList => "tool.list", + Self::ProviderList => "provider.list", + Self::AppLog => "app.log", + Self::AgentList => "app.agents", + Self::CommandList => "command.list", + Self::SessionList => "session.list", + Self::SessionCreate => "session.create", + Self::SessionStatus => "session.status", + Self::SessionDelete { .. } => "session.delete", + Self::SessionGet { .. } => "session.get", + Self::SessionUpdate { .. } => "session.update", + Self::SessionChildren { .. } => "session.children", + Self::SessionTodo { .. } => "session.todo", + Self::SessionFork { .. } => "session.fork", + Self::SessionAbort { .. } => "session.abort", + Self::SessionDiff { .. } => "session.diff", + Self::SessionMessages { .. } => "session.messages", + Self::SessionMessage { .. } => "session.message", + Self::PtyList => "pty.list", + Self::PtyCreate => "pty.create", + Self::PtyDelete { .. } => "pty.remove", + Self::PtyGet { .. } => "pty.get", + Self::PtyUpdate { .. } => "pty.update", + Self::FindText => "find.text", + Self::FindFiles => "find.files", + Self::FileList => "file.list", + Self::FileRead => "file.read", + Self::FileStatus => "file.status", + Self::McpStatus => "mcp.status", + Self::LspStatus => "lsp.status", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpRouteMatch { + pub route: OpenCodeClientRoute, + pub path: String, + pub query: HashMap>, +} + +impl HttpRouteMatch { + pub fn query_first(&self, key: &str) -> Option<&str> { + self.query + .get(key) + .and_then(|values| values.first()) + .map(String::as_str) + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum HttpRouteError { + #[error("request path is invalid")] + InvalidPath, + #[error("OpenCode client route was not found")] + NotFound, + #[error("HTTP method is not allowed for this OpenCode client route")] + MethodNotAllowed, +} + +pub fn match_http_route( + method: &str, + path_and_query: &str, +) -> Result { + if !path_and_query.starts_with('/') || path_and_query.len() > 16 * 1024 { + return Err(HttpRouteError::InvalidPath); + } + let url = Url::parse(&format!("http://127.0.0.1{path_and_query}")) + .map_err(|_| HttpRouteError::InvalidPath)?; + let path = url.path().trim_end_matches('/'); + let path = if path.is_empty() { "/" } else { path }; + let query = url.query_pairs().fold( + HashMap::>::new(), + |mut query, (key, value)| { + query + .entry(key.into_owned()) + .or_default() + .push(value.into_owned()); + query + }, + ); + let method = method.trim().to_ascii_uppercase(); + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(decode_segment) + .collect::, _>>()?; + let route = match (method.as_str(), path, segments.as_slice()) { + ("GET", "/project", _) => OpenCodeClientRoute::ProjectList, + ("GET", "/project/current", _) => OpenCodeClientRoute::ProjectCurrent, + ("GET", "/path", _) => OpenCodeClientRoute::PathGet, + ("GET", "/vcs", _) => OpenCodeClientRoute::VcsGet, + ("GET", "/config", _) => OpenCodeClientRoute::ConfigGet, + ("GET", "/config/providers", _) => OpenCodeClientRoute::ConfigProviders, + ("GET", "/experimental/tool/ids", _) => OpenCodeClientRoute::ToolIds, + ("GET", "/experimental/tool", _) => OpenCodeClientRoute::ToolList, + ("GET", "/provider", _) => OpenCodeClientRoute::ProviderList, + ("POST", "/log", _) => OpenCodeClientRoute::AppLog, + ("GET", "/agent", _) => OpenCodeClientRoute::AgentList, + ("GET", "/command", _) => OpenCodeClientRoute::CommandList, + ("GET", "/session", _) => OpenCodeClientRoute::SessionList, + ("POST", "/session", _) => OpenCodeClientRoute::SessionCreate, + ("GET", "/session/status", _) => OpenCodeClientRoute::SessionStatus, + ("DELETE", _, [session, session_id]) if session == "session" && session_id != "status" => { + OpenCodeClientRoute::SessionDelete { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id]) if session == "session" && session_id != "status" => { + OpenCodeClientRoute::SessionGet { + session_id: session_id.clone(), + } + } + ("PATCH", _, [session, session_id]) if session == "session" && session_id != "status" => { + OpenCodeClientRoute::SessionUpdate { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "children" => + { + OpenCodeClientRoute::SessionChildren { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "todo" => + { + OpenCodeClientRoute::SessionTodo { + session_id: session_id.clone(), + } + } + ("POST", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "fork" => + { + OpenCodeClientRoute::SessionFork { + session_id: session_id.clone(), + } + } + ("POST", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "abort" => + { + OpenCodeClientRoute::SessionAbort { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "diff" => + { + OpenCodeClientRoute::SessionDiff { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "message" => + { + OpenCodeClientRoute::SessionMessages { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, message, message_id]) + if session == "session" && session_id != "status" && message == "message" => + { + OpenCodeClientRoute::SessionMessage { + session_id: session_id.clone(), + message_id: message_id.clone(), + } + } + ("GET", "/pty", _) => OpenCodeClientRoute::PtyList, + ("POST", "/pty", _) => OpenCodeClientRoute::PtyCreate, + ("DELETE", _, [pty, pty_id]) if pty == "pty" => OpenCodeClientRoute::PtyDelete { + pty_id: pty_id.clone(), + }, + ("GET", _, [pty, pty_id]) if pty == "pty" => OpenCodeClientRoute::PtyGet { + pty_id: pty_id.clone(), + }, + ("PUT", _, [pty, pty_id]) if pty == "pty" => OpenCodeClientRoute::PtyUpdate { + pty_id: pty_id.clone(), + }, + ("GET", "/find", _) => OpenCodeClientRoute::FindText, + ("GET", "/find/file", _) => OpenCodeClientRoute::FindFiles, + ("GET", "/file", _) => OpenCodeClientRoute::FileList, + ("GET", "/file/content", _) => OpenCodeClientRoute::FileRead, + ("GET", "/file/status", _) => OpenCodeClientRoute::FileStatus, + ("GET", "/mcp", _) => OpenCodeClientRoute::McpStatus, + ("GET", "/lsp", _) => OpenCodeClientRoute::LspStatus, + _ if is_known_adapted_path(path, &segments) => { + return Err(HttpRouteError::MethodNotAllowed) + } + _ => return Err(HttpRouteError::NotFound), + }; + Ok(HttpRouteMatch { + route, + path: path.to_string(), + query, + }) +} + +fn decode_segment(segment: &str) -> Result { + let bytes = segment.as_bytes(); + for index in 0..bytes.len() { + if bytes[index] == b'%' + && (index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit()) + { + return Err(HttpRouteError::InvalidPath); + } + } + let decoded = urlencoding::decode(segment).map_err(|_| HttpRouteError::InvalidPath)?; + if decoded.is_empty() || decoded.contains('/') || decoded.contains('\\') { + return Err(HttpRouteError::InvalidPath); + } + Ok(decoded.into_owned()) +} + +fn is_known_adapted_path(path: &str, segments: &[String]) -> bool { + matches!( + path, + "/project" + | "/project/current" + | "/path" + | "/vcs" + | "/config" + | "/config/providers" + | "/experimental/tool/ids" + | "/experimental/tool" + | "/provider" + | "/log" + | "/agent" + | "/command" + | "/session" + | "/session/status" + | "/pty" + | "/find" + | "/find/file" + | "/file" + | "/file/content" + | "/file/status" + | "/mcp" + | "/lsp" + ) || matches!(segments, [root, _] if root == "session" || root == "pty") + || matches!( + segments, + [root, _, suffix] + if root == "session" + && matches!( + suffix.as_str(), + "children" | "todo" | "fork" | "abort" | "diff" | "message" + ) + ) + || matches!(segments, [root, _, message, _] if root == "session" && message == "message") +} + +#[derive(Debug, Error)] +pub enum HostStreamReadError { + #[error("request body exceeds the maximum allowed size")] + BodyTooLarge, + #[error("host stream returned invalid base64 data: {0}")] + InvalidBase64(#[source] base64::DecodeError), + #[error("host stream RPC failed: {0}")] + Rpc(#[from] PluginHostError), + #[error("host stream returned an invalid response")] + InvalidResponse, +} + +pub async fn read_host_stream( + client: &PluginHostClient, + instance_id: &str, + descriptor: &StreamDescriptor, + max_bytes: usize, + deadline: Duration, +) -> Result, HostStreamReadError> { + let result = read_host_stream_inner(client, instance_id, descriptor, max_bytes, deadline).await; + if let Err(error) = &result { + let reason = match error { + HostStreamReadError::BodyTooLarge => "request body too large", + HostStreamReadError::InvalidBase64(_) => "host stream returned invalid base64 data", + HostStreamReadError::InvalidResponse => "host stream returned an invalid response", + HostStreamReadError::Rpc(_) => "host stream RPC failed", + }; + cancel_host_stream(client, instance_id, descriptor, reason).await; + } + result +} + +async fn read_host_stream_inner( + client: &PluginHostClient, + instance_id: &str, + descriptor: &StreamDescriptor, + max_bytes: usize, + deadline: Duration, +) -> Result, HostStreamReadError> { + if descriptor.length.is_some_and(|length| length > max_bytes) { + return Err(HostStreamReadError::BodyTooLarge); + } + let mut output = Vec::with_capacity(descriptor.length.unwrap_or(0).min(max_bytes)); + loop { + let response = client + .request( + "host.stream.read", + json!({ + "instanceID": instance_id, + "streamID": descriptor.stream_id, + "maxBytes": MAX_STREAM_CHUNK_BYTES, + }), + deadline, + ) + .await?; + let data = response + .get("data") + .and_then(Value::as_str) + .ok_or(HostStreamReadError::InvalidResponse)?; + let eof = response + .get("eof") + .and_then(Value::as_bool) + .ok_or(HostStreamReadError::InvalidResponse)?; + let chunk = BASE64_STANDARD + .decode(data) + .map_err(HostStreamReadError::InvalidBase64)?; + if output.len().saturating_add(chunk.len()) > max_bytes { + return Err(HostStreamReadError::BodyTooLarge); + } + output.extend_from_slice(&chunk); + if eof { + return Ok(output); + } + } +} + +async fn cancel_host_stream( + client: &PluginHostClient, + instance_id: &str, + descriptor: &StreamDescriptor, + reason: &str, +) { + let _ = client + .request( + "host.stream.cancel", + json!({ + "instanceID": instance_id, + "streamID": descriptor.stream_id, + "reason": reason, + }), + Duration::from_secs(2), + ) + .await; +} + +pub fn json_error_body(code: &str, message: &str, route: &str) -> Vec { + serde_json::to_vec(&json!({ + "error": { + "code": code, + "message": message, + "route": route, + } + })) + .unwrap_or_else(|_| b"{\"error\":{\"code\":\"backend_failure\"}}".to_vec()) +} + +#[cfg(test)] +mod tests { + use super::{match_http_route, HttpRouteError, OpenCodeClientRoute}; + + fn assert_route(method: &str, path: &str, expected: OpenCodeClientRoute) { + let matched = match_http_route(method, path) + .unwrap_or_else(|error| panic!("route did not match: {method} {path}: {error}")); + assert_eq!( + matched.route, expected, + "unexpected route for {method} {path}" + ); + } + + #[test] + fn adapted_route_matrix_covers_every_documented_a_route() { + let cases = vec![ + ("GET", "/project", OpenCodeClientRoute::ProjectList), + ( + "GET", + "/project/current?directory=C%3A%5Cworkspace", + OpenCodeClientRoute::ProjectCurrent, + ), + ("GET", "/path", OpenCodeClientRoute::PathGet), + ("GET", "/vcs", OpenCodeClientRoute::VcsGet), + ("GET", "/config", OpenCodeClientRoute::ConfigGet), + ( + "GET", + "/config/providers", + OpenCodeClientRoute::ConfigProviders, + ), + ( + "GET", + "/experimental/tool/ids", + OpenCodeClientRoute::ToolIds, + ), + ( + "GET", + "/experimental/tool?provider=bitfun&model=primary", + OpenCodeClientRoute::ToolList, + ), + ("GET", "/provider", OpenCodeClientRoute::ProviderList), + ("POST", "/log", OpenCodeClientRoute::AppLog), + ("GET", "/agent", OpenCodeClientRoute::AgentList), + ("GET", "/command", OpenCodeClientRoute::CommandList), + ("GET", "/session", OpenCodeClientRoute::SessionList), + ("POST", "/session", OpenCodeClientRoute::SessionCreate), + ("GET", "/session/status", OpenCodeClientRoute::SessionStatus), + ( + "DELETE", + "/session/session%3A1", + OpenCodeClientRoute::SessionDelete { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1", + OpenCodeClientRoute::SessionGet { + session_id: "session:1".to_string(), + }, + ), + ( + "PATCH", + "/session/session%3A1", + OpenCodeClientRoute::SessionUpdate { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/children", + OpenCodeClientRoute::SessionChildren { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/todo", + OpenCodeClientRoute::SessionTodo { + session_id: "session:1".to_string(), + }, + ), + ( + "POST", + "/session/session%3A1/fork", + OpenCodeClientRoute::SessionFork { + session_id: "session:1".to_string(), + }, + ), + ( + "POST", + "/session/session%3A1/abort", + OpenCodeClientRoute::SessionAbort { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/diff?messageID=message%3A1", + OpenCodeClientRoute::SessionDiff { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/message?limit=10", + OpenCodeClientRoute::SessionMessages { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/message/message%3A2", + OpenCodeClientRoute::SessionMessage { + session_id: "session:1".to_string(), + message_id: "message:2".to_string(), + }, + ), + ("GET", "/pty", OpenCodeClientRoute::PtyList), + ("POST", "/pty", OpenCodeClientRoute::PtyCreate), + ( + "DELETE", + "/pty/pty%3A1", + OpenCodeClientRoute::PtyDelete { + pty_id: "pty:1".to_string(), + }, + ), + ( + "GET", + "/pty/pty%3A1", + OpenCodeClientRoute::PtyGet { + pty_id: "pty:1".to_string(), + }, + ), + ( + "PUT", + "/pty/pty%3A1", + OpenCodeClientRoute::PtyUpdate { + pty_id: "pty:1".to_string(), + }, + ), + ("GET", "/find?pattern=needle", OpenCodeClientRoute::FindText), + ( + "GET", + "/find/file?query=needle", + OpenCodeClientRoute::FindFiles, + ), + ("GET", "/file?path=src", OpenCodeClientRoute::FileList), + ( + "GET", + "/file/content?path=README.md", + OpenCodeClientRoute::FileRead, + ), + ("GET", "/file/status", OpenCodeClientRoute::FileStatus), + ("GET", "/mcp", OpenCodeClientRoute::McpStatus), + ("GET", "/lsp", OpenCodeClientRoute::LspStatus), + ]; + + for (method, path, expected) in cases { + assert_route(method, path, expected); + } + } + + #[test] + fn normalizes_methods_paths_and_query_values() { + let matched = match_http_route( + " get ", + "/project/current/?directory=C%3A%5Cworkspace&directory=D%3A%5Cignored", + ) + .expect("normalized project route"); + + assert_eq!(matched.route, OpenCodeClientRoute::ProjectCurrent); + assert_eq!(matched.path, "/project/current"); + assert_eq!(matched.query_first("directory"), Some("C:\\workspace")); + assert_eq!( + matched.query.get("directory"), + Some(&vec![ + "C:\\workspace".to_string(), + "D:\\ignored".to_string() + ]) + ); + } + + #[test] + fn rejects_invalid_and_unsafe_route_paths() { + let oversized = format!("/{}", "a".repeat(16 * 1024)); + for path in [ + "project/current", + "/session/%ZZ", + "/session/session%2Fescape", + "/session/session%5Cescape", + oversized.as_str(), + ] { + assert_eq!( + match_http_route("GET", path), + Err(HttpRouteError::InvalidPath), + "invalid path unexpectedly matched: {path}" + ); + } + + assert_eq!( + match_http_route("GET", "/unknown"), + Err(HttpRouteError::NotFound) + ); + } + + #[test] + fn postponed_and_excluded_routes_are_not_in_the_route_table() { + for (method, path) in [ + ("GET", "/global/event"), + ("GET", "/event"), + ("POST", "/instance/dispose"), + ("GET", "/provider/auth"), + ("POST", "/provider/openai/oauth/authorize"), + ("POST", "/provider/openai/oauth/callback"), + ("GET", "/pty/pty-1/connect"), + ("GET", "/find/symbol"), + ("GET", "/formatter"), + ("POST", "/session/s1/init"), + ("POST", "/session/s1/summarize"), + ("DELETE", "/session/s1/share"), + ("POST", "/session/s1/share"), + ("POST", "/session/s1/prompt_async"), + ("POST", "/session/s1/command"), + ("POST", "/session/s1/shell"), + ("POST", "/session/s1/revert"), + ("POST", "/session/s1/unrevert"), + ("POST", "/mcp/server/connect"), + ("POST", "/mcp/server/disconnect"), + ("DELETE", "/mcp/server/auth"), + ("POST", "/mcp/server/auth"), + ("POST", "/mcp/server/auth/callback"), + ("POST", "/mcp/server/auth/authenticate"), + ("PUT", "/auth/server"), + ("DELETE", "/auth/provider"), + ("POST", "/auth/provider"), + ("POST", "/auth/provider/callback"), + ("POST", "/auth/provider/authenticate"), + ("POST", "/tui/append-prompt"), + ("POST", "/tui/open-help"), + ("POST", "/tui/open-sessions"), + ("POST", "/tui/open-themes"), + ("POST", "/tui/open-models"), + ("POST", "/tui/submit-prompt"), + ("POST", "/tui/clear-prompt"), + ("POST", "/tui/execute-command"), + ("POST", "/tui/show-toast"), + ("POST", "/tui/publish"), + ("GET", "/tui/control/next"), + ("POST", "/tui/control/response"), + ("POST", "/session/s1/permissions/p1"), + ] { + assert_eq!( + match_http_route(method, path), + Err(HttpRouteError::NotFound), + "excluded route unexpectedly matched: {method} {path}" + ); + } + + for (method, path) in [ + ("POST", "/project/current"), + ("PATCH", "/config"), + ("DELETE", "/config"), + ("DELETE", "/session/status"), + ("POST", "/session/s1/message"), + ("PATCH", "/pty/pty-1"), + ("POST", "/mcp"), + ("POST", "/file/status"), + ] { + assert_eq!( + match_http_route(method, path), + Err(HttpRouteError::MethodNotAllowed), + "adapted route accepted the wrong method: {method} {path}" + ); + } + } +} diff --git a/src/crates/adapters/opencode-plugin-host/src/lib.rs b/src/crates/adapters/opencode-plugin-host/src/lib.rs new file mode 100644 index 0000000000..52b206da72 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/lib.rs @@ -0,0 +1,545 @@ +mod frame; +mod host_log; +mod http; +mod peer; +mod peer_runtime; +mod stream_registry; + +use bitfun_services_core::process_tree::{CleanupOutcome, ProcessTreeChild}; +use rand::{distributions::Alphanumeric, Rng}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use std::time::Instant; +use thiserror::Error; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::Command; + +use frame::{read_frame, write_frame}; +pub use http::{ + json_error_body, match_http_route, read_host_stream, BackendHttpRequest, BackendHttpResponse, + HostStreamReadError, HttpRouteError, HttpRouteMatch, OpenCodeClientRoute, StreamDescriptor, + MAX_HTTP_BODY_BYTES, MAX_STREAM_CHUNK_BYTES, +}; +pub use peer::{JsonRpcPeer, PluginHostClient, RpcHandlerError}; +pub use stream_registry::{ + PluginHostStreamRegistry, StreamCancelParams, StreamCancelResult, StreamReadParams, + StreamReadResult, StreamRegistryError, +}; + +const PROTOCOL_VERSION: u64 = 1; +const MIN_NEGOTIATED_FRAME_BYTES: usize = 64 * 1024; +const DEFAULT_MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; +const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +static NEXT_CONNECTION_GENERATION: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub struct PluginHostConfig { + pub runtime_command: PathBuf, + pub entry: PathBuf, + pub working_directory: PathBuf, + pub cache_directory: PathBuf, + pub log_file: PathBuf, + pub log_level: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDeclaration { + pub spec: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_directory: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPrepareRequest { + pub plugins: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub configuration_fingerprint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_base_directory: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstanceOpenRequest { + #[serde(rename = "instanceID")] + pub instance_id: String, + pub project: Value, + pub config: serde_json::Map, + pub directory: String, + pub worktree: String, + pub plugins: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub configuration_fingerprint: Option, +} + +#[derive(Debug, Error)] +pub enum PluginHostError { + #[error("plugin host entry is not an absolute path: {0}")] + RelativeEntry(PathBuf), + #[error("plugin host cache directory is not an absolute path: {0}")] + RelativeCacheDirectory(PathBuf), + #[error("plugin host log file is not an absolute path: {0}")] + RelativeLogFile(PathBuf), + #[error("failed to prepare plugin host cache directory: {0}")] + PrepareCache(#[source] std::io::Error), + #[error("failed to bind plugin host listener: {0}")] + Bind(#[source] std::io::Error), + #[error("plugin host runtime executable was not found: {0}")] + RuntimeNotFound(PathBuf), + #[error("failed to start plugin host runtime: {0}")] + Spawn(#[source] std::io::Error), + #[error("failed to prepare plugin host log: {0}")] + PrepareLog(#[source] std::io::Error), + #[error("plugin host did not connect within the startup timeout")] + StartupTimeout, + #[error("plugin host startup failed ({startup}) and process-tree cleanup failed: {cleanup}")] + StartupCleanup { + startup: String, + #[source] + cleanup: std::io::Error, + }, + #[error("plugin host IPC failed: {0}")] + Io(#[source] std::io::Error), + #[error("plugin host handshake frame is invalid: {0}")] + InvalidHandshake(String), + #[error("plugin host JSON-RPC protocol error: {0}")] + Protocol(String), + #[error("plugin host JSON-RPC connection closed: {0}")] + ConnectionClosed(String), + #[error("plugin host is shutting down")] + ShuttingDown, + #[error("plugin host JSON-RPC request timed out: method={method}, request_id={request_id}")] + RequestTimeout { method: String, request_id: String }, + #[error("plugin host JSON-RPC returned an error: code={code}, message={message}")] + Rpc { + code: i64, + message: String, + data: Option, + }, + #[error("plugin host JSON-RPC handler is already registered: {0}")] + DuplicateHandler(String), +} + +pub struct PluginHost { + child: ProcessTreeChild, + client: PluginHostClient, + host_log: Option, + max_frame_bytes: usize, +} + +#[derive(Debug, Clone, Copy)] +pub struct PluginHostShutdownPolicy { + pub drain_timeout: Duration, + pub rpc_timeout: Duration, + pub exit_timeout: Duration, + pub eof_timeout: Duration, + pub terminate_grace: Duration, +} + +impl Default for PluginHostShutdownPolicy { + fn default() -> Self { + Self { + drain_timeout: Duration::from_secs(3), + rpc_timeout: Duration::from_secs(5), + exit_timeout: Duration::from_secs(2), + eof_timeout: Duration::from_secs(1), + terminate_grace: Duration::from_millis(500), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginHostShutdownDisposition { + Graceful, + ExitedAfterShutdown, + ExitedAfterConnectionClose, + Forced, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginHostShutdownReport { + pub generation: u64, + pub disposition: PluginHostShutdownDisposition, + pub reaped: bool, + pub rpc_completed: bool, + pub exit_code: Option, + pub duration_ms: u64, +} + +impl PluginHost { + pub async fn start(config: PluginHostConfig) -> Result { + Self::start_with_timeout(config, STARTUP_TIMEOUT).await + } + + async fn start_with_timeout( + config: PluginHostConfig, + startup_timeout: Duration, + ) -> Result { + validate_config(&config)?; + tokio::fs::create_dir_all(&config.cache_directory) + .await + .map_err(PluginHostError::PrepareCache)?; + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(PluginHostError::Bind)?; + let address = listener.local_addr().map_err(PluginHostError::Bind)?; + let token: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + + let mut command = Command::new(&config.runtime_command); + command + .arg(&config.entry) + .current_dir(&config.working_directory) + .env("OPENCODE_EXTENSION_HOST_RPC_ADDRESS", address.to_string()) + .env("OPENCODE_EXTENSION_HOST_RPC_TOKEN", &token) + .env("OPENCODE_EXTENSION_HOST_LOG_LEVEL", &config.log_level) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + PluginHostError::RuntimeNotFound(config.runtime_command.clone()) + } else { + PluginHostError::Spawn(error) + } + })?; + let host_log = match host_log::attach_host_log(&mut child, &config.log_file).await { + Ok(host_log) => host_log, + Err(error) => { + return Err(cleanup_failed_start( + &mut child, + None, + PluginHostError::PrepareLog(error), + ) + .await); + } + }; + + let (stream, max_frame_bytes) = match accept_authenticated_connection( + &listener, + &token, + &config.cache_directory, + startup_timeout, + ) + .await + { + Ok(connection) => connection, + Err(error) => { + return Err(cleanup_failed_start(&mut child, Some(host_log), error).await); + } + }; + let generation = NEXT_CONNECTION_GENERATION.fetch_add(1, Ordering::Relaxed); + let peer = JsonRpcPeer::start(stream, generation, max_frame_bytes); + Ok(Self { + child, + client: peer.client(), + host_log: Some(host_log), + max_frame_bytes, + }) + } + + pub fn max_frame_bytes(&self) -> usize { + self.max_frame_bytes + } + + pub fn client(&self) -> PluginHostClient { + self.client.clone() + } + + pub fn is_connected(&mut self) -> Result { + if self + .child + .try_wait() + .map_err(PluginHostError::Io)? + .is_some() + { + return Ok(false); + } + Ok(!self.client.is_closed()) + } + + pub async fn shutdown(mut self, policy: PluginHostShutdownPolicy) -> PluginHostShutdownReport { + let started_at = Instant::now(); + let generation = self.client.generation(); + let pending = self.client.begin_draining().await; + log::info!( + "Plugin host shutdown started: generation={}, pending_requests={}, drain_deadline_ms={}, rpc_deadline_ms={}", + generation, + pending, + policy.drain_timeout.as_millis(), + policy.rpc_timeout.as_millis() + ); + if !self.client.wait_for_pending(policy.drain_timeout).await { + log::warn!( + "Plugin host RPC drain timed out: generation={}, pending_requests={}", + generation, + pending + ); + } + + let rpc_completed = self + .client + .request_during_shutdown("host.shutdown", json!({}), policy.rpc_timeout) + .await + .is_ok_and(|result| result.get("closed").and_then(Value::as_bool) == Some(true)); + if rpc_completed { + log::info!( + "Plugin host shutdown RPC completed: generation={}, duration_ms={}", + generation, + elapsed_ms(started_at) + ); + if let Ok(Ok(status)) = + tokio::time::timeout(policy.exit_timeout, self.child.wait()).await + { + let disposition = if status.success() { + PluginHostShutdownDisposition::Graceful + } else { + PluginHostShutdownDisposition::ExitedAfterShutdown + }; + let mut report = + shutdown_report(generation, disposition, true, status.code(), started_at); + report.reaped = + reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; + if report.disposition == PluginHostShutdownDisposition::Graceful { + log::info!( + "Plugin host exited gracefully: generation={}, exit_code={:?}, duration_ms={}", + generation, + report.exit_code, + report.duration_ms + ); + } else { + log::warn!( + "Plugin host exited after shutdown with a failure status: generation={}, exit_code={:?}, duration_ms={}", + generation, + report.exit_code, + report.duration_ms + ); + } + self.flush_host_log(policy.eof_timeout).await; + return report; + } + log::warn!( + "Plugin host exit timed out after shutdown response: generation={}", + generation + ); + } else { + log::warn!( + "Plugin host shutdown RPC failed or timed out: generation={}", + generation + ); + } + + self.client + .close("plugin host graceful shutdown fallback") + .await; + if let Ok(Ok(status)) = tokio::time::timeout(policy.eof_timeout, self.child.wait()).await { + let mut report = shutdown_report( + generation, + PluginHostShutdownDisposition::ExitedAfterConnectionClose, + rpc_completed, + status.code(), + started_at, + ); + report.reaped = + reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; + log::info!( + "Plugin host exited after RPC connection close: generation={}, exit_code={:?}, duration_ms={}", + generation, + report.exit_code, + report.duration_ms + ); + self.flush_host_log(policy.eof_timeout).await; + return report; + } + + let reaped = reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; + let exit_code = self + .child + .try_wait() + .ok() + .flatten() + .and_then(|status| status.code()); + let mut report = shutdown_report( + generation, + PluginHostShutdownDisposition::Forced, + rpc_completed, + exit_code, + started_at, + ); + report.reaped = reaped; + self.flush_host_log(policy.eof_timeout).await; + report + } + + async fn flush_host_log(&mut self, deadline: Duration) { + let Some(host_log) = self.host_log.take() else { + return; + }; + if !host_log.flush(deadline).await { + log::warn!( + "Plugin host log flush timed out: generation={}", + self.client.generation() + ); + } + } +} + +async fn reap_process_tree(child: &mut ProcessTreeChild, grace: Duration, generation: u64) -> bool { + match child.terminate(grace).await { + Ok(CleanupOutcome::AlreadyExited) => { + log::info!("Plugin host process tree already exited: generation={generation}"); + true + } + Ok(_) => { + log::info!("Plugin host process tree reaped: generation={generation}"); + true + } + Err(error) => { + log::error!( + "Plugin host process tree termination failed: generation={}, error={}", + generation, + error + ); + false + } + } +} + +async fn cleanup_failed_start( + child: &mut ProcessTreeChild, + host_log: Option, + startup: PluginHostError, +) -> PluginHostError { + let policy = PluginHostShutdownPolicy::default(); + let cleanup = child.terminate(policy.terminate_grace).await; + if let Some(host_log) = host_log { + let _ = host_log.flush(policy.eof_timeout).await; + } + match cleanup { + Ok(_) => startup, + Err(cleanup) => PluginHostError::StartupCleanup { + startup: startup.to_string(), + cleanup, + }, + } +} + +async fn accept_authenticated_connection( + listener: &TcpListener, + expected_token: &str, + cache_directory: &Path, + startup_timeout: Duration, +) -> Result<(TcpStream, usize), PluginHostError> { + tokio::time::timeout(startup_timeout, async { + let (mut stream, _) = listener.accept().await.map_err(PluginHostError::Io)?; + let max_frame_bytes = + complete_handshake(&mut stream, expected_token, cache_directory).await?; + Ok((stream, max_frame_bytes)) + }) + .await + .map_err(|_| PluginHostError::StartupTimeout)? +} + +fn shutdown_report( + generation: u64, + disposition: PluginHostShutdownDisposition, + rpc_completed: bool, + exit_code: Option, + started_at: Instant, +) -> PluginHostShutdownReport { + PluginHostShutdownReport { + generation, + disposition, + reaped: false, + rpc_completed, + exit_code, + duration_ms: elapsed_ms(started_at), + } +} + +fn elapsed_ms(started_at: Instant) -> u64 { + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +fn validate_config(config: &PluginHostConfig) -> Result<(), PluginHostError> { + if !config.entry.is_absolute() { + return Err(PluginHostError::RelativeEntry(config.entry.clone())); + } + if !config.cache_directory.is_absolute() { + return Err(PluginHostError::RelativeCacheDirectory( + config.cache_directory.clone(), + )); + } + if !config.log_file.is_absolute() { + return Err(PluginHostError::RelativeLogFile(config.log_file.clone())); + } + Ok(()) +} + +async fn complete_handshake( + stream: &mut TcpStream, + expected_token: &str, + cache_directory: &Path, +) -> Result { + let request = read_frame(stream, DEFAULT_MAX_FRAME_BYTES).await?; + let jsonrpc = request.get("jsonrpc").and_then(Value::as_str); + let method = request.get("method").and_then(Value::as_str); + let request_id = request + .get("id") + .and_then(Value::as_str) + .filter(|request_id| !request_id.is_empty()); + let params = request.get("params").and_then(Value::as_object); + let token = params + .and_then(|params| params.get("token")) + .and_then(Value::as_str); + let protocol_version = params + .and_then(|params| params.get("protocolVersion")) + .and_then(Value::as_u64); + let requested_frame_bytes = params + .and_then(|params| params.get("maxFrameBytes")) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()); + if jsonrpc != Some("2.0") + || method != Some("backend.handshake") + || request_id.is_none() + || request.get("result").is_some() + || request.get("error").is_some() + || token != Some(expected_token) + || protocol_version != Some(PROTOCOL_VERSION) + { + return Err(PluginHostError::InvalidHandshake( + "method, token, request id, or protocol version did not match".to_string(), + )); + } + let max_frame_bytes = requested_frame_bytes + .unwrap_or(DEFAULT_MAX_FRAME_BYTES) + .clamp(MIN_NEGOTIATED_FRAME_BYTES, MAX_FRAME_BYTES); + let response = json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": PROTOCOL_VERSION, + "maxFrameBytes": max_frame_bytes, + "cacheDirectory": cache_directory.to_string_lossy() + } + }); + write_frame(stream, &response, DEFAULT_MAX_FRAME_BYTES).await?; + Ok(max_frame_bytes) +} + +#[cfg(test)] +mod tests; diff --git a/src/crates/adapters/opencode-plugin-host/src/peer.rs b/src/crates/adapters/opencode-plugin-host/src/peer.rs new file mode 100644 index 0000000000..e2ebf9719c --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/peer.rs @@ -0,0 +1,353 @@ +use crate::peer_runtime::{run_reader, run_writer}; +use crate::{PluginHostError, PluginInstanceOpenRequest, PluginPrepareRequest}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, +}; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot, watch, Mutex, Notify, RwLock, Semaphore}; + +const OUTBOUND_CAPACITY: usize = 128; +const HANDLER_CONCURRENCY: usize = 32; + +pub(super) type HandlerFuture = + Pin> + Send>>; +pub(super) type Handler = Arc HandlerFuture + Send + Sync>; +pub(super) type PendingSender = oneshot::Sender>; + +#[derive(Debug, Clone)] +pub struct RpcHandlerError { + pub code: i64, + pub message: String, + pub data: Option, +} + +impl RpcHandlerError { + pub fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } +} + +#[derive(Clone)] +pub struct PluginHostClient { + state: Arc, +} + +impl PluginHostClient { + pub fn generation(&self) -> u64 { + self.state.generation + } + + pub fn is_closed(&self) -> bool { + self.state.closed.load(Ordering::Acquire) + } + + pub async fn set_log_level(&self, level: &str) -> Result<(), PluginHostError> { + let result = self + .request( + "host.log.setLevel", + json!({ "level": level }), + Duration::from_secs(5), + ) + .await?; + if result.get("level").and_then(Value::as_str) == Some(level) { + return Ok(()); + } + Err(PluginHostError::Protocol( + "host.log.setLevel returned an invalid level".to_string(), + )) + } + + pub async fn open_instance( + &self, + request: PluginInstanceOpenRequest, + deadline: Duration, + ) -> Result { + let params = serde_json::to_value(request) + .map_err(|error| PluginHostError::Protocol(error.to_string()))?; + self.request("host.instance.open", params, deadline).await + } + + pub async fn prepare_plugins( + &self, + request: PluginPrepareRequest, + deadline: Duration, + ) -> Result { + let params = serde_json::to_value(request) + .map_err(|error| PluginHostError::Protocol(error.to_string()))?; + self.request("host.plugins.prepare", params, deadline).await + } + + pub async fn close_instance( + &self, + instance_id: &str, + deadline: Duration, + ) -> Result { + let result = self + .request( + "host.instance.close", + json!({"instanceID": instance_id}), + deadline, + ) + .await?; + result + .get("closed") + .and_then(Value::as_bool) + .ok_or_else(|| { + PluginHostError::Protocol( + "host.instance.close returned an invalid result".to_string(), + ) + }) + } + + pub async fn request( + &self, + method: &str, + params: Value, + deadline: Duration, + ) -> Result { + self.request_inner(method, params, deadline, false).await + } + + pub(crate) async fn request_during_shutdown( + &self, + method: &str, + params: Value, + deadline: Duration, + ) -> Result { + self.request_inner(method, params, deadline, true).await + } + + async fn request_inner( + &self, + method: &str, + params: Value, + deadline: Duration, + allow_during_shutdown: bool, + ) -> Result { + let sequence = self.state.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let request_id = format!("backend:{}:{}", self.state.generation, sequence); + let (sender, receiver) = oneshot::channel(); + let exchange = async { + self.state + .register_pending(request_id.clone(), sender, allow_during_shutdown) + .await?; + log::debug!( + "Plugin host RPC request sending: generation={}, request_id={}, method={}", + self.state.generation, + request_id, + method + ); + self.state + .outbound + .send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + })) + .await + .map_err(|_| { + PluginHostError::ConnectionClosed("JSON-RPC writer is closed".to_string()) + })?; + receiver.await.map_err(|_| { + PluginHostError::ConnectionClosed("JSON-RPC response channel is closed".to_string()) + })? + }; + match tokio::time::timeout(deadline, exchange).await { + Ok(result) => { + if result.is_err() { + self.state.remove_pending(&request_id).await; + } + result + } + Err(_) => { + self.state.remove_pending(&request_id).await; + log::warn!( + "Plugin host RPC request timed out: generation={}, request_id={}, method={}", + self.state.generation, + request_id, + method + ); + Err(PluginHostError::RequestTimeout { + method: method.to_string(), + request_id, + }) + } + } + } + + pub async fn notify(&self, method: &str, params: Value) -> Result<(), PluginHostError> { + if self.state.draining.load(Ordering::Acquire) { + return Err(PluginHostError::ShuttingDown); + } + let permit = self.state.outbound.reserve().await.map_err(|_| { + PluginHostError::ConnectionClosed("JSON-RPC writer is closed".to_string()) + })?; + let _admission = self.state.admission.lock().await; + if self.state.draining.load(Ordering::Acquire) { + return Err(PluginHostError::ShuttingDown); + } + if self.is_closed() { + return Err(PluginHostError::ConnectionClosed( + "JSON-RPC peer is closed".to_string(), + )); + } + permit.send(json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + })); + log::debug!( + "Plugin host RPC notification sent: generation={}, method={}", + self.state.generation, + method + ); + Ok(()) + } + + pub async fn begin_draining(&self) -> usize { + let _admission = self.state.admission.lock().await; + self.state.draining.store(true, Ordering::Release); + let pending = self.state.pending.lock().await; + pending.len() + } + + pub async fn wait_for_pending(&self, deadline: Duration) -> bool { + let wait = async { + loop { + let notified = self.state.pending_empty.notified(); + if self.state.pending.lock().await.is_empty() { + return; + } + notified.await; + } + }; + tokio::time::timeout(deadline, wait).await.is_ok() + } + + pub async fn close(&self, reason: impl Into) { + self.state.close(reason.into()).await; + } + + pub async fn register_handler( + &self, + method: &str, + handler: F, + ) -> Result<(), PluginHostError> + where + F: Fn(Value) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let mut handlers = self.state.handlers.write().await; + if handlers.contains_key(method) { + return Err(PluginHostError::DuplicateHandler(method.to_string())); + } + handlers.insert( + method.to_string(), + Arc::new(move |params| Box::pin(handler(params))), + ); + Ok(()) + } +} + +pub struct JsonRpcPeer { + client: PluginHostClient, +} + +impl JsonRpcPeer { + pub fn start(stream: TcpStream, generation: u64, max_frame_bytes: usize) -> Self { + let (outbound, receiver) = mpsc::channel(OUTBOUND_CAPACITY); + let state = Arc::new(PeerState { + generation, + max_frame_bytes, + sequence: AtomicU64::new(0), + admission: Mutex::new(()), + pending: Mutex::new(HashMap::new()), + handlers: RwLock::new(HashMap::new()), + handler_limit: Arc::new(Semaphore::new(HANDLER_CONCURRENCY)), + outbound, + closed: AtomicBool::new(false), + draining: AtomicBool::new(false), + pending_empty: Notify::new(), + close_signal: watch::channel(false).0, + }); + let (reader, writer) = stream.into_split(); + tokio::spawn(run_reader(reader, state.clone())); + tokio::spawn(run_writer(writer, receiver, state.clone())); + Self { + client: PluginHostClient { state }, + } + } + + pub fn client(&self) -> PluginHostClient { + self.client.clone() + } +} + +pub(super) struct PeerState { + pub(super) generation: u64, + pub(super) max_frame_bytes: usize, + pub(super) sequence: AtomicU64, + pub(super) admission: Mutex<()>, + pub(super) pending: Mutex>, + pub(super) handlers: RwLock>, + pub(super) handler_limit: Arc, + pub(super) outbound: mpsc::Sender, + pub(super) closed: AtomicBool, + pub(super) draining: AtomicBool, + pub(super) pending_empty: Notify, + pub(super) close_signal: watch::Sender, +} + +impl PeerState { + async fn register_pending( + &self, + request_id: String, + sender: PendingSender, + allow_during_shutdown: bool, + ) -> Result<(), PluginHostError> { + let _admission = self.admission.lock().await; + let mut pending = self.pending.lock().await; + if self.draining.load(Ordering::Acquire) && !allow_during_shutdown { + return Err(PluginHostError::ShuttingDown); + } + if self.closed.load(Ordering::Acquire) { + return Err(PluginHostError::ConnectionClosed( + "JSON-RPC peer is closed".to_string(), + )); + } + pending.insert(request_id, sender); + Ok(()) + } + + pub(super) async fn remove_pending(&self, request_id: &str) -> Option { + let mut pending = self.pending.lock().await; + let sender = pending.remove(request_id); + if pending.is_empty() { + self.pending_empty.notify_waiters(); + } + sender + } + + pub(super) async fn close(&self, reason: String) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + self.close_signal.send_replace(true); + let pending = std::mem::take(&mut *self.pending.lock().await); + self.pending_empty.notify_waiters(); + for sender in pending.into_values() { + let _ = sender.send(Err(PluginHostError::ConnectionClosed(reason.clone()))); + } + } +} diff --git a/src/crates/adapters/opencode-plugin-host/src/peer_runtime.rs b/src/crates/adapters/opencode-plugin-host/src/peer_runtime.rs new file mode 100644 index 0000000000..ebe5d3d2ad --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/peer_runtime.rs @@ -0,0 +1,227 @@ +use crate::peer::{PeerState, RpcHandlerError}; +use crate::{read_frame, write_frame, PluginHostError}; +use serde_json::{json, Map, Value}; +use std::sync::Arc; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::{mpsc, OwnedSemaphorePermit}; + +pub(super) async fn run_reader(mut reader: OwnedReadHalf, state: Arc) { + let mut closed = state.close_signal.subscribe(); + if *closed.borrow() { + return; + } + loop { + let message = tokio::select! { + biased; + change = closed.changed() => { + let _ = change; + return; + } + result = read_frame(&mut reader, state.max_frame_bytes) => result, + }; + let result = match message { + Ok(message) => route_message(message, state.clone()).await, + Err(error) => Err(error), + }; + if let Err(error) = result { + state.close(error.to_string()).await; + return; + } + } +} + +pub(super) async fn run_writer( + mut writer: OwnedWriteHalf, + mut receiver: mpsc::Receiver, + state: Arc, +) { + let mut closed = state.close_signal.subscribe(); + if *closed.borrow() { + return; + } + loop { + let message = tokio::select! { + biased; + change = closed.changed() => { + let _ = change; + receiver.close(); + return; + } + message = receiver.recv() => message, + }; + let Some(message) = message else { + state + .close("JSON-RPC outbound channel is closed".to_string()) + .await; + receiver.close(); + return; + }; + if let Err(error) = write_frame(&mut writer, &message, state.max_frame_bytes).await { + state.close(error.to_string()).await; + receiver.close(); + return; + } + } +} + +async fn route_message(message: Value, state: Arc) -> Result<(), PluginHostError> { + let object = message.as_object().ok_or_else(|| { + PluginHostError::Protocol("JSON-RPC message must be an object".to_string()) + })?; + if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(protocol_error("JSON-RPC version must be 2.0")); + } + if object.contains_key("method") { + return route_request(object, state); + } + route_response(object, &state).await +} + +fn route_request( + object: &Map, + state: Arc, +) -> Result<(), PluginHostError> { + if object.contains_key("result") || object.contains_key("error") { + return Err(protocol_error( + "JSON-RPC request must not contain result or error", + )); + } + let method = object + .get("method") + .and_then(Value::as_str) + .filter(|method| !method.is_empty()) + .ok_or_else(|| protocol_error("JSON-RPC request has no non-empty string method"))?; + let request_id = match object.get("id") { + Some(Value::String(request_id)) if !request_id.is_empty() => Some(request_id.clone()), + Some(_) => { + return Err(protocol_error( + "JSON-RPC request id must be a non-empty string", + )) + } + None => None, + }; + let params = object.get("params").cloned().unwrap_or(Value::Null); + match state.handler_limit.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(dispatch_request( + state, + permit, + request_id, + method.to_string(), + params, + )); + } + Err(_) => reject_overloaded_request(&state, request_id)?, + } + Ok(()) +} + +fn reject_overloaded_request( + state: &PeerState, + request_id: Option, +) -> Result<(), PluginHostError> { + let Some(request_id) = request_id else { + return Ok(()); + }; + state + .outbound + .try_send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32000, + "message": "JSON-RPC handler concurrency limit reached" + } + })) + .map_err(|_| protocol_error("JSON-RPC outbound queue is full")) +} + +async fn route_response( + object: &Map, + state: &PeerState, +) -> Result<(), PluginHostError> { + let request_id = object + .get("id") + .and_then(Value::as_str) + .filter(|request_id| !request_id.is_empty()) + .ok_or_else(|| protocol_error("JSON-RPC response has no non-empty string id"))?; + let result = match (object.get("result"), object.get("error")) { + (Some(result), None) => Ok(result.clone()), + (None, Some(error)) => parse_rpc_error(error), + (Some(_), Some(_)) => Err(protocol_error( + "JSON-RPC response must not contain both result and error", + )), + (None, None) => Err(protocol_error( + "JSON-RPC response has neither result nor error", + )), + }; + let protocol_failure = result.as_ref().err().and_then(|error| match error { + PluginHostError::Protocol(message) => Some(message.clone()), + _ => None, + }); + if let Some(sender) = state.remove_pending(request_id).await { + log::debug!( + "Plugin host RPC response received: generation={}, request_id={}, outcome={}", + state.generation, + request_id, + if result.is_ok() { "success" } else { "error" } + ); + let _ = sender.send(result); + } + match protocol_failure { + Some(message) => Err(PluginHostError::Protocol(message)), + None => Ok(()), + } +} + +async fn dispatch_request( + state: Arc, + _permit: OwnedSemaphorePermit, + request_id: Option, + method: String, + params: Value, +) { + let handler = state.handlers.read().await.get(&method).cloned(); + let result = match handler { + Some(handler) => handler(params).await, + None => Err(RpcHandlerError::new( + -32601, + format!("Method not found: {method}"), + )), + }; + let Some(request_id) = request_id else { + return; + }; + let response = match result { + Ok(value) => json!({"jsonrpc": "2.0", "id": request_id, "result": value}), + Err(error) => json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": error.code, "message": error.message, "data": error.data}, + }), + }; + let _ = state.outbound.send(response).await; +} + +fn parse_rpc_error(value: &Value) -> Result { + let Some(object) = value.as_object() else { + return Err(protocol_error("JSON-RPC error must be an object")); + }; + let code = object + .get("code") + .and_then(Value::as_i64) + .ok_or_else(|| protocol_error("JSON-RPC error has no integer code"))?; + let message = object + .get("message") + .and_then(Value::as_str) + .ok_or_else(|| protocol_error("JSON-RPC error has no string message"))?; + Err(PluginHostError::Rpc { + code, + message: message.to_string(), + data: object.get("data").cloned(), + }) +} + +fn protocol_error(message: &str) -> PluginHostError { + PluginHostError::Protocol(message.to_string()) +} diff --git a/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs b/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs new file mode 100644 index 0000000000..bdcf33681b --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs @@ -0,0 +1,328 @@ +use crate::http::{StreamDescriptor, MAX_STREAM_CHUNK_BYTES}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use thiserror::Error; +use tokio::sync::{Mutex, Notify}; + +const DEFAULT_MAX_ACTIVE_STREAMS: usize = 128; +const DEFAULT_MAX_TOTAL_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamReadParams { + #[serde(rename = "instanceID")] + pub instance_id: String, + #[serde(rename = "streamID")] + pub stream_id: String, + pub max_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StreamReadResult { + pub data: String, + pub eof: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamCancelParams { + #[serde(rename = "instanceID")] + pub instance_id: String, + #[serde(rename = "streamID")] + pub stream_id: String, + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct StreamCancelResult { + pub cancelled: bool, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum StreamRegistryError { + #[error("response stream registry capacity was reached")] + Capacity, + #[error("response stream body exceeds the registry byte limit")] + BodyTooLarge, + #[error("maxBytes must be between 1 and {MAX_STREAM_CHUNK_BYTES}")] + InvalidMaxBytes, + #[error("response stream belongs to a different plugin instance")] + InstanceMismatch, +} + +#[derive(Clone)] +pub struct PluginHostStreamRegistry { + state: Arc>, + sequence: Arc, + changed: Arc, + max_active_streams: usize, + max_total_bytes: usize, +} + +struct StreamRegistryState { + streams: HashMap, + total_bytes: usize, +} + +struct ResponseStream { + instance_id: String, + bytes: Vec, + offset: usize, +} + +impl Default for PluginHostStreamRegistry { + fn default() -> Self { + Self::with_limits(DEFAULT_MAX_ACTIVE_STREAMS, DEFAULT_MAX_TOTAL_BYTES) + } +} + +impl PluginHostStreamRegistry { + pub fn with_limits(max_active_streams: usize, max_total_bytes: usize) -> Self { + Self { + state: Arc::new(Mutex::new(StreamRegistryState { + streams: HashMap::new(), + total_bytes: 0, + })), + sequence: Arc::new(AtomicU64::new(0)), + changed: Arc::new(Notify::new()), + max_active_streams, + max_total_bytes, + } + } + + pub async fn add( + &self, + instance_id: &str, + bytes: Vec, + ) -> Result { + let mut state = self.state.lock().await; + if state.streams.len() >= self.max_active_streams { + return Err(StreamRegistryError::Capacity); + } + if bytes.len() > self.max_total_bytes + || state.total_bytes.saturating_add(bytes.len()) > self.max_total_bytes + { + return Err(StreamRegistryError::BodyTooLarge); + } + let length = bytes.len(); + let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let stream_id = format!("backend-response-stream:{sequence}"); + state.total_bytes += length; + state.streams.insert( + stream_id.clone(), + ResponseStream { + instance_id: instance_id.to_string(), + bytes, + offset: 0, + }, + ); + Ok(StreamDescriptor { + stream_id, + length: Some(length), + }) + } + + pub async fn read( + &self, + params: StreamReadParams, + ) -> Result { + let max_bytes = params.max_bytes.unwrap_or(MAX_STREAM_CHUNK_BYTES); + if !(1..=MAX_STREAM_CHUNK_BYTES).contains(&max_bytes) { + return Err(StreamRegistryError::InvalidMaxBytes); + } + let mut state = self.state.lock().await; + let Some(stream) = state.streams.get_mut(¶ms.stream_id) else { + return Ok(StreamReadResult { + data: String::new(), + eof: true, + }); + }; + if stream.instance_id != params.instance_id { + return Err(StreamRegistryError::InstanceMismatch); + } + let end = stream + .offset + .saturating_add(max_bytes) + .min(stream.bytes.len()); + let data = BASE64_STANDARD.encode(&stream.bytes[stream.offset..end]); + stream.offset = end; + let eof = stream.offset == stream.bytes.len(); + if eof { + let removed = state + .streams + .remove(¶ms.stream_id) + .expect("stream exists"); + state.total_bytes = state.total_bytes.saturating_sub(removed.bytes.len()); + self.changed.notify_waiters(); + } + Ok(StreamReadResult { data, eof }) + } + + pub async fn cancel( + &self, + params: StreamCancelParams, + ) -> Result { + let mut state = self.state.lock().await; + let Some(stream) = state.streams.get(¶ms.stream_id) else { + return Ok(StreamCancelResult { cancelled: false }); + }; + if stream.instance_id != params.instance_id { + return Err(StreamRegistryError::InstanceMismatch); + } + let removed = state + .streams + .remove(¶ms.stream_id) + .expect("stream exists"); + state.total_bytes = state.total_bytes.saturating_sub(removed.bytes.len()); + self.changed.notify_waiters(); + Ok(StreamCancelResult { cancelled: true }) + } + + pub async fn cancel_instance(&self, instance_id: &str) -> usize { + let mut state = self.state.lock().await; + let stream_ids = state + .streams + .iter() + .filter(|(_, stream)| stream.instance_id == instance_id) + .map(|(stream_id, _)| stream_id.clone()) + .collect::>(); + for stream_id in &stream_ids { + if let Some(stream) = state.streams.remove(stream_id) { + state.total_bytes = state.total_bytes.saturating_sub(stream.bytes.len()); + } + } + if !stream_ids.is_empty() { + self.changed.notify_waiters(); + } + stream_ids.len() + } + + pub async fn cancel_all(&self) -> usize { + let mut state = self.state.lock().await; + let count = state.streams.len(); + state.streams.clear(); + state.total_bytes = 0; + if count > 0 { + self.changed.notify_waiters(); + } + count + } + + pub async fn active_count(&self) -> usize { + self.state.lock().await.streams.len() + } + + pub async fn wait_until_empty(&self, timeout: Duration) -> bool { + let wait = async { + loop { + let changed = self.changed.notified(); + if self.active_count().await == 0 { + return; + } + changed.await; + } + }; + tokio::time::timeout(timeout, wait).await.is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::{ + PluginHostStreamRegistry, StreamCancelParams, StreamReadParams, StreamRegistryError, + }; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64::Engine; + + #[tokio::test] + async fn reads_chunks_and_removes_stream_at_eof() { + let registry = PluginHostStreamRegistry::with_limits(2, 32); + let descriptor = registry + .add("instance:1", b"abcdef".to_vec()) + .await + .expect("add stream"); + let first = registry + .read(StreamReadParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id.clone(), + max_bytes: Some(2), + }) + .await + .expect("first chunk"); + assert_eq!(BASE64_STANDARD.decode(first.data).unwrap(), b"ab"); + assert!(!first.eof); + let second = registry + .read(StreamReadParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id.clone(), + max_bytes: Some(8), + }) + .await + .expect("second chunk"); + assert_eq!(BASE64_STANDARD.decode(second.data).unwrap(), b"cdef"); + assert!(second.eof); + assert_eq!(registry.active_count().await, 0); + } + + #[tokio::test] + async fn enforces_instance_ownership_and_cancel() { + let registry = PluginHostStreamRegistry::default(); + let descriptor = registry + .add("instance:1", b"body".to_vec()) + .await + .expect("add stream"); + assert_eq!( + registry + .read(StreamReadParams { + instance_id: "instance:2".to_string(), + stream_id: descriptor.stream_id.clone(), + max_bytes: None, + }) + .await, + Err(StreamRegistryError::InstanceMismatch) + ); + assert!( + registry + .cancel(StreamCancelParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id, + reason: Some("test".to_string()), + }) + .await + .expect("cancel") + .cancelled + ); + } + + #[tokio::test] + async fn waits_for_response_streams_to_drain() { + let registry = PluginHostStreamRegistry::default(); + let descriptor = registry + .add("instance:1", b"body".to_vec()) + .await + .expect("add stream"); + let waiter = { + let registry = registry.clone(); + tokio::spawn(async move { + registry + .wait_until_empty(std::time::Duration::from_secs(1)) + .await + }) + }; + registry + .cancel(StreamCancelParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id, + reason: Some("test".to_string()), + }) + .await + .expect("cancel"); + assert!(waiter.await.expect("wait task")); + } +} diff --git a/src/crates/adapters/opencode-plugin-host/src/tests.rs b/src/crates/adapters/opencode-plugin-host/src/tests.rs new file mode 100644 index 0000000000..8c71d21b90 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/tests.rs @@ -0,0 +1,604 @@ +use super::{ + accept_authenticated_connection, complete_handshake, read_frame, validate_config, write_frame, + PluginHost, PluginHostConfig, PluginHostError, PluginHostShutdownDisposition, + PluginHostShutdownPolicy, DEFAULT_MAX_FRAME_BYTES, MAX_FRAME_BYTES, MIN_NEGOTIATED_FRAME_BYTES, +}; +use serde_json::json; +use std::path::PathBuf; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::Command; + +mod peer_tests; + +#[test] +fn relative_entry_is_rejected_before_process_start() { + let config = PluginHostConfig { + runtime_command: PathBuf::from("bun"), + entry: PathBuf::from("dist/extension-host.js"), + working_directory: PathBuf::from("."), + cache_directory: std::env::temp_dir(), + log_file: std::env::temp_dir().join("plugin-host.log"), + log_level: "debug".to_string(), + }; + + assert!(matches!( + validate_config(&config), + Err(PluginHostError::RelativeEntry(_)) + )); +} + +#[tokio::test] +async fn handshake_accepts_matching_token_and_returns_cache_directory() { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let cache_directory = std::env::temp_dir().join("bitfun-plugin-host-test-cache"); + let expected_cache_directory = cache_directory.to_string_lossy().into_owned(); + let host = tokio::spawn(async move { + let mut stream = TcpStream::connect(address) + .await + .expect("fake host should connect"); + write_frame( + &mut stream, + &json!({ + "jsonrpc": "2.0", + "id": "host:1", + "method": "backend.handshake", + "params": { + "token": "test-token", + "protocolVersion": 1, + "opencodeVersion": "1.17.18", + "maxFrameBytes": DEFAULT_MAX_FRAME_BYTES + } + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("fake host should write handshake"); + read_frame(&mut stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("fake host should read handshake response") + }); + let (mut backend_stream, _) = listener + .accept() + .await + .expect("backend should accept fake host"); + + let negotiated = complete_handshake(&mut backend_stream, "test-token", &cache_directory) + .await + .expect("matching handshake should succeed"); + let response = host.await.expect("fake host task should finish"); + + assert_eq!(negotiated, DEFAULT_MAX_FRAME_BYTES); + assert_eq!( + response["result"]["cacheDirectory"], + expected_cache_directory + ); +} + +#[tokio::test] +async fn startup_timeout_covers_a_connected_client_that_never_handshakes() { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let _client = TcpStream::connect(address) + .await + .expect("fake host should connect"); + + let result = accept_authenticated_connection( + &listener, + "test-token", + &std::env::temp_dir(), + Duration::from_millis(25), + ) + .await; + + assert!(matches!(result, Err(PluginHostError::StartupTimeout))); +} + +#[tokio::test] +async fn startup_failure_waits_until_the_spawned_process_is_reaped() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("never-connects.mjs"); + tokio::fs::write( + &script, + r#"import fs from "node:fs"; +fs.writeFileSync("child.pid", String(process.pid)); +setInterval(() => {}, 1000); +"#, + ) + .await + .expect("startup failure fixture should be written"); + let result = PluginHost::start_with_timeout( + PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }, + Duration::from_secs(1), + ) + .await; + + assert!(matches!(result, Err(PluginHostError::StartupTimeout))); + let process_id = tokio::fs::read_to_string(directory.path().join("child.pid")) + .await + .expect("fixture should publish its process id") + .parse::() + .expect("fixture process id should be numeric"); + assert!( + !process_is_running(process_id).await, + "PluginHost::start must not return while its failed child is still alive" + ); +} + +#[cfg(windows)] +async fn process_is_running(process_id: u32) -> bool { + let filter = format!("PID eq {process_id}"); + Command::new("tasklist") + .args(["/FI", &filter, "/FO", "CSV", "/NH"]) + .output() + .await + .is_ok_and(|output| { + output.status.success() + && String::from_utf8_lossy(&output.stdout).contains(&format!("\"{process_id}\"")) + }) +} + +#[cfg(unix)] +async fn process_is_running(process_id: u32) -> bool { + Command::new("kill") + .args(["-0", &process_id.to_string()]) + .status() + .await + .is_ok_and(|status| status.success()) +} + +#[tokio::test] +async fn handshake_clamps_requested_frame_limit_to_the_safe_range() { + async fn negotiate(requested: usize) -> usize { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let host = tokio::spawn(async move { + let mut stream = TcpStream::connect(address) + .await + .expect("fake host should connect"); + write_frame( + &mut stream, + &json!({ + "jsonrpc": "2.0", + "id": "host:1", + "method": "backend.handshake", + "params": { + "token": "test-token", + "protocolVersion": 1, + "opencodeVersion": "1.17.18", + "maxFrameBytes": requested + } + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("fake host should write handshake"); + read_frame(&mut stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("fake host should read handshake response"); + }); + let (mut stream, _) = listener.accept().await.expect("backend should accept host"); + let negotiated = complete_handshake(&mut stream, "test-token", &std::env::temp_dir()) + .await + .expect("handshake should succeed"); + host.await.expect("fake host should finish"); + negotiated + } + + assert_eq!(negotiate(1).await, MIN_NEGOTIATED_FRAME_BYTES); + assert_eq!(negotiate(usize::MAX).await, MAX_FRAME_BYTES); +} + +#[tokio::test] +async fn node_child_connects_and_completes_authenticated_handshake() { + assert_runtime_child_connects("node").await; +} + +#[tokio::test] +async fn bun_child_connects_and_completes_authenticated_handshake() { + assert_runtime_child_connects("bun").await; +} + +#[tokio::test] +async fn configured_bun_host_connects_and_completes_authenticated_handshake() { + let Some(entry) = std::env::var_os("BITFUN_TEST_BUN_HOST_ENTRY").map(PathBuf::from) else { + return; + }; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let working_directory = entry + .parent() + .expect("configured Bun host entry should have a parent") + .to_path_buf(); + + let mut host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("bun"), + entry, + working_directory, + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("configured Bun host should complete handshake"); + + assert!(host.is_connected().expect("host status should be readable")); +} + +#[tokio::test] +async fn child_stdout_and_stderr_are_written_to_plugin_host_log() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("logging-host.mjs"); + let log_file = directory.path().join("logs").join("plugin-host.log"); + tokio::fs::write( + &script, + r#"import net from "node:net"; +console.log("fixture stdout"); +console.error("fixture stderr"); +console.error(`fixture level=${process.env.OPENCODE_EXTENSION_HOST_LOG_LEVEL}`); +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +const request = Buffer.from(JSON.stringify({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +const header = Buffer.alloc(4); +header.writeUInt32BE(request.length); +socket.write(Buffer.concat([header, request])); +"#, + ) + .await + .expect("fake plugin host should be written"); + + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: log_file.clone(), + log_level: "info".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + for _ in 0..20 { + let content = tokio::fs::read_to_string(&log_file) + .await + .unwrap_or_default(); + if content.contains("[stdout] fixture stdout") + && content.contains("[stderr] fixture stderr") + && content.contains("[stderr] fixture level=info") + { + drop(host); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let content = tokio::fs::read_to_string(&log_file) + .await + .expect("plugin host log should be readable"); + assert!(content.contains("[stdout] fixture stdout")); + assert!(content.contains("[stderr] fixture stderr")); + assert!(content.contains("[stderr] fixture level=info")); +} + +#[tokio::test] +async fn plugin_host_shutdown_waits_for_rpc_response_and_process_exit() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("shutdown-host.mjs"); + tokio::fs::write(&script, graceful_shutdown_fixture()) + .await + .expect("graceful shutdown fixture should be written"); + let log_file = directory.path().join("plugin-host.log"); + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: log_file.clone(), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + let descendant_id = tokio::fs::read_to_string(directory.path().join("descendant.pid")) + .await + .expect("graceful fixture should publish its descendant id") + .parse::() + .expect("descendant id should be numeric"); + + let report = host.shutdown(PluginHostShutdownPolicy::default()).await; + + assert_eq!(report.disposition, PluginHostShutdownDisposition::Graceful); + assert!(report.reaped); + assert!(report.rpc_completed); + assert_eq!(report.exit_code, Some(0)); + assert!( + !process_is_running(descendant_id).await, + "graceful shutdown must reap the whole process tree before returning" + ); + let log = tokio::fs::read_to_string(log_file) + .await + .expect("plugin host shutdown log should be readable"); + assert!(log.contains("[stdout] fixture shutdown complete")); +} + +#[tokio::test] +async fn plugin_host_shutdown_reports_a_nonzero_exit_as_not_graceful() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("failed-shutdown-host.mjs"); + tokio::fs::write(&script, failed_shutdown_fixture()) + .await + .expect("failed shutdown fixture should be written"); + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + + let report = host.shutdown(PluginHostShutdownPolicy::default()).await; + + assert_eq!( + report.disposition, + PluginHostShutdownDisposition::ExitedAfterShutdown + ); + assert!(report.rpc_completed); + assert_eq!(report.exit_code, Some(7)); +} + +#[tokio::test] +async fn plugin_host_shutdown_forces_a_host_that_ignores_shutdown_and_eof() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("hanging-shutdown-host.mjs"); + tokio::fs::write(&script, hanging_shutdown_fixture()) + .await + .expect("hanging shutdown fixture should be written"); + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + let policy = PluginHostShutdownPolicy { + drain_timeout: Duration::from_millis(50), + rpc_timeout: Duration::from_millis(50), + exit_timeout: Duration::from_millis(50), + eof_timeout: Duration::from_millis(50), + terminate_grace: Duration::from_millis(50), + }; + + let report = host.shutdown(policy).await; + + assert_eq!(report.disposition, PluginHostShutdownDisposition::Forced); + assert!(report.reaped); + assert!(!report.rpc_completed); + assert!(report.duration_ms < 2_000); +} + +async fn assert_runtime_child_connects(runtime_command: &str) { + let runtime_available = Command::new(runtime_command) + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("fake-host.mjs"); + tokio::fs::write( + &script, + r#"import net from "node:net"; +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +const request = Buffer.from(JSON.stringify({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +const header = Buffer.alloc(4); +header.writeUInt32BE(request.length); +socket.write(Buffer.concat([header, request])); +"#, + ) + .await + .expect("fake plugin host should be written"); + + let mut host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from(runtime_command), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + + assert!(host.is_connected().expect("host status should be readable")); +} + +fn graceful_shutdown_fixture() -> &'static str { + r#"import fs from "node:fs"; +import net from "node:net"; +import { spawn } from "node:child_process"; +const descendant = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }); +descendant.unref(); +fs.writeFileSync("descendant.pid", String(descendant.pid)); +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +let buffer = Buffer.alloc(0); +function send(message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length); + socket.write(Buffer.concat([header, payload])); +} +socket.on("connect", () => send({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (buffer.length < length + 4) return; + const message = JSON.parse(buffer.subarray(4, length + 4).toString()); + buffer = buffer.subarray(length + 4); + if (message.method === "host.shutdown") { + console.log("fixture shutdown complete"); + send({ jsonrpc: "2.0", id: message.id, result: { closed: true } }); + socket.end(); + } + } +}); +"# +} + +fn hanging_shutdown_fixture() -> &'static str { + r#"import net from "node:net"; +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +let buffer = Buffer.alloc(0); +function send(message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length); + socket.write(Buffer.concat([header, payload])); +} +socket.on("connect", () => send({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); +}); +setInterval(() => {}, 1000); +"# +} + +fn failed_shutdown_fixture() -> &'static str { + r#"import net from "node:net"; +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +let buffer = Buffer.alloc(0); +function send(message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length); + socket.write(Buffer.concat([header, payload])); +} +socket.on("connect", () => send({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (buffer.length < length + 4) return; + const message = JSON.parse(buffer.subarray(4, length + 4).toString()); + buffer = buffer.subarray(length + 4); + if (message.method === "host.shutdown") { + send({ jsonrpc: "2.0", id: message.id, result: { closed: true } }); + process.exitCode = 7; + socket.end(); + } + } +}); +"# +} diff --git a/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs b/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs new file mode 100644 index 0000000000..2bd189e1c0 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs @@ -0,0 +1,406 @@ +use crate::{ + read_frame, read_host_stream, write_frame, HostStreamReadError, JsonRpcPeer, PluginDeclaration, + PluginHostError, PluginInstanceOpenRequest, PluginPrepareRequest, StreamDescriptor, + DEFAULT_MAX_FRAME_BYTES, +}; +use serde_json::json; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; + +#[tokio::test] +async fn peer_correlates_out_of_order_responses_by_request_id() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 7, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let first = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("first request should be readable"); + let second = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("second request should be readable"); + assert_eq!(first["id"], "backend:7:1"); + assert_eq!(second["id"], "backend:7:2"); + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": second["id"], "result": second["params"]}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("second response should be written first"); + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": first["id"], "result": first["params"]}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("first response should be written second"); + }); + + let first = client.request("host.first", json!({"value": 1}), Duration::from_secs(1)); + let second = client.request("host.second", json!({"value": 2}), Duration::from_secs(1)); + let (first_result, second_result) = tokio::join!(first, second); + + assert_eq!( + first_result.expect("first request should resolve"), + json!({"value": 1}) + ); + assert_eq!( + second_result.expect("second request should resolve"), + json!({"value": 2}) + ); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn peer_handles_reentrant_host_request_while_backend_request_is_pending() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 8, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + client + .register_handler("backend.echo", |params| async move { Ok(params) }) + .await + .expect("handler should register"); + let host = tokio::spawn(async move { + let backend_request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("backend request should be readable"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": "host:2", + "method": "backend.echo", + "params": {"reentrant": true} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("reentrant request should be written"); + let reentrant_response = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("reentrant response should be readable"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": backend_request["id"], + "result": reentrant_response["result"] + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("backend response should be written"); + }); + + let result = client + .request("host.instance.open", json!({}), Duration::from_secs(1)) + .await + .expect("backend request should resolve after reentrant request"); + + assert_eq!(result, json!({"reentrant": true})); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn client_opens_a_typed_plugin_instance() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 13, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("instance open request should be readable"); + assert_eq!(request["method"], "host.instance.open"); + assert_eq!(request["params"]["instanceID"], "bitfun:test-instance"); + assert_eq!(request["params"]["plugins"][0]["spec"], "bitfun-demo-echo"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"instanceID": "bitfun:test-instance"} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("instance open response should be written"); + }); + + let result = client + .open_instance( + PluginInstanceOpenRequest { + instance_id: "bitfun:test-instance".to_string(), + project: json!({"id": "project", "worktree": "C:/workspace"}), + config: serde_json::Map::new(), + directory: "C:/workspace".to_string(), + worktree: "C:/workspace".to_string(), + plugins: vec![PluginDeclaration { + spec: "bitfun-demo-echo".to_string(), + options: None, + base_directory: None, + }], + configuration_fingerprint: Some("fixture-open".to_string()), + }, + Duration::from_secs(1), + ) + .await + .expect("instance open should resolve"); + + assert_eq!(result["instanceID"], "bitfun:test-instance"); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn client_prepares_typed_plugins() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 14, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("plugin prepare request should be readable"); + assert_eq!(request["method"], "host.plugins.prepare"); + assert_eq!( + request["params"]["configurationFingerprint"], + "fixture-prewarm" + ); + assert_eq!(request["params"]["plugins"][0]["spec"], "bitfun-demo-echo"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"prepared": [], "failed": [], "diagnostics": []} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("plugin prepare response should be written"); + }); + + let result = client + .prepare_plugins( + PluginPrepareRequest { + plugins: vec![PluginDeclaration { + spec: "bitfun-demo-echo".to_string(), + options: None, + base_directory: None, + }], + configuration_fingerprint: Some("fixture-prewarm".to_string()), + default_base_directory: None, + }, + Duration::from_secs(1), + ) + .await + .expect("plugin prepare should resolve"); + + assert_eq!(result["prepared"], json!([])); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn peer_fails_pending_requests_when_host_disconnects() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 9, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("request should be readable"); + }); + + let error = client + .request("host.never", json!({}), Duration::from_secs(1)) + .await + .expect_err("disconnect should fail the pending request"); + + assert!(matches!(error, PluginHostError::ConnectionClosed(_))); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn read_host_stream_cancels_after_an_invalid_response() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 15, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let read_request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("stream read request should be readable"); + assert_eq!(read_request["method"], "host.stream.read"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": read_request["id"], + "result": {"data": "not-base64!", "eof": true} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("invalid stream response should be written"); + let cancel_request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("stream cancel request should be readable"); + assert_eq!(cancel_request["method"], "host.stream.cancel"); + assert_eq!(cancel_request["params"]["instanceID"], "instance"); + assert_eq!(cancel_request["params"]["streamID"], "host-stream:1"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": cancel_request["id"], + "result": {"cancelled": true} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("stream cancel response should be written"); + }); + + let error = read_host_stream( + &client, + "instance", + &StreamDescriptor { + stream_id: "host-stream:1".to_string(), + length: None, + }, + 1024, + Duration::from_secs(1), + ) + .await + .expect_err("invalid base64 should fail the stream read"); + assert!(matches!(error, HostStreamReadError::InvalidBase64(_))); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn peer_rejects_response_with_result_and_error() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 10, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("request should be readable"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"invalid": true}, + "error": {"code": -32603, "message": "invalid envelope"} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("malformed response should be written"); + }); + + let error = client + .request("host.invalid", json!({}), Duration::from_secs(1)) + .await + .expect_err("malformed response should fail the request"); + + assert!(matches!(error, PluginHostError::Protocol(_))); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn draining_waits_for_admitted_request_and_rejects_new_requests() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 11, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let active = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("active request should be readable"); + assert_eq!(active["method"], "host.active"); + tokio::time::sleep(Duration::from_millis(50)).await; + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": active["id"], "result": {"done": true}}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("active response should be written"); + + let shutdown = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("shutdown request should be readable"); + assert_eq!(shutdown["method"], "host.shutdown"); + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": shutdown["id"], "result": {"closed": true}}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("shutdown response should be written"); + }); + + let active_client = client.clone(); + let active = tokio::spawn(async move { + active_client + .request("host.active", json!({}), Duration::from_secs(1)) + .await + }); + tokio::task::yield_now().await; + + let admitted = client.begin_draining().await; + assert_eq!(admitted, 1); + let error = client + .request("host.rejected", json!({}), Duration::from_secs(1)) + .await + .expect_err("new request should be rejected while draining"); + assert!(matches!(error, PluginHostError::ShuttingDown)); + assert!(client.wait_for_pending(Duration::from_secs(1)).await); + assert_eq!( + active + .await + .expect("active request task should finish") + .expect("active request should complete"), + json!({"done": true}) + ); + assert_eq!( + client + .request_during_shutdown("host.shutdown", json!({}), Duration::from_secs(1)) + .await + .expect("shutdown request should complete"), + json!({"closed": true}) + ); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn draining_rejects_new_notifications() { + let (backend_stream, _host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 12, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + client.begin_draining().await; + + let error = client + .notify("host.rejected", json!({})) + .await + .expect_err("new notification should be rejected while draining"); + + assert!(matches!(error, PluginHostError::ShuttingDown)); +} + +async fn connected_streams() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let host = tokio::spawn(async move { + TcpStream::connect(address) + .await + .expect("fake host should connect") + }); + let (backend_stream, _) = listener + .accept() + .await + .expect("backend should accept fake host"); + let host_stream = host.await.expect("fake host connection should finish"); + (backend_stream, host_stream) +} diff --git a/src/crates/adapters/webdriver/Cargo.toml b/src/crates/adapters/webdriver/Cargo.toml index 309426f905..27e815e57a 100644 --- a/src/crates/adapters/webdriver/Cargo.toml +++ b/src/crates/adapters/webdriver/Cargo.toml @@ -19,7 +19,7 @@ log = { workspace = true } tauri = { workspace = true } uuid = { workspace = true } base64 = { workspace = true } -image = { workspace = true } +image = { workspace = true, features = ["png"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = { workspace = true } diff --git a/src/crates/assembly/core/AGENTS-CN.md b/src/crates/assembly/core/AGENTS-CN.md index c22bb38398..603a09852f 100644 --- a/src/crates/assembly/core/AGENTS-CN.md +++ b/src/crates/assembly/core/AGENTS-CN.md @@ -91,6 +91,7 @@ SessionManager -> Session -> DialogTurn -> ModelRound - `docs/architecture/agent-runtime-services-design.md` - `src/crates/execution/agent-runtime/AGENTS.md` - `src/crates/execution/tool-contracts/AGENTS.md` +- `src/crates/execution/agent-workflows/AGENTS.md` - `src/crates/execution/harness/AGENTS.md` - `src/crates/contracts/product-domains/AGENTS.md` - `src/crates/contracts/runtime-ports/` 与 `src/crates/execution/runtime-services/` 源码说明 diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 36c2f0edb0..6beaf01e8b 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -70,6 +70,13 @@ SessionManager -> Session -> DialogTurn -> ModelRound for their respective capability contracts. Product surfaces consume product-level views and must not import adapter or raw plugin runtime client types. +- The managed OpenCode Plugin Host is an adapter/service resource. Core may + assemble its launch, retain opaque logical instance and PTY scope bindings, + and bridge matched requests to existing product owners. OpenCode route + matching, wire DTOs, serialization/error mapping, and physical process-tree + supervision stay in `opencode-plugin-host` and `services-core`; Core route + projections must not invent provider connectivity, VCS, permission, or other + owner state. - External-source Desktop, TUI, Peer, and Server surfaces share the versioned product-domain control DTO and closed generic actions. Capability-specific approvals and conflict choices remain typed owner operations; do not add a @@ -104,8 +111,9 @@ SessionManager -> Session -> DialogTurn -> ModelRound capability from Cargo's feature union. The Agent Runtime baseline plan is exactly `Basic` plus `AgentControl`, not a hidden delivery profile. `external-sources` adds third-party discovery/import adapters, - `plugin-runtime` adds executable plugin-client wiring. None may enable - `product-full`. + `plugin-runtime` adds executable plugin-client wiring, + `opencode-plugin-host` composes the managed Host and its reviewed route + owners. None may enable `product-full`. - CLI/ACP closure checks keep Cargo resolver-v2 normal and host (build/proc-macro) feature contexts separate, while treating all target-specific declarations within each context as one reviewed architecture @@ -159,6 +167,7 @@ Use these files for ownership details instead of expanding this guide: - `docs/architecture/agent-runtime-services-design.md` - `src/crates/execution/agent-runtime/AGENTS.md` - `src/crates/execution/tool-contracts/AGENTS.md` +- `src/crates/execution/agent-workflows/AGENTS.md` - `src/crates/execution/harness/AGENTS.md` - `src/crates/contracts/product-domains/AGENTS.md` - `src/crates/contracts/runtime-ports/` and `src/crates/execution/runtime-services/` source docs diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 398d7b56db..c25fe710d3 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -32,7 +32,7 @@ chrono-tz = { workspace = true, optional = true } cron = { workspace = true, optional = true } regex = { workspace = true, optional = true } base64 = { workspace = true, optional = true } -image = { workspace = true, optional = true } +image = { workspace = true, features = ["bmp", "gif", "jpeg", "png", "webp"], optional = true } md5 = { workspace = true, optional = true } hex = { workspace = true } rusqlite = { version = "0.32", features = ["bundled"], optional = true } @@ -65,8 +65,7 @@ bitfun-agent-content = { path = "../agent-content", optional = true } # Agent runtime owner contracts bitfun-agent-runtime = { path = "../../execution/agent-runtime", optional = true } - -# Harness workflow contracts +bitfun-agent-workflows = { path = "../../execution/agent-workflows", optional = true } bitfun-harness = { path = "../../execution/harness", optional = true } # Product capability pack contracts @@ -101,6 +100,7 @@ fluent-bundle = { workspace = true, optional = true } unic-langid = { workspace = true, optional = true } sha2 = { workspace = true } +urlencoding = { workspace = true } # QR code generation @@ -116,6 +116,7 @@ bitfun-runtime-services = { path = "../../execution/runtime-services", optional # Reviewed product-full plugin composition root. bitfun-opencode-adapter = { path = "../../adapters/opencode-adapter", optional = true } bitfun-dsh-adapter = { path = "../../adapters/dsh-adapter", optional = true } +bitfun-opencode-plugin-host = { path = "../../adapters/opencode-plugin-host", optional = true } bitfun-claude-code-adapter = { path = "../../adapters/claude-code-adapter", optional = true } bitfun-codex-adapter = { path = "../../adapters/codex-adapter", optional = true } bitfun-plugin-runtime-client = { path = "../../execution/plugin-runtime-client", optional = true } @@ -169,6 +170,7 @@ product-full = [ "canvas-runtime", "external-sources", "plugin-runtime", + "opencode-plugin-host", "announcement", "file-watch", "workspace-watch", @@ -196,6 +198,7 @@ agent-runtime = [ "dep:bitfun-agent-content", "dep:bitfun-agent-stream", "dep:bitfun-agent-tools", + "dep:bitfun-harness", "bitfun-agent-tools/computer-use-contract", "bitfun-runtime-ports/agent-api", "bitfun-runtime-ports/git-port", @@ -206,7 +209,6 @@ agent-runtime = [ "bitfun-runtime-ports/tool-runtime-handles", "bitfun-runtime-ports/workspace-ports", "dep:base64", - "dep:bitfun-harness", "dep:dashmap", "dep:filetime", "dep:flate2", @@ -305,7 +307,7 @@ web-tools = [ "tool-runtime/web-readable", ] deep-research = [ - "bitfun-agent-runtime?/deep-research", + "dep:bitfun-agent-workflows", "bitfun-services-integrations/deep-research", ] script-tool-runtime = [ @@ -371,6 +373,15 @@ plugin-runtime = [ "dep:bitfun-plugin-runtime-client", "bitfun-runtime-ports/plugin-runtime", ] +# Managed OpenCode Host assembly. Its HTTP compatibility routes consume the +# Agent lifecycle plus model, Git, and Remote Connect owners; keep that +# product-shaped closure separate from the portable plugin client boundary. +opencode-plugin-host = [ + "plugin-runtime", + "remote-connect", + "git", + "dep:bitfun-opencode-plugin-host", +] ai-adapter-runtime = [ "dep:bitfun-ai-adapters", ] diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md index c3ee710785..12c7e68790 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/SKILL.md @@ -105,6 +105,8 @@ Treat the bundled registry as the offline authority. Confirm it before targeting python scripts/sync_registry.py --check ``` +The check compares the exported Appearance contract, not provenance-only commit or timestamp fields. Unrelated BitFun commits do not invalidate a compatible snapshot. + Refresh it only from a clean checkout after BitFun changes Appearance descriptors: ```powershell diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json index 358c341f6b..d6773324d1 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/examples/cinematic-animated-wallpaper/references/surface-plan.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "scope": "example-style-selection", "styleId": "cinematic-animated-wallpaper", - "validatedAgainstRegistryRevision": "71fbdbb26757c930eedfef5cfda55ca12e008ae1", + "validatedAgainstRegistryRevision": "a47c21a1a5b0b28ef9d2c1e17b9e1cea1be6f94c", "scenes": { "workbench": { "parts": { diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json index 0a6aff8aff..d91ab316a4 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/appearance-registry.json @@ -3226,6 +3226,52 @@ } ] }, + { + "id": "acp-mode-selector", + "parts": [ + { + "id": "root", + "propertyProfile": "container" + }, + { + "id": "trigger", + "propertyProfile": "container" + }, + { + "id": "label", + "propertyProfile": "container" + }, + { + "id": "menu", + "propertyProfile": "container" + }, + { + "id": "header", + "propertyProfile": "container" + }, + { + "id": "option", + "propertyProfile": "container" + } + ], + "facets": [], + "states": [ + { + "id": "open", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"open\"]" + } + }, + { + "id": "selected", + "selector": { + "kind": "self", + "suffix": "[data-bf-state~=\"selected\"]" + } + } + ] + }, { "id": "flow-chat-header", "parts": [ diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/package-contract.md b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/package-contract.md index c8c9898060..dde51c8f2d 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/package-contract.md +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/references/package-contract.md @@ -117,7 +117,7 @@ The package rejects unsafe ZIP paths, symlinks, undeclared files, unsupported me ## Registry provenance -`appearance-registry.json` is a self-contained production registry snapshot. Consumers of this skill do not need BitFun source code for offline authoring. Query the snapshot through the script instead of editing it by hand. When a checkout is available, run `python scripts/sync_registry.py --check` before relying on the snapshot. +`appearance-registry.json` is a self-contained production registry snapshot. Consumers of this skill do not need BitFun source code for offline authoring. Query the snapshot through the script instead of editing it by hand. When a checkout is available, run `python scripts/sync_registry.py --check` before relying on the snapshot. The check compares exported contract content and ignores provenance-only commit or timestamp differences. Maintainers synchronize it with `scripts/sync_registry.py`. Provenance includes the source revision, dirty state, tree evidence hash, and generation timestamp; a matching commit hash alone does not prove that a dirty working tree is synchronized. diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/sync_registry.py b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/sync_registry.py index f4c67a6ae6..bdaa2abb6b 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/sync_registry.py +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/sync_registry.py @@ -14,12 +14,28 @@ DEFAULT_OUTPUT = Path(__file__).resolve().parent.parent / "references" / "appearance-registry.json" +CONTRACT_KEYS = ( + "components", + "scenes", + "renderers", + "defaultForceableProperties", + "cssTokenNames", + "widgetVariableNames", +) class SyncError(Exception): pass +def contract_view(value: dict[str, Any]) -> dict[str, Any]: + return { + "schema": value.get("schema"), + "schemaVersion": value.get("schemaVersion"), + **{key: value.get(key) for key in CONTRACT_KEYS}, + } + + def run(command: list[str], cwd: Path) -> str: result = subprocess.run(command, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False) if result.returncode != 0: @@ -114,16 +130,15 @@ def main() -> int: current = json.loads(output.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise SyncError(f"Could not read bundled registry: {error}") from error - comparable_expected = {key: value for key, value in output_value.items() if key != "generatedAt"} - comparable_current = {key: value for key, value in current.items() if key != "generatedAt"} - if comparable_current != comparable_expected: + if contract_view(current) != contract_view(output_value): raise SyncError( "Bundled registry differs from the selected BitFun checkout; " "run sync_registry.py without --check to refresh it" ) print(json.dumps({ "output": str(output), - "revision": revision, + "snapshotRevision": current.get("sourceRevision"), + "checkoutRevision": revision, "dirty": dirty, "synchronized": True, }, indent=2)) diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_bitfun_appearance.py b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_bitfun_appearance.py index c1723b6921..eee7abacf9 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_bitfun_appearance.py +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_bitfun_appearance.py @@ -5,6 +5,7 @@ import unittest import bitfun_appearance as appearance +import sync_registry as registry_sync def base_manifest() -> dict[str, object]: @@ -112,6 +113,30 @@ def test_contract_output_preserves_authoring_metadata(self) -> None: self.assertTrue(formatted_button["states"]) self.assertTrue(all("selector" in state for state in formatted_button["states"])) + def test_registry_contract_comparison_ignores_provenance_only_changes(self) -> None: + current = { + "schema": "bitfun.appearance.registry", + "schemaVersion": 1, + "sourceRevision": "old-revision", + "generatedAt": "old-time", + **{key: [] for key in registry_sync.CONTRACT_KEYS}, + } + checkout = { + **current, + "sourceRevision": "new-revision", + "generatedAt": "new-time", + } + self.assertEqual( + registry_sync.contract_view(current), + registry_sync.contract_view(checkout), + ) + + checkout["components"] = [{"id": "new-component"}] + self.assertNotEqual( + registry_sync.contract_view(current), + registry_sync.contract_view(checkout), + ) + if __name__ == "__main__": unittest.main() diff --git a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_cinematic_tools.py b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_cinematic_tools.py index 1d69cda7f4..8d87e99146 100644 --- a/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_cinematic_tools.py +++ b/src/crates/assembly/core/builtin_skills/create-bitfun-skin/scripts/test_cinematic_tools.py @@ -64,7 +64,10 @@ def test_surface_plan_contains_only_resolvable_palette_placeholders(self) -> Non plan = contract.load_surface_plan() self.assertEqual("example-style-selection", plan["scope"]) self.assertEqual("cinematic-animated-wallpaper", plan["styleId"]) - self.assertIsInstance(plan["validatedAgainstRegistryRevision"], str) + self.assertEqual( + build_support.registry_provenance()["sourceRevision"], + plan["validatedAgainstRegistryRevision"], + ) resolved = contract.resolve_palette_values(plan, palette["colors"]) serialized = json.dumps(resolved) self.assertNotIn('"kind": "palette"', serialized) diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 6ce995ee15..b8dba65560 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -46,6 +46,7 @@ pub use prompt_builder::{ pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; #[cfg(feature = "external-sources")] pub(crate) use registry::external_subagent_runtime_key; +pub(crate) use registry::initialize_global_agent_registry_for_profile; pub use registry::types::{ subagent_source_from_custom_kind, AgentCategory, AgentInfo, AgentSource, AgentToolPolicy, CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs index 48b9bb0d99..2f2bfe5280 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs @@ -1,7 +1,7 @@ use super::types::{AgentEntry, AgentSource}; use super::visibility::SubagentVisibilityPolicy; use super::AgentRegistry; -use crate::agentic::agents::registry::catalog::builtin_agent_specs; +use crate::agentic::agents::registry::catalog::{builtin_agent_specs, builtin_agent_specs_for_ids}; use crate::agentic::agents::{Agent, AgentCategory, SubAgentSource}; use bitfun_agent_runtime::agents as runtime_agents; use log::error; @@ -14,6 +14,18 @@ pub(crate) fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static s impl AgentRegistry { pub(crate) fn build_builtin_agents() -> HashMap { + Self::build_builtin_agents_from_specs(builtin_agent_specs()) + } + + fn build_builtin_agents_for_ids(agent_ids: &[&str]) -> HashMap { + Self::build_builtin_agents_from_specs(builtin_agent_specs_for_ids( + agent_ids.iter().copied(), + )) + } + + fn build_builtin_agents_from_specs( + specs: Vec, + ) -> HashMap { let mut agents = HashMap::new(); let register = |agents: &mut HashMap, @@ -39,7 +51,7 @@ impl AgentRegistry { ); }; - for spec in builtin_agent_specs() { + for spec in specs { let source = if spec.category == AgentCategory::SubAgent { Some(SubAgentSource::Builtin) } else { @@ -59,8 +71,17 @@ impl AgentRegistry { /// Create a new agent registry with built-in agents pub fn new() -> Self { + Self::from_builtin_agents(Self::build_builtin_agents()) + } + + pub(crate) fn for_profile(profile: bitfun_product_capabilities::DeliveryProfile) -> Self { + let plan = bitfun_product_capabilities::product_assembly_plan_for_profile(profile); + Self::from_builtin_agents(Self::build_builtin_agents_for_ids(plan.agent_ids())) + } + + fn from_builtin_agents(agents: HashMap) -> Self { Self { - agents: std::sync::RwLock::new(Self::build_builtin_agents()), + agents: std::sync::RwLock::new(agents), project_subagents: std::sync::RwLock::new(HashMap::new()), user_custom_agents_loaded: std::sync::RwLock::new(false), external_subagents: std::sync::Arc::new( diff --git a/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs index edf80039d4..ba65b28895 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs @@ -19,8 +19,22 @@ pub struct BuiltinAgentSpec { } pub fn builtin_agent_specs() -> Vec { + builtin_agent_specs_for_ids( + runtime_agents::builtin_agent_definition_specs() + .iter() + .map(|spec| spec.id), + ) +} + +pub(crate) fn builtin_agent_specs_for_ids<'a>( + agent_ids: impl IntoIterator, +) -> Vec { + let selected = agent_ids + .into_iter() + .collect::>(); runtime_agents::builtin_agent_definition_specs() .into_iter() + .filter(|spec| selected.contains(spec.id)) .map(|spec| BuiltinAgentSpec { factory: builtin_agent_factory(spec.id), category: spec.category, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 1952a3f9c4..a6fdb41951 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -228,15 +228,65 @@ impl bitfun_agent_runtime::sdk::RuntimeAgentRegistry for AgentRegistry { } } +struct GlobalAgentRegistry { + profile: Option, + registry: Arc, +} + // Global agent registry singleton -static GLOBAL_AGENT_REGISTRY: OnceLock> = OnceLock::new(); +static GLOBAL_AGENT_REGISTRY: OnceLock = OnceLock::new(); + +pub(crate) fn initialize_global_agent_registry_for_profile( + profile: bitfun_product_capabilities::DeliveryProfile, +) -> Result, String> { + if let Some(global) = GLOBAL_AGENT_REGISTRY.get() { + return if global.profile == Some(profile) { + Ok(global.registry.clone()) + } else { + Err(format!( + "Global agent registry already uses {}; cannot replace it with {}", + global + .profile + .map(|selected| selected.to_string()) + .unwrap_or_else(|| "the compatibility catalog".to_string()), + profile + )) + }; + } + + let _ = GLOBAL_AGENT_REGISTRY.set(GlobalAgentRegistry { + profile: Some(profile), + registry: Arc::new(AgentRegistry::for_profile(profile)), + }); + let global = GLOBAL_AGENT_REGISTRY + .get() + .expect("global agent registry must be initialized"); + if global.profile != Some(profile) { + return Err(format!( + "Global agent registry concurrently selected {}; requested {}", + global + .profile + .map(|selected| selected.to_string()) + .unwrap_or_else(|| "the compatibility catalog".to_string()), + profile + )); + } + Ok(global.registry.clone()) +} /// Get the global agent registry pub fn get_agent_registry() -> Arc { GLOBAL_AGENT_REGISTRY .get_or_init(|| { debug!("Initializing global agent registry"); - Arc::new(AgentRegistry::new()) + GlobalAgentRegistry { + #[cfg(feature = "product-full")] + profile: Some(bitfun_product_capabilities::DeliveryProfile::ProductFull), + #[cfg(not(feature = "product-full"))] + profile: None, + registry: Arc::new(AgentRegistry::new()), + } }) + .registry .clone() } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 836a7aa526..50ed72794d 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -451,6 +451,47 @@ fn memory_phase2_hidden_agent_is_registered() { assert_eq!(agent.name(), "Memory Phase 2"); } +#[test] +fn sdk_agent_registry_excludes_desktop_product_workflows() { + let registry = AgentRegistry::for_profile(bitfun_product_capabilities::DeliveryProfile::Sdk); + let plan = bitfun_product_capabilities::product_assembly_plan_for_profile( + bitfun_product_capabilities::DeliveryProfile::Sdk, + ); + + for product_agent in [ + "DeepResearch", + "ResearchSpecialist", + "DeepReview", + "CodeReview", + "ReviewWorker", + "ReviewJudge", + "ReviewFixer", + ] { + assert!( + !registry.check_agent_exists(product_agent), + "SDK registry must not contain {product_agent}" + ); + } + for code_agent in plan.agent_ids() { + assert!( + registry.check_agent_exists(code_agent), + "SDK registry must contain {code_agent}" + ); + } +} + +#[test] +fn product_full_agent_registry_preserves_the_complete_builtin_catalog() { + let mut selected = + AgentRegistry::for_profile(bitfun_product_capabilities::DeliveryProfile::ProductFull) + .agent_ids(RuntimeAgentRegistryQuery::default()); + let mut compatibility = AgentRegistry::new().agent_ids(RuntimeAgentRegistryQuery::default()); + selected.sort(); + compatibility.sort(); + + assert_eq!(selected, compatibility); +} + #[test] fn generate_doc_hidden_agent_defaults_to_fast() { assert_eq!(default_model_id_for_builtin_agent("GenerateDoc"), "fast"); diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 41bb644f54..7366deca23 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -10156,14 +10156,31 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet partial_result.text.len() ); if let Some(parent_info) = subagent_parent_info.as_ref() { - let event = self.session_manager.record_subagent_partial_timeout( - &parent_info.session_id, - &parent_info.dialog_turn_id, - &logical_agent_type, - &partial_result.text, - Some("timeout"), - ); - partial_result = partial_result.with_ledger_event_id(event.event_id); + match self + .session_manager + .record_subagent_partial_timeout( + &parent_info.session_id, + &parent_info.dialog_turn_id, + &logical_agent_type, + &partial_result.text, + Some("timeout"), + ) + .await + { + Ok(event) => { + partial_result = + partial_result.with_ledger_event_id(event.event_id); + } + Err(error) => { + warn!( + "Failed to persist partial subagent evidence: parent_session_id={}, parent_turn_id={}, agent_type={}, error={}", + parent_info.session_id, + parent_info.dialog_turn_id, + logical_agent_type, + error + ); + } + } } if let Err(cleanup_err) = self.cleanup_subagent_resources(&session_id).await { warn!( diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 392a128f93..bf700f2d00 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -5002,7 +5002,7 @@ impl ExecutionEngine { // dialog success) so other agents and failed turns are unaffected. #[cfg(feature = "deep-research")] { - if bitfun_agent_runtime::deep_research::should_post_process_research_report( + if bitfun_agent_workflows::deep_research::should_post_process_research_report( &agent_type, success, ) { diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 14e82898ee..4ac416b575 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -17,6 +17,9 @@ use crate::agentic::session::transcript_render::{ use crate::agentic::session::{ CoreSessionStorePort, SessionPromptCache, TokenAnchor, PROMPT_CACHE_SCHEMA_VERSION, }; +use crate::agentic::session::{ + EvidenceLedgerEvent, PersistedEvidenceLedgerFile, EVIDENCE_LEDGER_SCHEMA_VERSION, +}; use crate::agentic::skill_agent_snapshot::TurnSkillAgentSnapshot; use crate::infrastructure::PathManager; use crate::service::config::get_global_config_service; @@ -487,6 +490,8 @@ pub struct PersistenceManager { #[cfg(test)] fail_next_session_state_write: std::sync::Mutex>, #[cfg(test)] + fail_next_evidence_ledger_write: std::sync::Mutex>, + #[cfg(test)] fail_next_session_metadata_write: std::sync::Mutex>, #[cfg(test)] fail_next_session_metadata_rollback: std::sync::Mutex>, @@ -502,6 +507,8 @@ impl PersistenceManager { #[cfg(test)] fail_next_session_state_write: std::sync::Mutex::new(None), #[cfg(test)] + fail_next_evidence_ledger_write: std::sync::Mutex::new(None), + #[cfg(test)] fail_next_session_metadata_write: std::sync::Mutex::new(None), #[cfg(test)] fail_next_session_metadata_rollback: std::sync::Mutex::new(None), @@ -531,6 +538,14 @@ impl PersistenceManager { .expect("session state fault lock") = Some(session_id.to_string()); } + #[cfg(test)] + pub(crate) fn fail_next_evidence_ledger_write_for_test(&self, session_id: &str) { + *self + .fail_next_evidence_ledger_write + .lock() + .expect("evidence ledger fault lock") = Some(session_id.to_string()); + } + #[cfg(test)] pub(crate) fn fail_next_session_metadata_write_for_test(&self, session_id: &str) { *self @@ -635,6 +650,12 @@ impl PersistenceManager { self.session_layout(workspace_path).state_path(session_id) } + fn evidence_ledger_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { + self.session_layout(workspace_path) + .session_dir(session_id) + .join("evidence-ledger.json") + } + fn prompt_cache_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { self.session_layout(workspace_path) .prompt_cache_path(session_id) @@ -1586,6 +1607,152 @@ impl PersistenceManager { .await } + pub(crate) async fn load_evidence_ledger_events( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult> { + Self::validate_session_id(session_id)?; + let path = self.evidence_ledger_path(workspace_path, session_id); + let file = JsonFileStore + .read_locked_optional::(&path) + .await + .map_err(Self::json_store_error)?; + file.map(|file| { + file.validated_events(session_id) + .map_err(|error| BitFunError::parse(error.to_string())) + }) + .transpose() + .map(Option::unwrap_or_default) + } + + pub(crate) async fn append_evidence_ledger_event( + &self, + workspace_path: &Path, + event: &EvidenceLedgerEvent, + ) -> BitFunResult> { + Self::validate_session_id(&event.session_id)?; + let _session_write = + self.lock_session_write_operation(workspace_path, &event.session_id)?; + self.ensure_runtime_for_write(workspace_path).await?; + let persistence_lock = self + .get_session_persistence_lock(workspace_path, &event.session_id) + .await; + let _persistence_guard = persistence_lock.lock().await; + self.ensure_session_dir(workspace_path, &event.session_id) + .await?; + + #[cfg(test)] + { + let mut fault = self + .fail_next_evidence_ledger_write + .lock() + .expect("evidence ledger fault lock"); + if fault.as_deref() == Some(event.session_id.as_str()) { + *fault = None; + return Err(BitFunError::io("Injected evidence ledger write failure")); + } + } + + let path = self.evidence_ledger_path(workspace_path, &event.session_id); + let _file_lock = JsonFileStore + .acquire_cross_process_lock(&path) + .await + .map_err(Self::json_store_error)?; + let mut file = JsonFileStore + .read_optional::(&path) + .await + .map_err(Self::json_store_error)? + .unwrap_or_else(|| PersistedEvidenceLedgerFile::new(event.session_id.clone())); + file.append(event.clone()) + .map_err(|error| BitFunError::parse(error.to_string()))?; + file.schema_version = EVIDENCE_LEDGER_SCHEMA_VERSION; + JsonFileStore + .write_atomic_strict(&path, &file) + .await + .map_err(Self::json_store_error)?; + file.validated_events(&event.session_id) + .map_err(|error| BitFunError::parse(error.to_string())) + } + + pub(crate) async fn retain_evidence_ledger_events( + &self, + workspace_path: &Path, + session_id: &str, + surviving_turn_ids: &std::collections::HashSet, + ) -> BitFunResult>> { + Self::validate_session_id(session_id)?; + let _session_write = self.lock_session_write_operation(workspace_path, session_id)?; + let persistence_lock = self + .get_session_persistence_lock(workspace_path, session_id) + .await; + let _persistence_guard = persistence_lock.lock().await; + + let path = self.evidence_ledger_path(workspace_path, session_id); + let _file_lock = JsonFileStore + .acquire_cross_process_lock(&path) + .await + .map_err(Self::json_store_error)?; + if !path.exists() { + return Ok(None); + } + let Some(mut file) = JsonFileStore + .read_optional::(&path) + .await + .map_err(Self::json_store_error)? + else { + return Err(BitFunError::io(format!( + "Evidence ledger disappeared while retaining: {}", + path.display() + ))); + }; + let retained = file + .retain_turn_ids(session_id, surviving_turn_ids) + .map_err(|error| BitFunError::parse(error.to_string()))?; + file.schema_version = EVIDENCE_LEDGER_SCHEMA_VERSION; + JsonFileStore + .write_atomic_strict(&path, &file) + .await + .map_err(Self::json_store_error)?; + Ok(Some(retained)) + } + + /// Write a complete evidence ledger sidecar for a session. Used by session + /// branching to copy inherited evidence into the fork target. The caller + /// must already hold the session write lock for `session_id`. + pub(crate) async fn save_evidence_ledger_events( + &self, + workspace_path: &Path, + session_id: &str, + events: Vec, + ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; + let persistence_lock = self + .get_session_persistence_lock(workspace_path, session_id) + .await; + let _persistence_guard = persistence_lock.lock().await; + self.ensure_session_dir(workspace_path, session_id).await?; + let path = self.evidence_ledger_path(workspace_path, session_id); + let _file_lock = JsonFileStore + .acquire_cross_process_lock(&path) + .await + .map_err(Self::json_store_error)?; + let file = PersistedEvidenceLedgerFile { + schema_version: EVIDENCE_LEDGER_SCHEMA_VERSION, + session_id: session_id.to_string(), + events, + }; + // Validate before writing so a bad session_id on an event is caught. + file.clone() + .validated_events(session_id) + .map_err(|error| BitFunError::parse(error.to_string()))?; + JsonFileStore + .write_atomic_strict(&path, &file) + .await + .map_err(Self::json_store_error)?; + Ok(()) + } + pub async fn load_prompt_cache( &self, workspace_path: &Path, diff --git a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs index 97e6251950..3b279c0a5e 100644 --- a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs @@ -176,6 +176,35 @@ impl PersistenceManager { .await?; } + // Copy evidence ledger events for the branched turns, rewriting + // session_id to the target session so the fork inherits + // checkpoints, failed commands, and partial subagent results. + let source_evidence_events = self + .load_evidence_ledger_events(workspace_path, &request.source_session_id) + .await?; + if !source_evidence_events.is_empty() { + let copied_turn_ids: std::collections::HashSet = branched_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + let branched_evidence_events = source_evidence_events + .into_iter() + .filter(|event| copied_turn_ids.contains(&event.turn_id)) + .map(|mut event| { + event.session_id = target_session_id.clone(); + event + }) + .collect::>(); + if !branched_evidence_events.is_empty() { + self.save_evidence_ledger_events( + workspace_path, + &target_session_id, + branched_evidence_events, + ) + .await?; + } + } + let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 68051e56a5..06a190d858 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -348,6 +348,7 @@ pub struct SessionManager { Arc>, file_read_state_store: Arc, evidence_ledger: Arc, + evidence_ledger_operation_locks: Arc, persistence_manager: Arc, memory_database: Arc, @@ -660,7 +661,13 @@ impl SessionManager { } let config_service = get_global_config_service().await.ok()?; - config_service.get_config(Some("ai")).await.ok() + Self::load_effective_ai_config_from_service(config_service.as_ref()).await + } + + async fn load_effective_ai_config_from_service( + config_service: &crate::service::config::ConfigService, + ) -> Option { + config_service.get_effective_ai_config().await.ok() } pub(crate) async fn resolve_effective_reasoning_preset_for_turn( @@ -2027,6 +2034,7 @@ impl SessionManager { edit_constraints_store: Arc::new(DashMap::new()), file_read_state_store: Arc::new(FileReadStateStore::new()), evidence_ledger: Arc::new(SessionEvidenceLedger::new()), + evidence_ledger_operation_locks: Arc::new(KeyedAsyncLock::default()), persistence_manager, memory_database, config, @@ -2046,21 +2054,138 @@ impl SessionManager { self.persistence_manager.clone() } - pub fn append_evidence_event(&self, event: EvidenceLedgerEvent) -> EvidenceLedgerEvent { - self.evidence_ledger.append(event) + pub async fn append_evidence_event( + &self, + event: EvidenceLedgerEvent, + ) -> BitFunResult { + let _mutation_guard = self.lock_session_mutation(&event.session_id).await; + let _operation_guard = self + .evidence_ledger_operation_locks + .lock(&event.session_id) + .await; + let should_persist = self.config.enable_persistence + && self + .sessions + .get(&event.session_id) + .is_some_and(|session| self.should_persist_session(&session)); + if !should_persist { + return Ok(self.evidence_ledger.append(event)); + } + + let storage_path = self + .effective_session_storage_path(&event.session_id) + .await + .or_else(|| { + self.session_storage_path_index + .get(&event.session_id) + .map(|entry| entry.value().path.clone()) + }) + .ok_or_else(|| { + BitFunError::session(format!( + "Session storage path unavailable while persisting evidence: {}", + event.session_id + )) + })?; + let persisted_events = self + .persistence_manager + .append_evidence_ledger_event(&storage_path, &event) + .await?; + // Project the persisted events to the session's currently visible + // turns before publishing to memory. This prevents stale evidence + // (from a sidecar that has not yet been converged, e.g. after an + // older build rewrote session history) from re-entering the runtime. + let visible_events = { + let visible_turn_ids = self + .sessions + .get(&event.session_id) + .map(|session| { + session + .dialog_turn_ids + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + if visible_turn_ids.is_empty() { + persisted_events + } else { + persisted_events + .into_iter() + .filter(|e| visible_turn_ids.contains(&e.turn_id)) + .collect::>() + } + }; + self.evidence_ledger + .replace_session(&event.session_id, visible_events) + .map_err(|error| BitFunError::parse(error.to_string()))?; + Ok(event) + } + + /// Callers must hold the Session mutation boundary. The evidence operation + /// lock serializes this retention with evidence appends and restores. + /// + /// `prune_persisted_sidecar` must only be true for permanent history + /// truncations. Staged undo/redo keeps the sidecar complete so a later + /// redo can restore hidden evidence; committing the revert or performing a + /// legacy rollback permanently discards the hidden suffix, so those paths + /// prune the sidecar as well. + async fn retain_evidence_events_locked( + &self, + session_storage_path: Option<&Path>, + session_id: &str, + surviving_turn_ids: &HashSet, + prune_persisted_sidecar: bool, + ) -> BitFunResult<()> { + let _operation_guard = self.evidence_ledger_operation_locks.lock(session_id).await; + let storage_path = session_storage_path.ok_or_else(|| { + BitFunError::session(format!( + "Session storage path unavailable while retaining evidence: {}", + session_id + )) + })?; + if prune_persisted_sidecar { + let mut retained = Vec::new(); + if let Some(events) = self + .persistence_manager + .retain_evidence_ledger_events(storage_path, session_id, surviving_turn_ids) + .await? + { + retained = events; + } + self.evidence_ledger + .replace_session(session_id, retained) + .map_err(|error| BitFunError::parse(error.to_string()))?; + return Ok(()); + } + // Staged undo/redo only changes what this runtime can see. Rebuild + // memory from the untouched sidecar so redo can reveal hidden evidence + // without losing it from disk. + let sidecar_events = self + .persistence_manager + .load_evidence_ledger_events(storage_path, session_id) + .await?; + let retained = sidecar_events + .into_iter() + .filter(|event| surviving_turn_ids.contains(&event.turn_id)) + .collect::>(); + self.evidence_ledger + .replace_session(session_id, retained) + .map_err(|error| BitFunError::parse(error.to_string()))?; + Ok(()) } - pub fn record_checkpoint_created( + pub async fn record_checkpoint_created( &self, session_id: &str, turn_id: &str, tool_name: &str, target: &str, checkpoint: EvidenceLedgerCheckpoint, - ) -> EvidenceLedgerEvent { + ) -> BitFunResult { self.append_evidence_event(EvidenceLedgerEvent::checkpoint_created( session_id, turn_id, tool_name, target, checkpoint, )) + .await } pub fn evidence_events_for_turn( @@ -2089,14 +2214,14 @@ impl SessionManager { (!contract.is_empty()).then_some(contract) } - pub fn record_subagent_partial_timeout( + pub async fn record_subagent_partial_timeout( &self, session_id: &str, turn_id: &str, subagent_type: &str, partial_output: &str, error_kind: Option<&str>, - ) -> EvidenceLedgerEvent { + ) -> BitFunResult { let summary = format!( "Subagent {} timed out after producing partial output.", subagent_type @@ -2113,7 +2238,7 @@ impl SessionManager { .with_error_kind(error_kind.unwrap_or("timeout")) .with_partial_output(partial_output); - self.append_evidence_event(event) + self.append_evidence_event(event).await } /// Decide whether the given session model id is still usable. @@ -2360,6 +2485,7 @@ impl SessionManager { let edit_constraints_store = self.edit_constraints_store.clone(); let file_read_state_store = self.file_read_state_store.clone(); let evidence_ledger = self.evidence_ledger.clone(); + let evidence_ledger_operation_locks = self.evidence_ledger_operation_locks.clone(); let persistence_manager = self.persistence_manager.clone(); let memory_database = self.memory_database.clone(); let manager_config = self.config.clone(); @@ -2393,6 +2519,7 @@ impl SessionManager { edit_constraints_store, file_read_state_store, evidence_ledger, + evidence_ledger_operation_locks, persistence_manager, memory_database, config: manager_config, @@ -5519,6 +5646,7 @@ impl SessionManager { include_internal: bool, ) -> BitFunResult<(Session, Vec)> { let _mutation_guard = self.lock_session_mutation(session_id).await; + let _evidence_ledger_guard = self.evidence_ledger_operation_locks.lock(session_id).await; if self.is_session_loaded_from_storage_path(session_storage_path, session_id)? { let session = self.get_session(session_id).ok_or_else(|| { @@ -5621,6 +5749,37 @@ impl SessionManager { if let Some(revert) = staged_revert.as_ref() { persisted_turns.retain(|turn| turn.turn_index < revert.boundary_turn); } + let surviving_turn_ids: HashSet = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + let all_evidence_events = self + .persistence_manager + .load_evidence_ledger_events(session_storage_path, session_id) + .await?; + let restored_evidence_events = all_evidence_events + .iter() + .filter(|event| surviving_turn_ids.contains(&event.turn_id)) + .cloned() + .collect::>(); + // Converge the sidecar to surviving turns when there is no staged undo + // marker. A staged undo keeps the full sidecar on disk so a later redo + // can restore hidden evidence. Without this convergence, a stale event + // left by an older build (or a previous rollback) would re-enter memory + // on the next evidence append. + if staged_revert.is_none() && self.config.enable_persistence { + let should_converge = self + .sessions + .get(session_id) + .is_some_and(|session| self.should_persist_session(&session)) + || !session_already_in_memory; + if should_converge { + let converged_events = restored_evidence_events.clone(); + self.persistence_manager + .save_evidence_ledger_events(session_storage_path, session_id, converged_events) + .await?; + } + } debug!( "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", session_id, @@ -5992,6 +6151,9 @@ impl SessionManager { self.evidence_ledger.as_ref(), ); } + self.evidence_ledger + .replace_session(session_id, restored_evidence_events) + .map_err(|error| BitFunError::parse(error.to_string()))?; let context_replace_started_at = Instant::now(); self.context_store @@ -6056,8 +6218,10 @@ impl SessionManager { } /// Move the loaded Session to a persisted staged-revert boundary without - /// deleting any turn, context snapshot, or compression artifact. The - /// durable `session-revert.json` remains the authoritative visibility fact. + /// deleting any turn, context snapshot, compression artifact, or evidence + /// sidecar. The durable `session-revert.json` remains the authoritative + /// visibility fact, and the complete sidecar lets a later redo restore the + /// hidden evidence. pub(crate) async fn apply_staged_revert_context_locked( &self, session_storage_path: &Path, @@ -6134,6 +6298,17 @@ impl SessionManager { session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); } + let surviving_turn_ids = visible_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect::>(); + self.retain_evidence_events_locked( + Some(session_storage_path), + session_id, + &surviving_turn_ids, + false, + ) + .await?; Ok(()) } @@ -6157,12 +6332,8 @@ impl SessionManager { .save_session(session_storage_path, &session) .await?; } - // A durable revert marker means persisted Session artifacts exist even - // when automatic Session persistence is disabled for the current - // runtime (for example, an adapter restoring an explicitly selected - // history). Committing that marker must therefore always prune the - // persisted suffix; `enable_persistence` only controls automatic - // Session writes, not explicit history mutations. + // Committing the marker permanently discards the hidden suffix, so the + // evidence sidecar must be pruned to the surviving turns as well. self.persistence_manager .delete_dialog_turns_from(session_storage_path, session_id, boundary_turn) .await?; @@ -6193,6 +6364,13 @@ impl SessionManager { .unwrap_or_default(); self.rollback_edit_constraint_state_to_turns(session_id, &surviving_turn_ids) .await; + self.retain_evidence_events_locked( + Some(session_storage_path), + session_id, + &surviving_turn_ids, + true, + ) + .await?; let messages = self.context_store.get_context_messages(session_id); self.prune_token_anchors_to_messages(session_id, &messages) .await; @@ -6342,6 +6520,13 @@ impl SessionManager { .remove_from(session_id, target_turn); self.rollback_edit_constraint_state_to_turns(session_id, &surviving_dialog_turn_ids) .await; + self.retain_evidence_events_locked( + Some(workspace_path), + session_id, + &surviving_dialog_turn_ids, + true, + ) + .await?; Ok(()) } @@ -9376,12 +9561,14 @@ mod tests { ProcessingPhase, Session, SessionAgentRouteOwner, SessionConfig, SessionModelBindingPolicy, SessionState, ToolCall, ToolResult, TurnStats, }; - use crate::agentic::persistence::PersistenceManager; + use crate::agentic::persistence::{PersistenceManager, SessionBranchRequest}; use crate::agentic::session::{ revert::{SessionRevertPhase, SessionRevertState, SESSION_REVERT_SCHEMA_VERSION}, - PromptCachePolicy, PromptCacheScope, SessionContextStore, SystemPromptCacheIdentity, - UserContextCacheIdentity, + EvidenceLedgerCheckpoint, PersistedEvidenceLedgerFile, PromptCachePolicy, PromptCacheScope, + SessionContextStore, SystemPromptCacheIdentity, UserContextCacheIdentity, }; + #[cfg(feature = "remote-workspace")] + use crate::agentic::session::{EvidenceLedgerEventStatus, EvidenceLedgerTargetKind}; use crate::agentic::skill_agent_snapshot::{SkillSnapshotEntry, TurnSkillAgentSnapshot}; use crate::infrastructure::ai::reasoning_catalog::{ project_model_reasoning_catalog as project_test_model_reasoning_catalog, @@ -9394,6 +9581,7 @@ mod tests { model_runtime_binding_fingerprint as service_model_runtime_binding_fingerprint, AIConfig as ServiceAIConfig, AIModelConfig as ServiceAIModelConfig, }; + use crate::service::config::{ConfigManagerSettings, ConfigService}; use crate::service::session::{ DialogTurnData, DialogTurnKind, DialogTurnRecoveryStatus, ModelRoundData, SessionContextUsage, SessionContextUsageSource, SessionKind, SessionMetadata, @@ -9406,6 +9594,7 @@ mod tests { SessionExecutionTarget, }; use bitfun_runtime_ports::SessionStoragePathRequest; + use bitfun_services_core::session::SessionBranchBoundary; use dashmap::{try_result::TryResult, DashMap}; use serde_json::json; use std::collections::HashSet; @@ -9414,6 +9603,41 @@ mod tests { use std::time::{Duration, SystemTime}; use uuid::Uuid; + #[tokio::test] + async fn runtime_model_is_visible_to_turn_admission_config() { + let dir = tempfile::tempdir().expect("temporary config directory"); + let config = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(Arc::new(PathManager::with_user_root_for_tests( + dir.path().join("runtime-turn-admission"), + ))), + auto_save: true, + backup_count: 0, + }) + .await + .expect("test ConfigService"); + config + .install_runtime_ai_model(ServiceAIModelConfig { + id: "sdk:openai:fixture".to_string(), + name: "SDK fixture".to_string(), + provider: "openai".to_string(), + model_name: "fixture-model".to_string(), + base_url: "http://127.0.0.1:43123/v1".to_string(), + api_key: "fixture-secret".to_string(), + enabled: true, + ..ServiceAIModelConfig::default() + }) + .await + .unwrap(); + + let ai_config = SessionManager::load_effective_ai_config_from_service(&config) + .await + .expect("turn admission should see the runtime model"); + assert_eq!( + ai_config.resolve_model_reference("sdk:openai:fixture"), + Some("sdk:openai:fixture".to_string()) + ); + } + struct TestWorkspace { path: PathBuf, } @@ -9497,6 +9721,14 @@ mod tests { [durable_id.as_str()] ); assert!(sessions.contains_key(&transient_id)); + + assert!(SessionManager::collect_expired_session_candidates( + &sessions, + &transient_session_ids, + now, + Duration::MAX, + ) + .is_empty()); } #[test] @@ -9922,7 +10154,7 @@ mod tests { let persistence_manager = Arc::new( PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); - let manager = test_manager(persistence_manager); + let manager = test_manager(persistence_manager.clone()); let session = manager .create_session( "Recovery permission".to_string(), @@ -15631,13 +15863,16 @@ mod tests { Arc::new(PersistenceManager::new(test_path_manager()).expect("persistence manager")); let manager = test_manager(persistence_manager); - let event = manager.record_subagent_partial_timeout( - "session-a", - "turn-a", - "ReviewSecurity", - "Found token logging before timeout.", - Some("timeout"), - ); + let event = manager + .record_subagent_partial_timeout( + "session-a", + "turn-a", + "ReviewSecurity", + "Found token logging before timeout.", + Some("timeout"), + ) + .await + .expect("in-memory evidence should record"); assert!(!event.event_id.is_empty()); let events = manager.evidence_events_for_turn("session-a", "turn-a"); @@ -15647,6 +15882,1408 @@ mod tests { assert_eq!(summary.partial_subagent_results[0].event_id, event.event_id); } + #[tokio::test] + async fn evidence_ledger_persists_across_session_unload_and_restore() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Durable evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + let turn = DialogTurnData::new( + "turn-a".to_string(), + 0, + session.session_id.clone(), + UserMessageData { + id: "turn-a-user".to_string(), + content: "continue".to_string(), + timestamp: 1, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec!["turn-a".to_string()]; + let event = manager + .record_checkpoint_created( + &session.session_id, + "turn-a", + "Edit", + "src/lib.rs", + EvidenceLedgerCheckpoint { + current_branch: Some("feature/evidence".to_string()), + dirty_state_summary: "staged=0, unstaged=1, untracked=0".to_string(), + touched_files: vec!["src/lib.rs".to_string()], + diff_hash: Some("abc123".to_string()), + }, + ) + .await + .expect("checkpoint should persist before mutation"); + let ledger_path = storage_path + .join(&session.session_id) + .join("evidence-ledger.json"); + let stored: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read(&ledger_path).expect("ledger sidecar should exist"), + ) + .expect("ledger sidecar should deserialize"); + assert_eq!(stored.session_id, session.session_id); + assert_eq!(stored.events, vec![event.clone()]); + + assert!(manager + .unload_session_from_memory(&session.session_id) + .await + .expect("session should unload")); + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-a") + .is_empty()); + + manager + .restore_session_from_storage_path(&storage_path, &session.session_id) + .await + .expect("session should restore with evidence"); + assert_eq!( + manager.evidence_events_for_turn(&session.session_id, "turn-a"), + vec![event] + ); + let summary = manager.evidence_summary_for_session(&session.session_id, 10); + assert_eq!(summary.latest_checkpoints.len(), 1); + assert_eq!(summary.latest_checkpoints[0].target, "src/lib.rs"); + } + + fn evidence_event_ids(ledger_path: &std::path::Path) -> Vec { + let stored: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read(ledger_path).expect("ledger sidecar should exist"), + ) + .expect("ledger sidecar should deserialize"); + stored + .events + .iter() + .map(|event| event.event_id.clone()) + .collect() + } + + struct StagedEvidenceSession { + session_id: String, + ledger_path: PathBuf, + } + + async fn create_staged_evidence_session( + manager: &SessionManager, + persistence_manager: &PersistenceManager, + workspace: &TestWorkspace, + turn_count: usize, + ) -> StagedEvidenceSession { + let session = manager + .create_session( + "Staged evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + for index in 0..turn_count { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + let messages = (0..=index) + .map(|message_index| { + crate::agentic::core::Message::user(format!("prompt {message_index}")) + }) + .collect::>(); + persistence_manager + .save_turn_context_snapshot(workspace.path(), &session.session_id, index, &messages) + .await + .expect("context snapshot should save"); + } + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = (0..turn_count) + .map(|index| format!("turn-{index}")) + .collect(); + for index in 0..turn_count { + manager + .record_subagent_partial_timeout( + &session.session_id, + &format!("turn-{index}"), + "ReviewSecurity", + &format!("Partial turn {index}"), + Some("timeout"), + ) + .await + .expect("turn evidence should persist"); + } + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + let ledger_path = storage_path + .join(&session.session_id) + .join("evidence-ledger.json"); + StagedEvidenceSession { + session_id: session.session_id, + ledger_path, + } + } + + #[tokio::test] + async fn staged_revert_filters_memory_but_keeps_evidence_sidecar() { + use crate::agentic::session::revert::{ + SessionRevertPhase, SessionRevertState, SESSION_REVERT_SCHEMA_VERSION, + }; + + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Staged revert evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + for index in 0..3 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + let messages = (0..=index) + .map(|message_index| { + crate::agentic::core::Message::user(format!("prompt {message_index}")) + }) + .collect::>(); + persistence_manager + .save_turn_context_snapshot(workspace.path(), &session.session_id, index, &messages) + .await + .expect("context snapshot should save"); + } + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string(), + ]; + let turn_0_event = manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-0", + "ReviewSecurity", + "Partial turn 0", + Some("timeout"), + ) + .await + .expect("turn-0 evidence should persist"); + let turn_1_event = manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-1", + "ReviewTests", + "Partial turn 1", + Some("timeout"), + ) + .await + .expect("turn-1 evidence should persist"); + let turn_2_event = manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-2", + "Edit", + "Partial turn 2", + Some("timeout"), + ) + .await + .expect("turn-2 evidence should persist"); + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + let ledger_path = storage_path + .join(&session.session_id) + .join("evidence-ledger.json"); + let stored_before: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read(&ledger_path).expect("ledger sidecar should exist"), + ) + .expect("ledger sidecar should deserialize"); + assert_eq!( + stored_before + .events + .iter() + .map(|event| event.event_id.clone()) + .collect::>(), + vec![ + turn_0_event.event_id.clone(), + turn_1_event.event_id.clone(), + turn_2_event.event_id.clone(), + ] + ); + + let state = SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 2, + original_turn_end: 3, + phase: SessionRevertPhase::Staged, + workspace_checkpoint: Vec::new(), + }; + persistence_manager + .save_session_revert_state(workspace.path(), &session.session_id, &state) + .await + .expect("staged revert should persist"); + + manager + .apply_staged_revert_context_locked( + workspace.path(), + &session.session_id, + state.boundary_turn, + ) + .await + .expect("staged context should apply"); + + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-2") + .is_empty()); + assert_eq!( + manager.evidence_events_for_turn(&session.session_id, "turn-1"), + vec![turn_1_event.clone()] + ); + let stored_after: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read(&ledger_path).expect("ledger sidecar should still exist"), + ) + .expect("ledger sidecar should deserialize"); + assert_eq!( + stored_after.events, + vec![ + turn_0_event.clone(), + turn_1_event.clone(), + turn_2_event.clone() + ] + ); + + assert!(manager + .unload_session_from_memory(&session.session_id) + .await + .expect("session should unload")); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("staged session should restore"); + assert_eq!( + restored.dialog_turn_ids, + vec!["turn-0".to_string(), "turn-1".to_string()] + ); + assert_eq!( + manager.evidence_events_for_turn(&session.session_id, "turn-1"), + vec![turn_1_event.clone()] + ); + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-2") + .is_empty()); + assert_eq!( + evidence_event_ids(&ledger_path), + vec![ + turn_0_event.event_id.clone(), + turn_1_event.event_id.clone(), + turn_2_event.event_id.clone(), + ] + ); + assert_eq!( + manager + .evidence_summary_for_session(&session.session_id, 10) + .partial_subagent_results + .len(), + 2 + ); + } + + #[tokio::test] + async fn staged_undo_then_redo_restores_evidence_from_sidecar() { + use crate::agentic::session::revert::{ + SessionRevertPhase, SessionRevertState, SESSION_REVERT_SCHEMA_VERSION, + }; + + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let staged = + create_staged_evidence_session(&manager, &persistence_manager, &workspace, 3).await; + let original_ledger = evidence_event_ids(&staged.ledger_path); + assert_eq!(original_ledger.len(), 3); + + let state = SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 2, + original_turn_end: 3, + phase: SessionRevertPhase::Staged, + workspace_checkpoint: Vec::new(), + }; + persistence_manager + .save_session_revert_state(workspace.path(), &staged.session_id, &state) + .await + .expect("staged undo should persist"); + let _mutation = manager + .acquire_session_mutation(&staged.session_id) + .await + .expect("session mutation"); + manager + .apply_staged_revert_context_locked( + workspace.path(), + &staged.session_id, + state.boundary_turn, + ) + .await + .expect("staged undo should apply"); + assert!(manager + .evidence_events_for_turn(&staged.session_id, "turn-2") + .is_empty()); + assert_eq!(evidence_event_ids(&staged.ledger_path), original_ledger); + + // Redo clears the staged boundary back to the original end. The intact + // sidecar must repopulate memory with the hidden turn evidence. + manager + .apply_staged_revert_context_locked( + workspace.path(), + &staged.session_id, + state.original_turn_end, + ) + .await + .expect("redo should reapply the full boundary"); + assert_eq!( + manager + .evidence_events_for_turn(&staged.session_id, "turn-2") + .len(), + 1 + ); + persistence_manager + .delete_session_revert_state(workspace.path(), &staged.session_id) + .await + .expect("redo marker should clear"); + assert_eq!( + manager + .get_session(&staged.session_id) + .expect("session should remain active") + .dialog_turn_ids, + vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string() + ] + ); + assert_eq!(evidence_event_ids(&staged.ledger_path), original_ledger); + assert_eq!( + manager + .evidence_summary_for_session(&staged.session_id, 10) + .partial_subagent_results + .len(), + 3 + ); + } + + #[tokio::test] + async fn consecutive_staged_undo_and_redo_keep_sidecar_evidence() { + use crate::agentic::session::revert::{ + SessionRevertPhase, SessionRevertState, SESSION_REVERT_SCHEMA_VERSION, + }; + + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let staged = + create_staged_evidence_session(&manager, &persistence_manager, &workspace, 3).await; + let original_ledger = evidence_event_ids(&staged.ledger_path); + assert_eq!(original_ledger.len(), 3); + let _mutation = manager + .acquire_session_mutation(&staged.session_id) + .await + .expect("session mutation"); + + for boundary in [2usize, 1, 0] { + let state = SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: boundary, + original_turn_end: 3, + phase: SessionRevertPhase::Staged, + workspace_checkpoint: Vec::new(), + }; + persistence_manager + .save_session_revert_state(workspace.path(), &staged.session_id, &state) + .await + .expect("staged undo should persist"); + manager + .apply_staged_revert_context_locked(workspace.path(), &staged.session_id, boundary) + .await + .expect("staged undo should apply"); + } + assert!(manager + .evidence_events_for_turn(&staged.session_id, "turn-2") + .is_empty()); + assert!(manager + .evidence_events_for_turn(&staged.session_id, "turn-1") + .is_empty()); + assert_eq!(evidence_event_ids(&staged.ledger_path), original_ledger); + + for boundary in [1usize, 2, 3] { + let state = SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: boundary, + original_turn_end: 3, + phase: SessionRevertPhase::Staged, + workspace_checkpoint: Vec::new(), + }; + persistence_manager + .save_session_revert_state(workspace.path(), &staged.session_id, &state) + .await + .expect("staged redo should persist"); + manager + .apply_staged_revert_context_locked(workspace.path(), &staged.session_id, boundary) + .await + .expect("staged redo should apply"); + } + persistence_manager + .delete_session_revert_state(workspace.path(), &staged.session_id) + .await + .expect("redo marker should clear"); + assert_eq!( + manager + .get_session(&staged.session_id) + .expect("session should remain active") + .dialog_turn_ids, + vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string() + ] + ); + assert_eq!(evidence_event_ids(&staged.ledger_path), original_ledger); + assert_eq!( + manager + .evidence_events_for_turn(&staged.session_id, "turn-2") + .len(), + 1 + ); + assert_eq!( + manager + .evidence_events_for_turn(&staged.session_id, "turn-1") + .len(), + 1 + ); + } + + #[tokio::test] + async fn restoring_clearing_phase_keeps_redo_evidence_available() { + use crate::agentic::session::revert::{ + SessionRevertPhase, SessionRevertState, SESSION_REVERT_SCHEMA_VERSION, + }; + + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let staged = + create_staged_evidence_session(&manager, &persistence_manager, &workspace, 3).await; + let original_ledger = evidence_event_ids(&staged.ledger_path); + assert_eq!(original_ledger.len(), 3); + + let state = SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 3, + original_turn_end: 3, + phase: SessionRevertPhase::Clearing, + workspace_checkpoint: Vec::new(), + }; + persistence_manager + .save_session_revert_state(workspace.path(), &staged.session_id, &state) + .await + .expect("clearing marker should persist"); + assert!(manager + .unload_session_from_memory(&staged.session_id) + .await + .expect("session should unload")); + + manager + .restore_session(workspace.path(), &staged.session_id) + .await + .expect("clearing session should restore"); + let _mutation = manager + .acquire_session_mutation(&staged.session_id) + .await + .expect("session mutation"); + manager + .apply_staged_revert_context_locked( + workspace.path(), + &staged.session_id, + state.boundary_turn, + ) + .await + .expect("clearing boundary should reapply"); + persistence_manager + .delete_session_revert_state(workspace.path(), &staged.session_id) + .await + .expect("clearing marker should clear"); + assert_eq!( + manager + .get_session(&staged.session_id) + .expect("session should restore") + .dialog_turn_ids, + vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string() + ] + ); + assert_eq!( + manager + .evidence_events_for_turn(&staged.session_id, "turn-2") + .len(), + 1 + ); + assert_eq!(evidence_event_ids(&staged.ledger_path), original_ledger); + assert_eq!( + manager + .evidence_summary_for_session(&staged.session_id, 10) + .partial_subagent_results + .len(), + 3 + ); + } + + #[tokio::test] + async fn staged_revert_prunes_evidence_sidecar_without_automatic_persistence() { + use crate::agentic::session::revert::{ + SessionRevertPhase, SessionRevertState, SESSION_REVERT_SCHEMA_VERSION, + }; + + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let writer = test_manager(persistence_manager.clone()); + let session = writer + .create_session( + "Staged revert explicit history evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + for index in 0..2 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + let messages = (0..=index) + .map(|message_index| { + crate::agentic::core::Message::user(format!("prompt {message_index}")) + }) + .collect::>(); + persistence_manager + .save_turn_context_snapshot(workspace.path(), &session.session_id, index, &messages) + .await + .expect("context snapshot should save"); + } + writer + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec!["turn-0".to_string(), "turn-1".to_string()]; + let turn_0_event = writer + .record_subagent_partial_timeout( + &session.session_id, + "turn-0", + "ReviewSecurity", + "Partial turn 0", + Some("timeout"), + ) + .await + .expect("turn-0 evidence should persist"); + let _turn_1_event = writer + .record_subagent_partial_timeout( + &session.session_id, + "turn-1", + "ReviewTests", + "Partial turn 1", + Some("timeout"), + ) + .await + .expect("turn-1 evidence should persist"); + let storage_path = writer + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + persistence_manager + .save_session_revert_state( + workspace.path(), + &session.session_id, + &SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 1, + original_turn_end: 2, + phase: SessionRevertPhase::Committing, + workspace_checkpoint: Vec::new(), + }, + ) + .await + .expect("staged revert should persist"); + assert!(writer + .unload_session_from_memory(&session.session_id) + .await + .expect("session should unload")); + + let manager = test_manager_with_config( + persistence_manager.clone(), + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + ); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("explicit history should restore"); + assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); + manager + .commit_staged_revert_context_locked(&storage_path, &session.session_id, 1) + .await + .expect("staged revert should commit without automatic persistence"); + + let stored: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read( + storage_path + .join(&session.session_id) + .join("evidence-ledger.json"), + ) + .expect("ledger sidecar should still exist"), + ) + .expect("ledger sidecar should deserialize"); + assert_eq!(stored.events, vec![turn_0_event.clone()]); + assert_eq!( + manager.evidence_events_for_turn(&session.session_id, "turn-0"), + vec![turn_0_event] + ); + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-1") + .is_empty()); + assert_eq!( + manager + .evidence_summary_for_session(&session.session_id, 10) + .partial_subagent_results + .len(), + 1 + ); + } + + #[tokio::test] + async fn legacy_rollback_prunes_evidence_ledger_to_surviving_turn_ids() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Rollback evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + for index in 0..2 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + let messages = (0..=index) + .map(|message_index| { + crate::agentic::core::Message::user(format!("prompt {message_index}")) + }) + .collect::>(); + persistence_manager + .save_turn_context_snapshot(workspace.path(), &session.session_id, index, &messages) + .await + .expect("context snapshot should save"); + } + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec!["turn-0".to_string(), "turn-1".to_string()]; + let turn_0_event = manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-0", + "ReviewSecurity", + "Partial turn 0", + Some("timeout"), + ) + .await + .expect("turn-0 evidence should persist"); + let _turn_1_event = manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-1", + "ReviewTests", + "Partial turn 1", + Some("timeout"), + ) + .await + .expect("turn-1 evidence should persist"); + + manager + .rollback_context_to_turn_start(workspace.path(), &session.session_id, 1) + .await + .expect("rollback should succeed"); + + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-1") + .is_empty()); + assert_eq!( + manager.evidence_events_for_turn(&session.session_id, "turn-0"), + vec![turn_0_event.clone()] + ); + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + let stored: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read( + storage_path + .join(&session.session_id) + .join("evidence-ledger.json"), + ) + .expect("ledger sidecar should exist"), + ) + .expect("ledger sidecar should deserialize"); + assert_eq!(stored.events, vec![turn_0_event.clone()]); + + assert!(manager + .unload_session_from_memory(&session.session_id) + .await + .expect("session should unload")); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("rolled-back session should restore"); + assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); + assert_eq!( + manager.evidence_events_for_turn(&session.session_id, "turn-0"), + vec![turn_0_event] + ); + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-1") + .is_empty()); + let contract = manager + .compression_contract_for_session(&session.session_id, 10) + .expect("compression contract should be available"); + assert!( + contract + .subagent_statuses + .iter() + .all(|item| item.target != "ReviewTests"), + "rolled-back turn evidence must not enter the compression contract" + ); + assert!(contract + .subagent_statuses + .iter() + .any(|item| item.target == "ReviewSecurity")); + assert_eq!( + manager + .evidence_summary_for_session(&session.session_id, 10) + .partial_subagent_results + .len(), + 1 + ); + } + + #[tokio::test] + async fn evidence_ledger_write_failure_does_not_publish_memory_only_evidence() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Evidence write failure".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + persistence_manager.fail_next_evidence_ledger_write_for_test(&session.session_id); + + manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-a", + "ReviewSecurity", + "Partial result", + Some("timeout"), + ) + .await + .expect_err("durable append failure must be visible"); + + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-a") + .is_empty()); + assert!(manager + .evidence_summary_for_session(&session.session_id, 10) + .partial_subagent_results + .is_empty()); + } + + #[tokio::test] + async fn concurrent_evidence_appends_keep_disk_and_memory_complete() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = Arc::new(test_manager(persistence_manager)); + let session = manager + .create_session( + "Concurrent evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + + let first_manager = manager.clone(); + let first_session_id = session.session_id.clone(); + let first = tokio::spawn(async move { + first_manager + .record_subagent_partial_timeout( + &first_session_id, + "turn-a", + "ReviewSecurity", + "First partial result", + Some("timeout"), + ) + .await + .expect("first evidence append") + }); + let second_manager = manager.clone(); + let second_session_id = session.session_id.clone(); + let second = tokio::spawn(async move { + second_manager + .record_subagent_partial_timeout( + &second_session_id, + "turn-b", + "ReviewTests", + "Second partial result", + Some("timeout"), + ) + .await + .expect("second evidence append") + }); + let first = first.await.expect("first append task"); + let second = second.await.expect("second append task"); + + let ledger_path = storage_path + .join(&session.session_id) + .join("evidence-ledger.json"); + let stored: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read(ledger_path).expect("ledger sidecar should exist"), + ) + .expect("ledger sidecar should deserialize"); + let mut stored_ids = stored + .events + .into_iter() + .map(|event| event.event_id) + .collect::>(); + let mut memory_ids = manager + .evidence_ledger + .events_for_session(&session.session_id) + .into_iter() + .map(|event| event.event_id) + .collect::>(); + let mut expected_ids = vec![first.event_id, second.event_id]; + stored_ids.sort(); + memory_ids.sort(); + expected_ids.sort(); + + assert_eq!(stored_ids, expected_ids); + assert_eq!(memory_ids, expected_ids); + } + + #[tokio::test] + async fn corrupt_evidence_ledger_blocks_restore_without_overwriting_original_bytes() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Corrupt evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + let ledger_path = storage_path + .join(&session.session_id) + .join("evidence-ledger.json"); + let corrupt_bytes = b"{not valid evidence"; + std::fs::write(&ledger_path, corrupt_bytes).expect("corrupt fixture should write"); + assert!(manager + .unload_session_from_memory(&session.session_id) + .await + .expect("session should unload")); + + manager + .restore_session_from_storage_path(&storage_path, &session.session_id) + .await + .expect_err("corrupt evidence must not degrade to an empty ledger"); + + assert!(manager.get_session(&session.session_id).is_none()); + assert_eq!( + std::fs::read(&ledger_path).expect("corrupt sidecar should remain"), + corrupt_bytes + ); + } + + #[tokio::test] + async fn corrupt_evidence_ledger_blocks_retention_without_overwriting_original_bytes() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Corrupt retention evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let ledger_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path") + .join(&session.session_id) + .join("evidence-ledger.json"); + let corrupt_bytes = b"{not valid evidence"; + std::fs::write(&ledger_path, corrupt_bytes).expect("corrupt fixture should write"); + + persistence_manager + .retain_evidence_ledger_events(workspace.path(), &session.session_id, &HashSet::new()) + .await + .expect_err("corrupt evidence must not degrade to an empty ledger"); + + assert_eq!( + std::fs::read(&ledger_path).expect("corrupt sidecar should remain"), + corrupt_bytes + ); + } + + #[cfg(feature = "remote-workspace")] + #[tokio::test] + async fn remote_workspace_evidence_uses_the_resolved_session_mirror() { + let workspace = TestWorkspace::new(); + let path_manager = workspace.path_manager(); + let persistence_manager = + Arc::new(PersistenceManager::new(path_manager.clone()).expect("persistence manager")); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Remote evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some("/home/wsp/project".to_string()), + remote_connection_id: Some("ssh-1".to_string()), + remote_ssh_host: Some("dev-host".to_string()), + ..Default::default() + }, + ) + .await + .expect("remote session should create"); + let event = manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-a", + "ReviewSecurity", + "Remote partial result", + Some("timeout"), + ) + .await + .expect("remote evidence should persist"); + let sessions_dir = crate::service::WorkspaceRuntimeService::new(path_manager) + .context_for_remote_workspace("dev-host", "/home/wsp/project") + .sessions_dir; + let ledger_path = sessions_dir + .join(&session.session_id) + .join("evidence-ledger.json"); + let stored: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read(ledger_path).expect("remote ledger sidecar should exist"), + ) + .expect("remote ledger sidecar should deserialize"); + + assert_eq!(stored.events, vec![event]); + assert_eq!( + stored.events[0].target_kind, + EvidenceLedgerTargetKind::Subagent + ); + assert_eq!( + stored.events[0].status, + EvidenceLedgerEventStatus::PartialTimeout + ); + } + + #[tokio::test] + async fn restore_converges_evidence_sidecar_to_surviving_turns() { + // P2 regression: after restore, the sidecar must be converged to the + // surviving turns so a subsequent evidence append does not resurrect + // stale events from a turn that no longer exists. + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Converge evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + + // Create two turns with evidence. + for index in 0..2 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + let messages = (0..=index) + .map(|i| crate::agentic::core::Message::user(format!("prompt {i}"))) + .collect::>(); + persistence_manager + .save_turn_context_snapshot(workspace.path(), &session.session_id, index, &messages) + .await + .expect("snapshot should save"); + } + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec!["turn-0".to_string(), "turn-1".to_string()]; + for index in 0..2 { + manager + .record_subagent_partial_timeout( + &session.session_id, + &format!("turn-{index}"), + "ReviewSecurity", + &format!("Partial turn {index}"), + Some("timeout"), + ) + .await + .expect("evidence should persist"); + } + let ledger_path = storage_path + .join(&session.session_id) + .join("evidence-ledger.json"); + assert_eq!(evidence_event_ids(&ledger_path).len(), 2); + + // Simulate an older build removing turn-1 from history but leaving + // the evidence sidecar untouched. + persistence_manager + .delete_dialog_turns_from(workspace.path(), &session.session_id, 1) + .await + .expect("turn-1 should be deleted"); + persistence_manager + .delete_turn_context_snapshots_from(workspace.path(), &session.session_id, 1) + .await + .expect("snapshot-1 should be deleted"); + + // Unload and restore. The restore should converge the sidecar. + assert!(manager + .unload_session_from_memory(&session.session_id) + .await + .expect("session should unload")); + manager + .restore_session_from_storage_path(&storage_path, &session.session_id) + .await + .expect("session should restore"); + + // The sidecar should now only contain turn-0's evidence. + let sidecar_ids = evidence_event_ids(&ledger_path); + assert_eq!(sidecar_ids.len(), 1); + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-1") + .is_empty()); + + // Appending new evidence must not resurrect turn-1's event. + manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-0", + "ReviewLogic", + "New partial result", + Some("timeout"), + ) + .await + .expect("new evidence should persist"); + let final_ids = evidence_event_ids(&ledger_path); + assert_eq!(final_ids.len(), 2); + assert!(manager + .evidence_events_for_turn(&session.session_id, "turn-1") + .is_empty()); + let summary = manager.evidence_summary_for_session(&session.session_id, 10); + assert_eq!(summary.partial_subagent_results.len(), 2); + } + + #[tokio::test] + async fn branch_session_copies_evidence_ledger_for_inherited_turns() { + // P1 regression: forking a session must copy the evidence ledger, + // filtered to the copied turns and rewritten to the target session. + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Fork evidence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let storage_path = manager + .effective_session_storage_path(&session.session_id) + .await + .expect("storage path"); + + // Create two turns with evidence. + for index in 0..2 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + let messages = (0..=index) + .map(|i| crate::agentic::core::Message::user(format!("prompt {i}"))) + .collect::>(); + persistence_manager + .save_turn_context_snapshot(workspace.path(), &session.session_id, index, &messages) + .await + .expect("snapshot should save"); + } + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec!["turn-0".to_string(), "turn-1".to_string()]; + let turn_0_event = manager + .record_checkpoint_created( + &session.session_id, + "turn-0", + "Edit", + "src/lib.rs", + EvidenceLedgerCheckpoint { + current_branch: Some("feature/evidence".to_string()), + dirty_state_summary: "staged=0".to_string(), + touched_files: vec!["src/lib.rs".to_string()], + diff_hash: Some("abc".to_string()), + }, + ) + .await + .expect("turn-0 checkpoint should persist"); + let turn_1_event = manager + .record_subagent_partial_timeout( + &session.session_id, + "turn-1", + "ReviewSecurity", + "Partial turn 1", + Some("timeout"), + ) + .await + .expect("turn-1 evidence should persist"); + + // Branch through turn-0 only. + let branch_result = persistence_manager + .branch_session( + workspace.path(), + &SessionBranchRequest { + source_session_id: session.session_id.clone(), + source_turn_id: "turn-0".to_string(), + boundary: SessionBranchBoundary::ThroughTurn, + }, + ) + .await + .expect("branch should succeed"); + + // The fork should have turn-0's evidence but not turn-1's. + let fork_ledger_path = storage_path + .join(&branch_result.session_id) + .join("evidence-ledger.json"); + assert!( + fork_ledger_path.exists(), + "fork evidence sidecar should exist" + ); + let fork_stored: PersistedEvidenceLedgerFile = serde_json::from_slice( + &std::fs::read(&fork_ledger_path).expect("fork ledger should read"), + ) + .expect("fork ledger should deserialize"); + assert_eq!(fork_stored.session_id, branch_result.session_id); + assert_eq!(fork_stored.events.len(), 1); + assert_eq!(fork_stored.events[0].turn_id, "turn-0"); + assert_eq!(fork_stored.events[0].session_id, branch_result.session_id); + assert_eq!(fork_stored.events[0].event_id, turn_0_event.event_id); + // The checkpoint summary should be preserved. + assert!(fork_stored.events[0].checkpoint.is_some()); + // turn-1's evidence must not be in the fork. + assert!(fork_stored + .events + .iter() + .all(|e| e.event_id != turn_1_event.event_id)); + } + #[tokio::test] async fn prompt_cache_persists_across_session_restore() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index 6830911e38..92351371d7 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -1,6 +1,7 @@ //! Agentic system assembly shared by CLI, ACP, and other hosts. use std::sync::Arc; +use std::time::Duration; use anyhow::Result; use log::info; @@ -18,6 +19,16 @@ use crate::runtime_ownership::CoreRuntimeOwnership; use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber}; pub use bitfun_product_capabilities::DeliveryProfile; +fn session_manager_config_for_profile( + delivery_profile: DeliveryProfile, +) -> session::SessionManagerConfig { + let mut config = session::SessionManagerConfig::default(); + if delivery_profile == DeliveryProfile::Sdk { + config.session_idle_timeout = Duration::MAX; + } + config +} + /// Agentic runtime state shared by host adapters. #[derive(Clone)] pub struct AgenticSystem { @@ -42,6 +53,8 @@ pub async fn init_agentic_system() -> Result { /// Product composition roots call this before configuration canonicalization; /// later initialization verifies the same profile and rejects replacement. pub fn select_agentic_system_profile(delivery_profile: DeliveryProfile) -> Result<()> { + crate::agentic::agents::initialize_global_agent_registry_for_profile(delivery_profile) + .map_err(anyhow::Error::msg)?; tools::registry::initialize_global_tool_registry_for_profile(delivery_profile) .map(|_| ()) .map_err(anyhow::Error::msg) @@ -86,7 +99,7 @@ pub async fn init_agentic_system_for_profile_with_runtime_ownership( let session_manager = Arc::new(session::SessionManager::new( context_store, persistence_manager, - Default::default(), + session_manager_config_for_profile(delivery_profile), )); event_router.subscribe_internal( @@ -166,3 +179,21 @@ pub async fn init_agentic_system_for_profile_with_runtime_ownership( token_usage_service, }) } + +#[cfg(test)] +mod tests { + use super::{session_manager_config_for_profile, DeliveryProfile}; + use std::time::Duration; + + #[test] + fn sdk_profile_keeps_attached_sessions_loaded_until_the_host_releases_them() { + assert_eq!( + session_manager_config_for_profile(DeliveryProfile::Sdk).session_idle_timeout, + Duration::MAX + ); + assert_eq!( + session_manager_config_for_profile(DeliveryProfile::ProductFull).session_idle_timeout, + Duration::from_secs(3600) + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs index 803ac5abd2..e0bb6e2588 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs @@ -605,7 +605,7 @@ Usage notes: if command_needs_light_checkpoint(command_str) { context .record_light_checkpoint("Bash", command_str, Vec::new()) - .await; + .await?; } // Remote workspace: execute via injected workspace shell diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs index 56b27386e4..a15c93b152 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -338,7 +338,7 @@ Important notes: &resolved.logical_path, vec![resolved.logical_path.clone()], ) - .await; + .await?; // Remote workspace path: delete via shell command if resolved.uses_remote_workspace_backend() { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs index 42eab82815..18ff12be79 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -338,7 +338,7 @@ impl Tool for FileEditTool { &resolved.logical_path, vec![resolved.logical_path.clone()], ) - .await; + .await?; // For remote workspace paths, use the abstract FS to read → edit in memory → write back. if resolved.uses_remote_workspace_backend() { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs index 01e9452626..7763bdc184 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs @@ -529,7 +529,7 @@ impl Tool for FileWriteTool { &resolved.logical_path, vec![resolved.logical_path.clone()], ) - .await; + .await?; let file_already_exists = Self::file_exists(context, &resolved).await; if file_already_exists diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs index 743ec68f1e..7ef810813e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs @@ -1408,7 +1408,7 @@ When creating commits, use this format for the commit message: &format!("git {} {}", operation, args.unwrap_or("").trim()), Vec::new(), ) - .await; + .await?; } let start_time = std::time::Instant::now(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 24dc483df8..c137a00831 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -56,7 +56,7 @@ pub mod page_deploy_tool; pub mod page_publish_tool; #[cfg(feature = "tools-miniapp")] pub mod playbook_tool; -#[cfg(feature = "tools-git")] +#[cfg(feature = "tools-agent-control")] pub mod port_forward_tool; #[cfg(feature = "tools-git")] pub mod review_platform_tool; @@ -130,7 +130,7 @@ pub use page_deploy_tool::PageDeployTool; pub use page_publish_tool::PagePublishTool; #[cfg(feature = "tools-miniapp")] pub use playbook_tool::PlaybookTool; -#[cfg(feature = "tools-git")] +#[cfg(feature = "tools-agent-control")] pub use port_forward_tool::PortForwardTool; #[cfg(feature = "tools-git")] pub use review_platform_tool::ReviewPlatformTool; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index 5b053d636f..de458d4028 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -127,6 +127,8 @@ impl ProductToolRuntime { #[cfg(all(test, feature = "product-full"))] mod tests { + use std::collections::BTreeSet; + use super::ProductToolRuntime; use crate::agentic::tools::registry::create_tool_registry; use bitfun_product_capabilities::{product_assembly_plan_for_profile, DeliveryProfile}; @@ -152,7 +154,7 @@ mod tests { } #[test] - fn product_tool_runtime_registry_preserves_provider_plan_order() { + fn product_tool_runtime_provider_plan_covers_registry_without_owning_order() { let assembly = product_assembly_plan_for_profile(DeliveryProfile::ProductFull) .capability_assembly() .clone(); @@ -160,10 +162,20 @@ mod tests { .tool_provider_group_plan() .iter() .flat_map(|group| group.tool_names()) - .map(|tool_name| tool_name.to_string()) - .collect::>(); + .copied() + .collect::>(); + let registry_names = create_tool_registry() + .get_tool_names() + .into_iter() + .collect::>(); - assert_eq!(planned_names, create_tool_registry().get_tool_names()); + assert_eq!( + planned_names, + registry_names + .iter() + .map(String::as_str) + .collect::>() + ); } #[test] @@ -172,16 +184,23 @@ mod tests { let owner_registry = runtime .create_registry() .expect("CLI runtime plan must materialize in the product-full test build"); - let compatibility_registry = create_tool_registry(); + let selected_names = product_assembly_plan_for_profile(DeliveryProfile::Cli) + .capability_assembly() + .tool_provider_group_plan() + .iter() + .flat_map(|group| group.tool_names()) + .copied() + .collect::>(); + let expected_names = create_tool_registry() + .get_tool_names() + .into_iter() + .filter(|tool_name| selected_names.contains(tool_name.as_str())) + .collect::>(); - assert_eq!( - owner_registry.get_tool_names(), - compatibility_registry.get_tool_names() - ); - assert_eq!( - owner_registry.get_deferred_tool_names(), - compatibility_registry.get_deferred_tool_names() - ); + assert_eq!(owner_registry.get_tool_names(), expected_names); + assert!(owner_registry.get_tool("CodeReview").is_none()); + assert!(owner_registry.get_tool("CreateCanvas").is_none()); + assert!(owner_registry.get_tool("InitMiniApp").is_none()); } #[test] @@ -190,16 +209,23 @@ mod tests { let owner_registry = runtime .create_registry() .expect("ACP runtime plan must materialize in the product-full test build"); - let compatibility_registry = create_tool_registry(); + let selected_names = product_assembly_plan_for_profile(DeliveryProfile::Acp) + .capability_assembly() + .tool_provider_group_plan() + .iter() + .flat_map(|group| group.tool_names()) + .copied() + .collect::>(); + let expected_names = create_tool_registry() + .get_tool_names() + .into_iter() + .filter(|tool_name| selected_names.contains(tool_name.as_str())) + .collect::>(); - assert_eq!( - owner_registry.get_tool_names(), - compatibility_registry.get_tool_names() - ); - assert_eq!( - owner_registry.get_deferred_tool_names(), - compatibility_registry.get_deferred_tool_names() - ); + assert_eq!(owner_registry.get_tool_names(), expected_names); + assert!(owner_registry.get_tool("CodeReview").is_none()); + assert!(owner_registry.get_tool("CreateCanvas").is_none()); + assert!(owner_registry.get_tool("InitMiniApp").is_none()); } #[test] diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 91cc5f705a..fd23016227 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -11,9 +11,76 @@ use bitfun_agent_tools::{ use bitfun_tool_packs::{ tool_feature_group, unavailable_feature_groups, ToolPackFeatureGroup, ToolProviderGroupPlan, }; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +// Product capability groups own inclusion. Core keeps the established registry +// order because it is observable in model tool manifests and deferred catalogs. +const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ + "LS", + "Read", + "view_image", + "analyze_image", + "Glob", + "Grep", + "Write", + "Edit", + "Delete", + "ExecCommand", + "WriteStdin", + "ExecControl", + "GetTime", + "ListModels", + "BitFunControl", + "Task", + "AgentSpawn", + "AgentSendInput", + "AgentInterrupt", + "AgentList", + "AgentDelete", + "AgentWait", + "LaunchReviewAgent", + "Skill", + "AskUserQuestion", + "TodoWrite", + "get_goal", + "create_goal", + "update_goal", + "CreatePlan", + "submit_code_review", + "GetToolSpec", + "CallDeferredTool", + "GetFileDiff", + "CreateCanvas", + "ReadCanvas", + "UpdateCanvas", + "PatchCanvas", + "SessionControl", + "SessionMessage", + "SessionHistory", + "Cron", + "PortForward", + "WebSearch", + "WebFetch", + "ListMCPResources", + "ReadMCPResource", + "ListMCPPrompts", + "GetMCPPrompt", + "GenerativeUI", + "Git", + "Worktree", + "ReviewPlatform", + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "PublishAppearance", + "PageDeploy", + "PagePublish", + "ControlHub", + "ComputerUse", + "Playbook", +]; + #[derive(Debug, thiserror::Error)] pub(crate) enum ProductToolMaterializationError { #[error("product capability plan requires tool groups absent from this binary: {groups}")] @@ -23,6 +90,11 @@ pub(crate) enum ProductToolMaterializationError { provider_id: &'static str, tool_name: &'static str, }, + #[error("product tool {tool_name} in provider {provider_id} has no registry order")] + MissingRegistrationOrder { + provider_id: &'static str, + tool_name: &'static str, + }, #[error(transparent)] StaticToolMaterialization(#[from] StaticToolMaterializationError), } @@ -148,9 +220,8 @@ pub(in crate::agentic::tools) fn create_product_tool_registry_from_plan( .iter() .copied() .collect::>(); - let mut entries = Vec::new(); + let mut selected_tools = HashMap::new(); for provider in plan { - let mut tool_names = Vec::new(); for tool_name in provider.tool_names() { let feature_group = tool_feature_group(tool_name).ok_or( ProductToolMaterializationError::MissingFeatureOwner { @@ -159,13 +230,23 @@ pub(in crate::agentic::tools) fn create_product_tool_registry_from_plan( }, )?; if requested.contains(&feature_group) { - tool_names.push(*tool_name); + selected_tools.insert(*tool_name, provider.provider_id()); } } - if !tool_names.is_empty() { - entries.push((provider.provider_id(), tool_names)); + } + + let mut entries = Vec::new(); + for tool_name in PRODUCT_TOOL_REGISTRATION_ORDER { + if let Some(provider_id) = selected_tools.remove(tool_name) { + entries.push((provider_id, vec![*tool_name])); } } + if let Some((tool_name, provider_id)) = selected_tools.into_iter().next() { + return Err(ProductToolMaterializationError::MissingRegistrationOrder { + provider_id, + tool_name, + }); + } Ok(ToolRuntimeAssembly::with_tool_decorator(tool_decorator) .create_registry_from_static_provider_entries(entries, &ProductConcreteToolFactory)?) diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 17cc224620..c98226785e 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -638,19 +638,21 @@ mod tests { #[cfg(feature = "product-full")] #[test] - fn product_capability_provider_plan_covers_registry_manifest_in_order() { + fn product_capability_provider_plan_covers_registry_manifest_without_owning_order() { let assembly = bitfun_product_capabilities::default_product_capability_assembly(); - let provider_tools = assembly + let mut provider_tools = assembly .tool_provider_group_plan() .iter() .flat_map(|group| group.tool_names()) .map(|tool_name| tool_name.to_string()) .collect::>(); + let mut registry_tools = create_tool_registry().get_tool_names(); + provider_tools.sort(); + registry_tools.sort(); assert_eq!( - provider_tools, - create_tool_registry().get_tool_names(), - "provider-based assembly must preserve the existing builtin registry order" + provider_tools, registry_tools, + "provider-based assembly must cover the existing builtin registry manifest" ); } @@ -668,11 +670,16 @@ mod tests { vec![ "core.basic", "core.agent", - "core.canvas", "core.session", - "core.integration" + "core.git", + "core.web", + "core.mcp", + "core.computer-use", + "core.review", + "core.miniapp", + "core.canvas", ], - "provider groups must stay stable until concrete tool-pack owners exist" + "provider groups must preserve the reviewed atomic ownership order" ); } diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 00e6fb2f72..b860b8d5d8 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -404,21 +404,23 @@ impl ToolUseContext { tool_name: &str, target: &str, touched_files: Vec, - ) { + ) -> BitFunResult<()> { let Some(session_id) = self.session_id.as_deref() else { - return; + return Ok(()); }; let Some(turn_id) = self.dialog_turn_id.as_deref() else { - return; + return Ok(()); }; let Some(coordinator) = get_global_coordinator() else { - return; + return Ok(()); }; let checkpoint = self.build_light_checkpoint(touched_files).await; coordinator .get_session_manager() - .record_checkpoint_created(session_id, turn_id, tool_name, target, checkpoint); + .record_checkpoint_created(session_id, turn_id, tool_name, target, checkpoint) + .await?; + Ok(()) } async fn build_light_checkpoint(&self, touched_files: Vec) -> EvidenceLedgerCheckpoint { diff --git a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs index e0e1b21c59..d584c314a5 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs @@ -144,12 +144,11 @@ impl AIClientFactory { } async fn resolve_model_id(&self, model_id: &str) -> Result { - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; + let ai_config = self.config_service.get_effective_ai_config().await?; resolve_required_model_selector( model_id, - |selector| global_config.ai.resolve_model_selection(selector), - |model_ref| global_config.ai.resolve_model_reference(model_ref), + |selector| ai_config.resolve_model_selection(selector), + |model_ref| ai_config.resolve_model_reference(model_ref), ) .map_err(|error| anyhow!(error.to_string())) } @@ -166,10 +165,8 @@ impl AIClientFactory { else { return Ok(client); }; - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; - let model = global_config - .ai + let ai_config = self.config_service.get_effective_ai_config().await?; + let model = ai_config .models .iter() .find(|model| model.id == model_id) @@ -236,35 +233,31 @@ impl AIClientFactory { if normalized_model_id.is_empty() { return Err(anyhow!("Model configuration id is empty")); } - if global_config - .ai - .models - .iter() - .filter(|model| model.id == normalized_model_id) - .nth(1) - .is_some() - { - return Err(anyhow!( - "Multiple model configurations use the same ID: {}", - normalized_model_id - )); - } - debug!("Creating new AI client: model_id={}", normalized_model_id); - let mut matching_models = global_config - .ai - .models - .iter() - .filter(|m| m.id == normalized_model_id); - let model_config = matching_models - .next() - .ok_or_else(|| anyhow!("Model configuration not found: {}", normalized_model_id))?; - if matching_models.next().is_some() { - return Err(anyhow!( - "Multiple model configurations use the same ID: {}", - normalized_model_id - )); - } + let model_config = if let Some(runtime_model) = self + .config_service + .get_runtime_ai_model(&normalized_model_id) + .await + { + runtime_model + } else { + let mut matching_models = global_config + .ai + .models + .iter() + .filter(|model| model.id == normalized_model_id); + let model = matching_models + .next() + .cloned() + .ok_or_else(|| anyhow!("Model configuration not found: {}", normalized_model_id))?; + if matching_models.next().is_some() { + return Err(anyhow!( + "Multiple model configurations use the same ID: {}", + normalized_model_id + )); + } + model + }; if !model_config.enabled { return Err(anyhow!( @@ -274,7 +267,7 @@ impl AIClientFactory { )); } - let configuration_fingerprint = model_runtime_binding_fingerprint(model_config); + let configuration_fingerprint = model_runtime_binding_fingerprint(&model_config); if expected_configuration_fingerprint .is_some_and(|expected| expected != configuration_fingerprint) { @@ -286,7 +279,7 @@ impl AIClientFactory { let models_dev = load_models_dev_reasoning_catalog().await; let reasoning_projection = - project_model_reasoning_catalog(model_config, models_dev.catalog.as_deref()); + project_model_reasoning_catalog(&model_config, models_dev.catalog.as_deref()); let default_reasoning_preset = resolve_default_reasoning_preset(&reasoning_projection).cloned(); @@ -328,7 +321,7 @@ impl AIClientFactory { #[cfg(not(feature = "subscription-auth"))] let _ = credential_expires_at; - let stream_options = build_stream_options_for_model(&global_config.ai, Some(model_config)); + let stream_options = build_stream_options_for_model(&global_config.ai, Some(&model_config)); let client = apply_default_reasoning_preset( AIClient::new_with_runtime_options(ai_config, proxy_config, stream_options), &reasoning_projection, @@ -587,12 +580,16 @@ pub async fn list_subscription_accounts() -> Vec PathBuf { - self.user_root.join("logs") + self.user_config_dir().join("logs") } /// Get temp directory: ~/.config/bitfun/temp/ @@ -919,6 +919,7 @@ mod tests { let pm = PathManager::new().expect("path manager should use env overrides"); assert_eq!(pm.user_config_dir(), user_root.join("config")); assert_eq!(pm.user_data_dir(), user_root.join("data")); + assert_eq!(pm.logs_dir(), user_root.join("config").join("logs")); assert_eq!(pm.bitfun_home_dir(), home_root); } diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index ad55987581..080b66a3a7 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -36,6 +36,12 @@ pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) pub mod native_hooks; #[cfg(all(test, feature = "agent-runtime"))] mod native_hooks_tests; +#[cfg(feature = "opencode-plugin-host")] +pub mod plugin_host; +#[cfg(feature = "opencode-plugin-host")] +mod plugin_host_http; +#[cfg(feature = "opencode-plugin-host")] +mod plugin_host_http_routes; #[cfg(feature = "plugin-runtime")] pub mod plugin_runtime; #[cfg(feature = "plugin-source")] diff --git a/src/crates/assembly/core/src/plugin_host.rs b/src/crates/assembly/core/src/plugin_host.rs new file mode 100644 index 0000000000..58fd638f16 --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host.rs @@ -0,0 +1,751 @@ +use bitfun_opencode_plugin_host::{ + PluginDeclaration, PluginHost, PluginHostConfig, PluginHostShutdownPolicy, + PluginHostShutdownReport, PluginInstanceOpenRequest, PluginPrepareRequest, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +// Product-assembly bridge for the managed OpenCode Plugin Host. +// +// `PluginHost` itself remains the adapter-owned process/IPC resource. Core +// keeps only the product-level lifecycle assembly and logical instance/PTy +// ownership needed to bind adapter callbacks to BitFun owners; these maps do +// not supervise a physical process tree or make trust/configuration policy. + +use terminal_core::{CloseSessionRequest, TerminalApi}; +use tokio::sync::{Mutex, Notify, OnceCell}; + +const BUN_HOST_ENTRY_ENV: &str = "BITFUN_OPENCODE_BUN_HOST_ENTRY"; +const BUN_COMMAND_ENV: &str = "BITFUN_BUN_COMMAND"; +static PLUGIN_HOST: OnceCell>> = OnceCell::const_new(); +static PLUGIN_HOST_SHUTDOWN_REPORT: OnceCell>> = + OnceCell::const_new(); +static PLUGIN_HOST_SHUTDOWN_NOTIFY: OnceCell = OnceCell::const_new(); +static PLUGIN_HOST_SHUTDOWN_STARTED: AtomicBool = AtomicBool::new(false); +static PLUGIN_HOST_SHUTDOWN_COMPLETE: AtomicBool = AtomicBool::new(false); +static PLUGIN_HOST_INSTANCES: OnceCell>> = + OnceCell::const_new(); +static PLUGIN_HOST_PTY_OWNERS: OnceCell>> = OnceCell::const_new(); +static NEXT_INSTANCE_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub(crate) struct PluginHostInstance { + pub(crate) canonical_directory: String, + pub(crate) directory: PathBuf, + pub(crate) worktree: PathBuf, + pub(crate) project_id: String, + pub(crate) created_at_ms: i64, + pub(crate) instance_id: String, + pub(crate) open_result: Value, + pub(crate) ready: bool, +} + +impl PluginHostInstance { + pub(crate) fn is_ready(&self) -> bool { + self.ready + } +} + +#[derive(Debug, Clone, Copy)] +struct PluginHostLaunchSpec { + runtime_name: &'static str, + default_command: &'static str, + command_env: &'static str, + entry_env: &'static str, + entry_filename: &'static str, +} + +impl PluginHostLaunchSpec { + fn bun() -> Self { + Self { + runtime_name: "Bun", + default_command: "bun", + command_env: BUN_COMMAND_ENV, + entry_env: BUN_HOST_ENTRY_ENV, + entry_filename: "extension-host.js", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginHostStartup { + Disabled, + Started, + AlreadyStarted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginHostLaunchPolicy { + Enabled, + Disabled, +} + +pub async fn initialize_configured_plugin_host( + launch_policy: PluginHostLaunchPolicy, +) -> crate::BitFunResult { + initialize_configured_plugin_host_with_log_file(launch_policy, None).await +} + +pub async fn initialize_configured_plugin_host_with_log_file( + launch_policy: PluginHostLaunchPolicy, + log_file: Option, +) -> crate::BitFunResult { + use crate::service::config::{get_global_config_service, GlobalConfig}; + + if launch_policy == PluginHostLaunchPolicy::Disabled { + return Ok(PluginHostStartup::Disabled); + } + let config_service = get_global_config_service().await?; + let config: GlobalConfig = config_service.get_config(None).await?; + if !config.has_configured_plugins() { + return Ok(PluginHostStartup::Disabled); + } + if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { + return Err(crate::BitFunError::ProcessError( + "Plugin host is shutting down".to_string(), + )); + } + let launch_spec = PluginHostLaunchSpec::bun(); + + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + let mut host_state = host_state.lock().await; + if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { + return Err(crate::BitFunError::ProcessError( + "Plugin host is shutting down".to_string(), + )); + } + if host_state.is_some() { + return Ok(PluginHostStartup::AlreadyStarted); + } + let path_manager = crate::infrastructure::try_get_path_manager_arc()?; + let log_file = log_file.unwrap_or_else(|| path_manager.logs_dir().join("plugin-host.log")); + let entry = resolve_host_entry(launch_spec)?; + let working_directory = entry.parent().ok_or_else(|| { + crate::BitFunError::config(format!( + "{} plugin host entry has no parent directory: {}", + launch_spec.runtime_name, + entry.display() + )) + })?; + let host = PluginHost::start(PluginHostConfig { + runtime_command: std::env::var_os(launch_spec.command_env) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(launch_spec.default_command)), + entry: entry.clone(), + working_directory: working_directory.to_path_buf(), + cache_directory: path_manager.cache_root().join("opencode-plugin-host"), + log_file, + log_level: config.app.logging.level.trim().to_lowercase(), + }) + .await + .map_err(|error| match error { + bitfun_opencode_plugin_host::PluginHostError::RuntimeNotFound(command) => { + crate::BitFunError::ProcessError(format!( + "{} executable was not found at {}. Install Bun or set {} to a valid Bun executable.", + launch_spec.runtime_name, + command.display(), + BUN_COMMAND_ENV + )) + } + error => crate::BitFunError::ProcessError(format!( + "Failed to initialize {} plugin host from {}: {error}", + launch_spec.runtime_name, + entry.display() + )), + })?; + let client = host.client(); + crate::plugin_host_http::register_plugin_host_backend_handlers(client.clone()).await?; + let plugins = config + .plugin + .iter() + .filter_map(plugin_declaration) + .collect::>(); + let configuration_fingerprint = plugin_config_fingerprint(&config)?; + *host_state = Some(host); + tokio::spawn(async move { + let plugin_count = plugins.len(); + log::info!( + "Configured plugin host background prewarm started: generation={}, plugin_count={}", + client.generation(), + plugin_count + ); + match client + .prepare_plugins( + PluginPrepareRequest { + plugins, + configuration_fingerprint: Some(configuration_fingerprint), + default_base_directory: None, + }, + std::time::Duration::from_secs(120), + ) + .await + { + Ok(result) => { + let prepared_count = result + .get("prepared") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let failed_count = result + .get("failed") + .and_then(Value::as_array) + .map_or(0, Vec::len); + log::info!( + "Configured plugin host background prewarm completed: generation={}, plugin_count={}, prepared_count={}, failed_count={}", + client.generation(), + plugin_count, + prepared_count, + failed_count + ); + } + Err(error) => { + log::warn!( + "Configured plugin host background prewarm failed: generation={}, plugin_count={}, error={}", + client.generation(), + plugin_count, + error + ); + } + } + }); + Ok(PluginHostStartup::Started) +} + +pub async fn set_configured_plugin_host_log_level(level: &str) -> crate::BitFunResult<()> { + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + let client = host_state.lock().await.as_ref().map(PluginHost::client); + let Some(client) = client else { + return Ok(()); + }; + client.set_log_level(level).await.map_err(|error| { + crate::BitFunError::ProcessError(format!( + "Failed to update plugin host log level to {}: {}", + level, error + )) + }) +} + +pub async fn ensure_configured_plugin_instance( + launch_policy: PluginHostLaunchPolicy, + directory: PathBuf, + worktree: PathBuf, + project_id: Option, + config: Map, +) -> crate::BitFunResult> { + use crate::service::config::{get_global_config_service, GlobalConfig}; + + if launch_policy == PluginHostLaunchPolicy::Disabled { + return Ok(None); + } + let config_service = get_global_config_service().await?; + let global_config: GlobalConfig = config_service.get_config(None).await?; + if !global_config.has_configured_plugins() { + return Ok(None); + } + if directory.as_os_str().is_empty() || !directory.is_dir() { + return Err(crate::BitFunError::Validation(format!( + "Plugin host instance directory does not exist: {}", + directory.display() + ))); + } + + let canonical_directory = dunce::canonicalize(&directory).map_err(|error| { + crate::BitFunError::Io(std::io::Error::other(format!( + "Failed to canonicalize plugin host instance directory {}: {error}", + directory.display() + ))) + })?; + let canonical_directory_string = canonical_directory.to_string_lossy().into_owned(); + let comparable_directory = comparable_instance_directory(&canonical_directory_string); + let config_fingerprint = plugin_config_fingerprint(&global_config)?; + let client = { + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + host_state + .lock() + .await + .as_ref() + .map(PluginHost::client) + .ok_or_else(|| { + crate::BitFunError::ProcessError( + "Configured plugin host is not running".to_string(), + ) + })? + }; + let instances = PLUGIN_HOST_INSTANCES + .get_or_init(|| async { Mutex::new(HashMap::new()) }) + .await; + let instance_key = format!("{comparable_directory}\n{config_fingerprint}"); + if let Some(instance) = instances.lock().await.get(&instance_key).cloned() { + log::debug!( + "Configured plugin host instance reused: generation={}, instance_id={}", + client.generation(), + instance.instance_id + ); + return Ok(Some(instance.open_result.clone())); + } + + let previous_keys = instances + .lock() + .await + .iter() + .filter(|(_, instance)| instance.canonical_directory == comparable_directory) + .map(|(key, instance)| (key.clone(), instance.instance_id.clone())) + .collect::>(); + for (key, instance_id) in previous_keys { + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.cancel_instance_streams(&instance_id).await; + } + client + .close_instance(&instance_id, std::time::Duration::from_secs(10)) + .await + .map_err(|error| { + crate::BitFunError::ProcessError(format!( + "Failed to close stale plugin host instance {instance_id}: {error}" + )) + })?; + close_plugin_host_ptys(&instance_id).await; + instances.lock().await.remove(&key); + } + + let sequence = NEXT_INSTANCE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let instance_id = format!("bitfun:host:{}:{sequence}", client.generation()); + let project_id = project_id + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + format!( + "bitfun-project-{}", + hex::encode(Sha256::digest(canonical_directory_string.as_bytes())) + ) + }); + let now_ms = chrono::Utc::now().timestamp_millis(); + let opening_context = PluginHostInstance { + canonical_directory: comparable_directory.clone(), + directory: canonical_directory.clone(), + worktree: worktree.clone(), + project_id: project_id.clone(), + created_at_ms: now_ms, + instance_id: instance_id.clone(), + open_result: Value::Null, + ready: false, + }; + instances + .lock() + .await + .insert(instance_key.clone(), opening_context); + let open_result = match client + .open_instance( + PluginInstanceOpenRequest { + instance_id: instance_id.clone(), + project: serde_json::json!({ + "id": project_id, + "worktree": canonical_directory_string, + "time": {"created": now_ms}, + }), + config, + directory: canonical_directory.to_string_lossy().into_owned(), + worktree: worktree.to_string_lossy().into_owned(), + plugins: global_config + .plugin + .iter() + .filter_map(plugin_declaration) + .collect(), + configuration_fingerprint: Some(config_fingerprint.clone()), + }, + std::time::Duration::from_secs(30), + ) + .await + { + Ok(result) => result, + Err(error) => { + close_plugin_host_ptys(&instance_id).await; + instances.lock().await.remove(&instance_key); + return Err(crate::BitFunError::ProcessError(format!( + "Failed to activate plugins for workspace {}: {error}", + canonical_directory.display() + ))); + } + }; + log::info!( + "Configured plugin host instance activated: generation={}, instance_id={}, plugin_count={}", + client.generation(), + instance_id, + global_config.plugin.len() + ); + if let Some(instance) = instances.lock().await.get_mut(&instance_key) { + instance.open_result = open_result.clone(); + instance.ready = true; + } + Ok(Some(open_result)) +} + +pub(crate) async fn plugin_host_instance_by_id(instance_id: &str) -> Option { + let instances = PLUGIN_HOST_INSTANCES.get()?; + instances + .lock() + .await + .values() + .find(|instance| instance.instance_id == instance_id) + .cloned() +} + +pub(crate) async fn register_plugin_host_pty(pty_id: &str, instance_id: &str) { + let owners = PLUGIN_HOST_PTY_OWNERS + .get_or_init(|| async { Mutex::new(HashMap::new()) }) + .await; + owners + .lock() + .await + .insert(pty_id.to_string(), instance_id.to_string()); +} + +pub(crate) async fn plugin_host_pty_owned_by(pty_id: &str, instance_id: &str) -> bool { + let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() else { + return false; + }; + owners + .lock() + .await + .get(pty_id) + .is_some_and(|owner| owner == instance_id) +} + +pub(crate) async fn unregister_plugin_host_pty(pty_id: &str, instance_id: &str) -> bool { + let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() else { + return false; + }; + let mut owners = owners.lock().await; + if owners.get(pty_id).is_some_and(|owner| owner == instance_id) { + owners.remove(pty_id); + true + } else { + false + } +} + +pub(crate) async fn prune_plugin_host_pty(pty_id: &str, instance_id: &str) { + if unregister_plugin_host_pty(pty_id, instance_id).await { + log::debug!( + "Removed stale plugin host PTY ownership: instance_id={}, pty_id={}", + instance_id, + pty_id + ); + } +} + +pub(crate) async fn plugin_host_pty_ids_for_instance(instance_id: &str) -> Vec { + let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() else { + return Vec::new(); + }; + owners + .lock() + .await + .iter() + .filter_map(|(pty_id, owner)| (owner == instance_id).then_some(pty_id.clone())) + .collect() +} + +async fn close_plugin_host_ptys(instance_id: &str) { + let pty_ids = plugin_host_pty_ids_for_instance(instance_id).await; + if pty_ids.is_empty() { + return; + } + let api = match TerminalApi::from_singleton() { + Ok(api) => Some(api), + Err(error) => { + log::warn!( + "Plugin host PTYs could not be closed because the terminal owner is unavailable: instance_id={}, pty_count={}, error={}", + instance_id, + pty_ids.len(), + error + ); + None + } + }; + for pty_id in &pty_ids { + if let Some(api) = api.as_ref() { + if let Err(error) = api + .close_session(CloseSessionRequest { + session_id: pty_id.clone(), + immediate: Some(false), + }) + .await + { + log::warn!( + "Plugin host PTY close failed: instance_id={}, pty_id={}, error={}", + instance_id, + pty_id, + error + ); + } + } + unregister_plugin_host_pty(pty_id, instance_id).await; + } + log::info!( + "Plugin host PTY cleanup completed: instance_id={}, pty_count={}", + instance_id, + pty_ids.len() + ); +} + +async fn close_all_plugin_host_ptys() { + let instance_ids = if let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() { + let mut instance_ids = owners.lock().await.values().cloned().collect::>(); + instance_ids.sort(); + instance_ids.dedup(); + instance_ids + } else { + Vec::new() + }; + for instance_id in instance_ids { + close_plugin_host_ptys(&instance_id).await; + } +} + +pub(crate) fn instance_directories_equal(requested: &str, expected: &Path) -> bool { + let Ok(expected) = dunce::canonicalize(expected) else { + return false; + }; + let expected = comparable_instance_directory(&expected.to_string_lossy()); + let matches = |candidate: &str| { + dunce::canonicalize(candidate) + .map(|path| comparable_instance_directory(&path.to_string_lossy()) == expected) + .unwrap_or(false) + }; + matches(requested) + || urlencoding::decode(requested) + .ok() + .is_some_and(|decoded| decoded.as_ref() != requested && matches(decoded.as_ref())) +} + +pub async fn shutdown_configured_plugin_host( +) -> crate::BitFunResult> { + let shutdown_report = PLUGIN_HOST_SHUTDOWN_REPORT + .get_or_init(|| async { Mutex::new(None) }) + .await; + let shutdown_notify = PLUGIN_HOST_SHUTDOWN_NOTIFY + .get_or_init(|| async { Notify::new() }) + .await; + + if PLUGIN_HOST_SHUTDOWN_STARTED.swap(true, Ordering::AcqRel) { + loop { + let notified = shutdown_notify.notified(); + if PLUGIN_HOST_SHUTDOWN_COMPLETE.load(Ordering::Acquire) { + return Ok(shutdown_report.lock().await.clone()); + } + notified.await; + } + } + + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.begin_draining().await; + } + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + let host = host_state.lock().await.take(); + if let Some(instances) = PLUGIN_HOST_INSTANCES.get() { + instances.lock().await.clear(); + } + let report = match host { + Some(host) => { + log::info!("Starting configured plugin host graceful shutdown"); + Some(host.shutdown(PluginHostShutdownPolicy::default()).await) + } + None => { + log::debug!("Configured plugin host graceful shutdown skipped: host not started"); + None + } + }; + close_all_plugin_host_ptys().await; + if let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() { + owners.lock().await.clear(); + } + *shutdown_report.lock().await = report.clone(); + PLUGIN_HOST_SHUTDOWN_COMPLETE.store(true, Ordering::Release); + shutdown_notify.notify_waiters(); + Ok(report) +} + +fn resolve_host_entry(spec: PluginHostLaunchSpec) -> crate::BitFunResult { + if let Some(entry) = std::env::var_os(spec.entry_env) { + return absolutize_existing_entry(PathBuf::from(entry), spec); + } + let executable = std::env::current_exe().map_err(crate::BitFunError::Io)?; + let executable_directory = executable.parent().ok_or_else(|| { + crate::BitFunError::config(format!( + "BitFun executable has no parent directory: {}", + executable.display() + )) + })?; + let bundled_entry = executable_directory + .join("resources") + .join("ext-host") + .join(spec.entry_filename); + if bundled_entry.is_file() { + return Ok(bundled_entry); + } + let development_entry = development_host_entry(spec); + if let Some(entry) = development_entry.filter(|entry| entry.is_file()) { + return Ok(entry); + } + Err(crate::BitFunError::NotFound(format!( + "{} plugin host entry does not exist at {}. Set {} in development.", + spec.runtime_name, + bundled_entry.display(), + spec.entry_env + ))) +} + +fn development_host_entry(spec: PluginHostLaunchSpec) -> Option { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(4) + .map(|repository_root| { + repository_root + .join("src") + .join("apps") + .join("extension-host") + .join("dist") + .join(spec.entry_filename) + }) +} + +fn plugin_declaration( + declaration: &crate::service::config::PluginDeclarationConfig, +) -> Option { + use crate::service::config::PluginDeclarationConfig; + + let declaration = match declaration { + PluginDeclarationConfig::Spec(spec) => PluginDeclaration { + spec: spec.clone(), + options: None, + base_directory: None, + }, + PluginDeclarationConfig::Detailed(details) => PluginDeclaration { + spec: details.spec.clone(), + options: details.options.clone(), + base_directory: details.base_directory.clone(), + }, + }; + if declaration.spec.trim().is_empty() { + None + } else { + Some(declaration) + } +} + +fn plugin_config_fingerprint( + config: &crate::service::config::GlobalConfig, +) -> crate::BitFunResult { + let declarations = config + .plugin + .iter() + .filter_map(plugin_declaration) + .collect::>(); + let bytes = serde_json::to_vec(&declarations)?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +fn comparable_instance_directory(directory: &str) -> String { + let mut comparable = directory.replace('\\', "/"); + #[cfg(windows)] + comparable.make_ascii_lowercase(); + comparable +} + +fn absolutize_existing_entry( + entry: PathBuf, + spec: PluginHostLaunchSpec, +) -> crate::BitFunResult { + let entry = if entry.is_absolute() { + entry + } else { + std::env::current_dir() + .map_err(crate::BitFunError::Io)? + .join(entry) + }; + if !entry.is_file() { + return Err(crate::BitFunError::NotFound(format!( + "{} plugin host entry does not exist: {}. Set {} in development.", + spec.runtime_name, + entry.display(), + spec.entry_env + ))); + } + Ok(entry) +} + +#[cfg(test)] +mod tests { + use super::{ + development_host_entry, initialize_configured_plugin_host, instance_directories_equal, + plugin_host_pty_ids_for_instance, plugin_host_pty_owned_by, register_plugin_host_pty, + unregister_plugin_host_pty, PluginHostLaunchPolicy, PluginHostLaunchSpec, + PluginHostStartup, + }; + use std::path::Path; + + #[test] + fn bun_runtime_selects_bun_command_and_entry() { + let spec = PluginHostLaunchSpec::bun(); + + assert_eq!(spec.default_command, "bun"); + assert_eq!(spec.entry_filename, "extension-host.js"); + assert_eq!(spec.command_env, "BITFUN_BUN_COMMAND"); + assert_eq!(spec.entry_env, "BITFUN_OPENCODE_BUN_HOST_ENTRY"); + } + + #[test] + fn development_host_entry_is_owned_by_the_bitfun_repository() { + let spec = PluginHostLaunchSpec::bun(); + let entry = development_host_entry(spec).expect("BitFun repository root"); + + assert!(entry.ends_with( + Path::new("src") + .join("apps") + .join("extension-host") + .join("dist") + .join("extension-host.js") + )); + } + + #[tokio::test] + async fn disabled_launch_policy_skips_host_initialization() { + let status = initialize_configured_plugin_host(PluginHostLaunchPolicy::Disabled) + .await + .expect("disabled policy"); + + assert_eq!(status, PluginHostStartup::Disabled); + } + + #[test] + fn instance_directory_matching_accepts_encoded_paths_and_rejects_siblings() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let workspace = directory.path().join("workspace with space"); + let sibling = directory.path().join("workspace with space-sibling"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + std::fs::create_dir_all(&sibling).expect("sibling directory"); + let encoded = urlencoding::encode(&workspace.to_string_lossy()).into_owned(); + + assert!(instance_directories_equal(&encoded, &workspace)); + assert!(!instance_directories_equal( + &sibling.to_string_lossy(), + &workspace + )); + } + + #[tokio::test] + async fn plugin_host_pty_ownership_is_instance_scoped() { + let pty_id = format!("pty-test-{}", std::process::id()); + let first = format!("instance-first-{}", std::process::id()); + let second = format!("instance-second-{}", std::process::id()); + + register_plugin_host_pty(&pty_id, &first).await; + assert!(plugin_host_pty_owned_by(&pty_id, &first).await); + assert!(!plugin_host_pty_owned_by(&pty_id, &second).await); + assert_eq!( + plugin_host_pty_ids_for_instance(&first).await, + vec![pty_id.clone()] + ); + assert!(unregister_plugin_host_pty(&pty_id, &first).await); + } +} diff --git a/src/crates/assembly/core/src/plugin_host_http.rs b/src/crates/assembly/core/src/plugin_host_http.rs new file mode 100644 index 0000000000..fa8455d39a --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host_http.rs @@ -0,0 +1,526 @@ +use bitfun_opencode_plugin_host::{ + json_error_body, match_http_route, read_host_stream, BackendHttpRequest, BackendHttpResponse, + HostStreamReadError, HttpRouteError, OpenCodeClientRoute, PluginHostClient, + PluginHostStreamRegistry, RpcHandlerError, StreamCancelParams, StreamReadParams, + StreamRegistryError, MAX_HTTP_BODY_BYTES, +}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Notify, OnceCell}; + +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const HTTP_DRAIN_TIMEOUT: Duration = Duration::from_secs(3); + +static PLUGIN_HOST_BACKEND_BRIDGE: OnceCell> = OnceCell::const_new(); + +pub(crate) struct PluginHostBackendBridge { + client: PluginHostClient, + streams: PluginHostStreamRegistry, + draining: AtomicBool, + active_requests: AtomicUsize, + requests_drained: Notify, +} + +struct ActiveRequest<'a> { + bridge: &'a PluginHostBackendBridge, +} + +impl Drop for ActiveRequest<'_> { + fn drop(&mut self) { + if self.bridge.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { + self.bridge.requests_drained.notify_waiters(); + } + } +} + +#[derive(Debug)] +pub(crate) struct RouteFailure { + pub(crate) status: u16, + pub(crate) code: &'static str, + pub(crate) message: String, +} + +impl RouteFailure { + pub(crate) fn bad_request(message: impl Into) -> Self { + Self::new(400, "invalid_request", message) + } + + pub(crate) fn forbidden(message: impl Into) -> Self { + Self::new(403, "instance_scope_denied", message) + } + + pub(crate) fn not_found(message: impl Into) -> Self { + Self::new(404, "not_found", message) + } + + pub(crate) fn unsupported(message: impl Into) -> Self { + Self::new(501, "unsupported_capability", message) + } + + pub(crate) fn backend(message: impl Into) -> Self { + Self::new(502, "backend_failure", message) + } + + pub(crate) fn unavailable(message: impl Into) -> Self { + Self::new(503, "backend_unavailable", message) + } + + fn new(status: u16, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + } + } +} + +impl PluginHostBackendBridge { + fn new(client: PluginHostClient) -> Self { + Self { + client, + streams: PluginHostStreamRegistry::default(), + draining: AtomicBool::new(false), + active_requests: AtomicUsize::new(0), + requests_drained: Notify::new(), + } + } + + fn admit(&self) -> Option> { + if self.draining.load(Ordering::Acquire) { + return None; + } + self.active_requests.fetch_add(1, Ordering::AcqRel); + if self.draining.load(Ordering::Acquire) { + if self.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { + self.requests_drained.notify_waiters(); + } + return None; + } + Some(ActiveRequest { bridge: self }) + } + + async fn handle_http(self: Arc, params: Value) -> Result { + let request: BackendHttpRequest = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.http.request", error))?; + if request.instance_id.trim().is_empty() + || request.instance_id.len() > 256 + || request.request_id.trim().is_empty() + || request.request_id.len() > 256 + || request.method.len() > 16 + || request.headers.len() > 64 + { + return Err(RpcHandlerError::new( + -32602, + "Invalid request identity, method, or header count for backend.http.request", + )); + } + let started_at = Instant::now(); + let path = request.path.clone(); + let method = request.method.clone(); + let instance_id = request.instance_id.clone(); + let request_id = request.request_id.clone(); + let Some(_active) = self.admit() else { + return self + .http_error( + &instance_id, + 503, + "host_draining", + "Plugin host is shutting down", + &path, + ) + .await; + }; + + let route_match = match match_http_route(&method, &path) { + Ok(route_match) => route_match, + Err(HttpRouteError::InvalidPath) => { + return self + .http_error( + &instance_id, + 400, + "invalid_request", + "Request path is invalid", + &path, + ) + .await + } + Err(HttpRouteError::NotFound) => { + return self + .http_error( + &instance_id, + 404, + "route_not_found", + "OpenCode client route was not found", + &path, + ) + .await + } + Err(HttpRouteError::MethodNotAllowed) => { + return self + .http_error( + &instance_id, + 405, + "method_not_allowed", + "HTTP method is not allowed for this route", + &path, + ) + .await + } + }; + let operation = route_match.route.operation(); + let context = match crate::plugin_host::plugin_host_instance_by_id(&instance_id).await { + Some(context) => context, + None => { + return self + .http_error( + &instance_id, + 404, + "instance_not_found", + "Plugin host instance was not found", + &path, + ) + .await + } + }; + if !context.is_ready() { + log::debug!( + "Plugin client request admitted during activation: instance_id={}, request_id={}, operation={}", + instance_id, + request_id, + operation + ); + } + if let Some(directory) = route_match.query_first("directory") { + if !crate::plugin_host::instance_directories_equal(directory, &context.directory) { + return self + .http_error( + &instance_id, + 403, + "instance_scope_denied", + "Request directory does not belong to this plugin instance", + &path, + ) + .await; + } + } + if let Some(directory) = request.headers.iter().find_map(|(name, value)| { + name.eq_ignore_ascii_case("x-opencode-directory") + .then_some(value.as_str()) + }) { + if !crate::plugin_host::instance_directories_equal(directory, &context.directory) { + return self + .http_error( + &instance_id, + 403, + "instance_scope_denied", + "Request directory does not belong to this plugin instance", + &path, + ) + .await; + } + } + let body = match request.body.as_ref() { + Some(descriptor) => match read_host_stream( + &self.client, + &instance_id, + descriptor, + MAX_HTTP_BODY_BYTES, + HTTP_REQUEST_TIMEOUT, + ) + .await + { + Ok(body) => body, + Err(HostStreamReadError::BodyTooLarge) => { + return self + .http_error( + &instance_id, + 413, + "request_too_large", + "Request body exceeds the configured limit", + &path, + ) + .await + } + Err(error) => { + return self + .http_error( + &instance_id, + 502, + "backend_failure", + &format!("Failed to read request body: {error}"), + &path, + ) + .await + } + }, + None => Vec::new(), + }; + + let outcome = tokio::time::timeout( + HTTP_REQUEST_TIMEOUT, + dispatch_route(&context, route_match.route, &route_match.query, &body), + ) + .await; + let response = match outcome { + Ok(Ok(value)) => self.json_response(&instance_id, 200, value).await, + Ok(Err(error)) => { + self.http_error( + &instance_id, + error.status, + error.code, + &error.message, + &path, + ) + .await + } + Err(_) => { + self.http_error( + &instance_id, + 504, + "backend_timeout", + "Backend route timed out", + &path, + ) + .await + } + }; + let status = response + .as_ref() + .ok() + .and_then(|value| value.get("status")) + .and_then(Value::as_u64) + .unwrap_or(500); + log::info!( + "Plugin client request completed: instance_id={}, request_id={}, method={}, path={}, status={}, duration_ms={}, route_status=A, operation={}", + instance_id, + request_id, + method, + path, + status, + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + operation + ); + response + } + + async fn json_response( + &self, + instance_id: &str, + status: u16, + value: Value, + ) -> Result { + let bytes = serde_json::to_vec(&value).map_err(|error| { + RpcHandlerError::new( + -32603, + format!("Failed to serialize HTTP response: {error}"), + ) + })?; + self.bytes_response(instance_id, status, "application/json", bytes) + .await + } + + async fn http_error( + &self, + instance_id: &str, + status: u16, + code: &str, + message: &str, + route: &str, + ) -> Result { + self.bytes_response( + instance_id, + status, + "application/json", + json_error_body(code, message, route), + ) + .await + } + + async fn bytes_response( + &self, + instance_id: &str, + status: u16, + content_type: &str, + bytes: Vec, + ) -> Result { + let body = self + .streams + .add(instance_id, bytes) + .await + .map_err(stream_rpc_error)?; + serde_json::to_value(BackendHttpResponse { + status, + status_text: None, + headers: vec![("content-type".to_string(), content_type.to_string())], + body: Some(body), + }) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + + pub(crate) async fn begin_draining(&self) { + self.draining.store(true, Ordering::Release); + let active_requests = self.active_requests.load(Ordering::Acquire); + let active_streams = self.streams.active_count().await; + log::info!( + "Plugin client bridge draining started: active_requests={}, active_streams={}", + active_requests, + active_streams + ); + let wait = async { + loop { + let notified = self.requests_drained.notified(); + if self.active_requests.load(Ordering::Acquire) == 0 { + return; + } + notified.await; + } + }; + if tokio::time::timeout(HTTP_DRAIN_TIMEOUT, wait) + .await + .is_err() + { + log::warn!( + "Plugin client bridge request drain timed out: active_requests={}", + self.active_requests.load(Ordering::Acquire) + ); + } + let streams_drained = self.streams.wait_until_empty(HTTP_DRAIN_TIMEOUT).await; + if !streams_drained { + log::warn!( + "Plugin client bridge response stream drain timed out: active_streams={}", + self.streams.active_count().await + ); + } + let cancelled = self.streams.cancel_all().await; + log::info!( + "Plugin client bridge draining completed: active_requests={}, cancelled_streams={}", + self.active_requests.load(Ordering::Acquire), + cancelled + ); + } + + pub(crate) async fn cancel_instance_streams(&self, instance_id: &str) { + let cancelled = self.streams.cancel_instance(instance_id).await; + if cancelled > 0 { + log::debug!( + "Plugin client response streams cancelled: instance_id={}, stream_count={}", + instance_id, + cancelled + ); + } + } +} + +pub(crate) async fn register_plugin_host_backend_handlers( + client: PluginHostClient, +) -> crate::BitFunResult> { + let bridge = Arc::new(PluginHostBackendBridge::new(client.clone())); + let http_bridge = bridge.clone(); + client + .register_handler("backend.http.request", move |params| { + let bridge = http_bridge.clone(); + async move { bridge.handle_http(params).await } + }) + .await + .map_err(plugin_host_handler_error)?; + let read_bridge = bridge.clone(); + client + .register_handler("backend.stream.read", move |params| { + let bridge = read_bridge.clone(); + async move { + let params: StreamReadParams = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.stream.read", error))?; + serde_json::to_value( + bridge + .streams + .read(params) + .await + .map_err(stream_rpc_error)?, + ) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + }) + .await + .map_err(plugin_host_handler_error)?; + let cancel_bridge = bridge.clone(); + client + .register_handler("backend.stream.cancel", move |params| { + let bridge = cancel_bridge.clone(); + async move { + let params: StreamCancelParams = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.stream.cancel", error))?; + serde_json::to_value( + bridge + .streams + .cancel(params) + .await + .map_err(stream_rpc_error)?, + ) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + }) + .await + .map_err(plugin_host_handler_error)?; + PLUGIN_HOST_BACKEND_BRIDGE + .set(bridge.clone()) + .map_err(|_| { + crate::BitFunError::ProcessError( + "Plugin host backend bridge is already initialized".to_string(), + ) + })?; + Ok(bridge) +} + +pub(crate) fn plugin_host_backend_bridge() -> Option> { + PLUGIN_HOST_BACKEND_BRIDGE.get().cloned() +} + +fn invalid_rpc_params(method: &str, error: serde_json::Error) -> RpcHandlerError { + RpcHandlerError::new(-32602, format!("Invalid parameters for {method}: {error}")) +} + +fn plugin_host_handler_error( + error: bitfun_opencode_plugin_host::PluginHostError, +) -> crate::BitFunError { + crate::BitFunError::ProcessError(format!( + "Failed to register plugin host backend handler: {error}" + )) +} + +fn stream_rpc_error(error: StreamRegistryError) -> RpcHandlerError { + match error { + StreamRegistryError::InstanceMismatch => RpcHandlerError::new(-32003, error.to_string()), + StreamRegistryError::InvalidMaxBytes => RpcHandlerError::new(-32602, error.to_string()), + StreamRegistryError::Capacity | StreamRegistryError::BodyTooLarge => { + RpcHandlerError::new(-32000, error.to_string()) + } + } +} + +fn parse_body(body: &[u8]) -> Result { + if body.is_empty() { + serde_json::from_value(json!({})) + .map_err(|error| RouteFailure::bad_request(error.to_string())) + } else { + serde_json::from_slice(body).map_err(|error| RouteFailure::bad_request(error.to_string())) + } +} + +async fn dispatch_route( + context: &crate::plugin_host::PluginHostInstance, + route: OpenCodeClientRoute, + query: &std::collections::HashMap>, + body: &[u8], +) -> Result { + crate::plugin_host_http_routes::dispatch_route(context, route, query, body).await +} + +pub(crate) fn body_as(body: &[u8]) -> Result { + parse_body(body) +} + +pub(crate) type RouteResult = Result; +pub(crate) use RouteFailure as Failure; diff --git a/src/crates/assembly/core/src/plugin_host_http_routes.rs b/src/crates/assembly/core/src/plugin_host_http_routes.rs new file mode 100644 index 0000000000..8baf9d565e --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host_http_routes.rs @@ -0,0 +1,858 @@ +use crate::plugin_host::PluginHostInstance; +// OpenCode client route projections backed by BitFun owners. +// +// The adapter owns OpenCode path/method matching, wire DTOs, framing, and +// transport errors. This module only invokes the existing BitFun session, +// filesystem, terminal, MCP, Git, and model owners and projects their +// results into the adapter's route contract. The logical instance and PTY +// maps in `plugin_host` scope those calls; they are not physical process +// supervision (that remains in the adapter/services process-tree boundary). + +use crate::plugin_host_http::{body_as, Failure, RouteResult}; +use bitfun_opencode_plugin_host::OpenCodeClientRoute; +use serde_json::{json, Value}; +use std::collections::HashMap; + +pub(crate) async fn dispatch_route( + context: &PluginHostInstance, + route: OpenCodeClientRoute, + query: &HashMap>, + body: &[u8], +) -> RouteResult { + match route { + OpenCodeClientRoute::ProjectList => project_list(context).await, + OpenCodeClientRoute::ProjectCurrent => Ok(project_value(context)), + OpenCodeClientRoute::PathGet => path_get(context), + OpenCodeClientRoute::VcsGet => vcs_get(context).await, + OpenCodeClientRoute::ConfigGet => config_get().await, + OpenCodeClientRoute::ConfigProviders => config_providers().await, + OpenCodeClientRoute::ProviderList => provider_list().await, + OpenCodeClientRoute::ToolIds => tool_ids().await, + OpenCodeClientRoute::ToolList => tool_list(query).await, + OpenCodeClientRoute::AppLog => app_log(context, body), + OpenCodeClientRoute::AgentList => agent_list(context).await, + OpenCodeClientRoute::CommandList => command_list(context).await, + OpenCodeClientRoute::SessionList => session_list(context).await, + OpenCodeClientRoute::SessionCreate => session_create(context, body).await, + OpenCodeClientRoute::SessionStatus => session_status(context).await, + OpenCodeClientRoute::SessionDelete { session_id } => { + session_delete(context, &session_id).await + } + OpenCodeClientRoute::SessionGet { session_id } => session_get(context, &session_id).await, + OpenCodeClientRoute::SessionUpdate { session_id } => { + session_update(context, &session_id, body).await + } + OpenCodeClientRoute::SessionChildren { session_id } => { + session_children(context, &session_id).await + } + OpenCodeClientRoute::SessionTodo { session_id } => session_todo(context, &session_id).await, + OpenCodeClientRoute::SessionFork { session_id } => { + session_fork(context, &session_id, body).await + } + OpenCodeClientRoute::SessionAbort { session_id } => { + session_abort(context, &session_id).await + } + OpenCodeClientRoute::SessionDiff { session_id } => { + session_diff(context, &session_id, query).await + } + OpenCodeClientRoute::SessionMessages { session_id } => { + session_messages(context, &session_id, query).await + } + OpenCodeClientRoute::SessionMessage { + session_id, + message_id, + } => session_message(context, &session_id, &message_id).await, + OpenCodeClientRoute::PtyList => pty_list(context).await, + OpenCodeClientRoute::PtyCreate => pty_create(context, body).await, + OpenCodeClientRoute::PtyDelete { pty_id } => pty_delete(context, &pty_id).await, + OpenCodeClientRoute::PtyGet { pty_id } => pty_get(context, &pty_id).await, + OpenCodeClientRoute::PtyUpdate { pty_id } => pty_update(context, &pty_id, body).await, + OpenCodeClientRoute::FindText => find_text(context, query).await, + OpenCodeClientRoute::FindFiles => find_files(context, query).await, + OpenCodeClientRoute::FileList => file_list(context, query).await, + OpenCodeClientRoute::FileRead => file_read(context, query).await, + OpenCodeClientRoute::FileStatus => file_status(context).await, + OpenCodeClientRoute::McpStatus => mcp_status().await, + OpenCodeClientRoute::LspStatus => lsp_status(context).await, + } +} + +fn query_first<'a>(query: &'a HashMap>, key: &str) -> Option<&'a str> { + query + .get(key) + .and_then(|values| values.first()) + .map(String::as_str) +} + +fn required_query<'a>( + query: &'a HashMap>, + key: &str, +) -> Result<&'a str, Failure> { + query_first(query, key) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| Failure::bad_request(format!("Missing required query parameter: {key}"))) +} + +fn project_value(context: &PluginHostInstance) -> Value { + json!({ + "id": context.project_id, + "worktree": context.worktree.to_string_lossy(), + "time": {"created": context.created_at_ms}, + }) +} + +async fn project_list(context: &PluginHostInstance) -> RouteResult { + Ok(json!([project_value(context)])) +} + +fn path_get(context: &PluginHostInstance) -> RouteResult { + let path_manager = crate::infrastructure::try_get_path_manager_arc() + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(json!({ + "state": path_manager.project_runtime_root(&context.directory).to_string_lossy(), + "config": path_manager.project_internal_config_dir(&context.directory).to_string_lossy(), + "worktree": context.worktree.to_string_lossy(), + "directory": context.directory.to_string_lossy(), + })) +} + +async fn vcs_get(context: &PluginHostInstance) -> RouteResult { + let repository = crate::service::git::GitService::get_repository_basic(&context.worktree) + .await + .map_err(|error| Failure::not_found(format!("Git repository is unavailable: {error}")))?; + Ok(json!({"branch": repository.current_branch})) +} + +async fn config_get() -> RouteResult { + use crate::service::config::{get_global_config_service, GlobalConfig}; + let service = get_global_config_service() + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let config: GlobalConfig = service + .get_config(None) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let plugins = config + .plugin + .iter() + .map(|plugin| plugin.spec().to_string()) + .filter(|spec| !spec.trim().is_empty()) + .collect::>(); + Ok(json!({ + "plugin": plugins, + "logLevel": config.app.logging.level.trim().to_ascii_uppercase(), + })) +} + +fn provider_projection( + models: &[crate::service::config::AIModelConfig], + catalog: &bitfun_core_types::ProviderCatalog, + full_model_dto: bool, +) -> Vec { + let mut grouped = + std::collections::BTreeMap::>::new(); + for model in models.iter().filter(|model| model.enabled) { + grouped + .entry(model.provider.clone()) + .or_default() + .push(model); + } + grouped + .into_iter() + .map(|(provider_id, models)| { + let model_values = models + .iter() + .map(|model| { + let attachment = model.capabilities.contains(&crate::service::config::ModelCapability::ImageUnderstanding); + let tool_call = model.capabilities.contains(&crate::service::config::ModelCapability::FunctionCalling); + let context = model.context_window.unwrap_or(crate::service::config::DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS); + let output = model.max_tokens.unwrap_or_else(|| crate::service::config::automatic_max_output_tokens(context)); + let catalog_model = matching_catalog_model(catalog, model); + let input_modalities = catalog_model + .map(|entry| entry.capabilities.input_modalities.clone()) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| if attachment { vec!["text".to_string(), "image".to_string()] } else { vec!["text".to_string()] }); + let output_modalities = catalog_model + .map(|entry| entry.capabilities.output_modalities.clone()) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| vec!["text".to_string()]); + let release_date = catalog_model.and_then(|entry| entry.release_date.clone()).unwrap_or_default(); + let status = catalog_model + .and_then(|entry| entry.status.as_deref()) + .filter(|status| matches!(*status, "alpha" | "beta" | "deprecated" | "active")) + .unwrap_or("active"); + let model_context = catalog_model + .and_then(|entry| entry.limits.as_ref()) + .and_then(|limits| limits.context) + .unwrap_or(context); + let model_output = catalog_model + .and_then(|entry| entry.limits.as_ref()) + .and_then(|limits| limits.output) + .unwrap_or(output); + let npm = provider_npm(&model.provider); + let api_url = model.request_url.as_deref().unwrap_or(&model.base_url); + let mut value = if full_model_dto { + json!({ + "id": model.id, + "providerID": model.provider, + "api": {"id": model.model_name, "url": api_url, "npm": npm}, + "name": model.name, + "capabilities": { + "temperature": model.temperature.is_some(), + "reasoning": model.reasoning.is_some() || catalog_model.is_some_and(|entry| entry.capabilities.reasoning), + "attachment": attachment, + "toolcall": tool_call, + "input": modality_flags(&input_modalities), + "output": modality_flags(&output_modalities), + }, + "cost": model_cost(catalog_model), + "limit": {"context": model_context, "output": model_output}, + "status": status, + "options": {}, + "headers": {}, + }) + } else { + json!({ + "id": model.id, + "name": model.name, + "release_date": release_date, + "attachment": attachment, + "reasoning": model.reasoning.is_some() || catalog_model.is_some_and(|entry| entry.capabilities.reasoning), + "temperature": model.temperature.is_some(), + "tool_call": tool_call, + "limit": {"context": model_context, "output": model_output}, + "modalities": {"input": input_modalities, "output": output_modalities}, + "status": status, + "options": {}, + "provider": {"npm": npm}, + }) + }; + if !full_model_dto { + if let Some(cost) = optional_model_cost(catalog_model) { + value["cost"] = cost; + } + } + (model.id.clone(), value) + }) + .collect::>(); + let api = models.first().map(|model| model.base_url.clone()); + json!({ + "id": provider_id, + "name": provider_id, + "env": [], + "api": api, + "npm": provider_npm(&provider_id), + "models": model_values, + }) + }) + .collect() +} + +fn provider_npm(provider: &str) -> &'static str { + match provider.trim().to_ascii_lowercase().as_str() { + "anthropic" => "@ai-sdk/anthropic", + "gemini" | "google" | "gemini-code-assist" => "@ai-sdk/google", + "openai-responses" => "@ai-sdk/openai", + _ => "@ai-sdk/openai-compatible", + } +} + +fn modality_flags(modalities: &[String]) -> Value { + let supports = |value: &str| { + modalities + .iter() + .any(|entry| entry.eq_ignore_ascii_case(value)) + }; + json!({ + "text": supports("text"), + "audio": supports("audio"), + "image": supports("image"), + "video": supports("video"), + "pdf": supports("pdf"), + }) +} + +fn matching_catalog_model<'a>( + catalog: &'a bitfun_core_types::ProviderCatalog, + model: &crate::service::config::AIModelConfig, +) -> Option<&'a bitfun_core_types::ProviderCatalogModel> { + let mut matches = catalog + .providers + .iter() + .flat_map(|provider| provider.models.iter()) + .filter(|entry| { + entry.id.eq_ignore_ascii_case(&model.model_name) + || entry.id.eq_ignore_ascii_case(&model.id) + }); + let first = matches.next()?; + matches.next().is_none().then_some(first) +} + +fn price(value: Option<&str>) -> Option { + value + .and_then(|value| value.parse::().ok()) + .filter(|value| value.is_finite() && *value >= 0.0) +} + +fn optional_model_cost(model: Option<&bitfun_core_types::ProviderCatalogModel>) -> Option { + let pricing = model?.pricing.as_ref()?; + let input = price(pricing.input.as_deref())?; + let output = price(pricing.output.as_deref())?; + let mut cost = json!({"input": input, "output": output}); + if let Some(cache_read) = price(pricing.cache_read.as_deref()) { + cost["cache_read"] = json!(cache_read); + } + if let Some(cache_write) = price(pricing.cache_write.as_deref()) { + cost["cache_write"] = json!(cache_write); + } + Some(cost) +} + +fn model_cost(model: Option<&bitfun_core_types::ProviderCatalogModel>) -> Value { + let pricing = model.and_then(|entry| entry.pricing.as_ref()); + json!({ + "input": price(pricing.and_then(|entry| entry.input.as_deref())).unwrap_or(0.0), + "output": price(pricing.and_then(|entry| entry.output.as_deref())).unwrap_or(0.0), + "cache": { + "read": price(pricing.and_then(|entry| entry.cache_read.as_deref())).unwrap_or(0.0), + "write": price(pricing.and_then(|entry| entry.cache_write.as_deref())).unwrap_or(0.0), + }, + }) +} + +async fn load_models() -> Result< + ( + Vec, + HashMap, + bitfun_core_types::ProviderCatalog, + ), + Failure, +> { + use crate::service::config::{get_global_config_service, GlobalConfig}; + let service = get_global_config_service() + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let config: GlobalConfig = service + .get_config(None) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let primary = config.ai.default_models.primary; + let mut defaults = HashMap::new(); + for model in config.ai.models.iter().filter(|model| model.enabled) { + defaults + .entry(model.provider.clone()) + .or_insert_with(|| model.id.clone()); + } + if let Some(primary) = primary { + if let Some(model) = config + .ai + .models + .iter() + .find(|model| model.enabled && model.id == primary) + { + defaults.insert(model.provider.clone(), model.id.clone()); + } + } + let catalog = crate::get_ai_model_catalog() + .await + .map_err(Failure::backend)? + .provider_catalog; + Ok((config.ai.models, defaults, catalog)) +} + +async fn config_providers() -> RouteResult { + let (models, defaults, catalog) = load_models().await?; + let providers = provider_projection(&models, &catalog, true) + .into_iter() + .map(|provider| { + let id = provider["id"].as_str().unwrap_or_default(); + let models = provider["models"].clone(); + json!({ + "id": id, + "name": id, + "source": "config", + "env": [], + "options": {}, + "models": models, + }) + }) + .collect::>(); + Ok(json!({"providers": providers, "default": defaults})) +} + +async fn provider_list() -> RouteResult { + let (models, defaults, catalog) = load_models().await?; + let all = provider_projection(&models, &catalog, false); + // Configured model records are not proof of live credentials or network + // connectivity. Until the provider owner exposes that fact, project an + // honest empty connected set instead of claiming every provider is live. + Ok(json!({"all": all, "default": defaults, "connected": []})) +} + +async fn enabled_tools() -> Vec> { + let mut tools = Vec::new(); + for tool in crate::agentic::tools::registry::get_all_registered_tools().await { + if tool.is_enabled().await { + tools.push(tool); + } + } + tools +} + +async fn tool_ids() -> RouteResult { + Ok(json!(enabled_tools() + .await + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>())) +} + +async fn tool_list(query: &HashMap>) -> RouteResult { + required_query(query, "provider")?; + required_query(query, "model")?; + let mut output = Vec::new(); + for tool in enabled_tools().await { + output.push(json!({ + "id": tool.name(), + "description": tool.description().await.unwrap_or_else(|_| tool.short_description()), + "parameters": tool.input_schema(), + })); + } + Ok(Value::Array(output)) +} + +#[derive(serde::Deserialize)] +struct AppLogBody { + service: String, + level: String, + message: String, +} + +fn app_log(context: &PluginHostInstance, body: &[u8]) -> RouteResult { + let input: AppLogBody = body_as(body)?; + if input.service.trim().is_empty() + || input.message.trim().is_empty() + || input.message.len() > 16 * 1024 + { + return Err(Failure::bad_request( + "Log service and message must be non-empty and bounded", + )); + } + let message = input.message.replace(['\r', '\n'], " "); + match input.level.to_ascii_lowercase().as_str() { + "debug" => log::debug!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + "info" => log::info!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + "warn" => log::warn!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + "error" => log::error!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + _ => return Err(Failure::bad_request("Unsupported log level")), + } + Ok(json!(true)) +} + +async fn agent_list(context: &PluginHostInstance) -> RouteResult { + let registry = crate::agentic::agents::get_agent_registry(); + let mut entries = registry + .get_modes_info_for_workspace(Some(&context.directory), true) + .await + .into_iter() + .map(|agent| (agent, "primary")) + .collect::>(); + entries.extend( + registry + .get_subagents_info(Some(&context.directory)) + .await + .into_iter() + .filter(|agent| agent.effective_enabled) + .map(|agent| (agent, "subagent")), + ); + Ok(Value::Array( + entries + .into_iter() + .map(|(agent, mode)| { + let tools = agent + .default_tools + .into_iter() + .map(|tool| (tool, Value::Bool(true))) + .collect::>(); + json!({ + "name": agent.id, + "description": agent.description, + "mode": mode, + "builtIn": matches!(agent.source, crate::agentic::agents::AgentSource::Builtin), + "tools": tools, + "options": {}, + }) + }) + .collect(), + )) +} + +async fn command_list(context: &PluginHostInstance) -> RouteResult { + let snapshot = + crate::external_sources::external_source_snapshot(Some(&context.directory), false) + .await + .map_err(Failure::backend)?; + Ok(Value::Array( + snapshot + .commands + .into_iter() + .filter_map(|entry| { + if !matches!( + entry.definition.availability, + crate::external_sources::PromptCommandAvailability::Available + ) { + return None; + } + Some(json!({ + "name": entry.definition.name, + "description": entry.definition.description, + "template": entry.definition.template, + "subtask": !entry.definition.execution_target.is_inline(), + })) + }) + .collect(), + )) +} + +// Session, PTY, filesystem, and MCP route implementations follow below. + +include!("plugin_host_http_routes_impl.rs"); + +#[cfg(test)] +mod tests { + use super::{ + app_log, assistant_parts, file_list, file_read, find_files, find_text, parse_pty_shell, + lsp_status, project_list, project_value, provider_projection, pty_create, pty_value, + resolve_scoped_path, search_line_offsets, session_create, session_update, tool_list, + }; + use crate::plugin_host::PluginHostInstance; + use crate::service::session::{ModelRoundData, ToolCallData, ToolItemData, ToolResultData}; + use bitfun_services_core::filesystem::{FileSearchResult, SearchMatchType}; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + use terminal_core::{SessionResponse, SessionSource, ShellType}; + + fn instance(directory: PathBuf, instance_id: &str, project_id: &str) -> PluginHostInstance { + let directory = dunce::canonicalize(directory).expect("canonical temporary workspace"); + PluginHostInstance { + canonical_directory: directory.to_string_lossy().into_owned(), + directory: directory.clone(), + worktree: directory, + project_id: project_id.to_string(), + created_at_ms: 1, + instance_id: instance_id.to_string(), + open_result: json!({}), + ready: true, + } + } + + #[tokio::test] + async fn project_list_isolated_to_current_instance() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + + let value = project_list(&context).await.expect("project list"); + + assert_eq!(value.as_array().map(Vec::len), Some(1)); + assert_eq!(value[0]["id"], "project-a"); + } + + #[tokio::test] + async fn lsp_route_reports_retired_capability_without_restoring_runtime() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + + let failure = lsp_status(&context) + .await + .expect_err("explore keeps LSP retired"); + + assert_eq!(failure.status, 501); + assert_eq!(failure.code, "unsupported_capability"); + assert!(failure.message.contains("retired")); + } + + #[test] + fn project_current_projects_only_instance_bound_workspace_data() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + + let value = project_value(&context); + + assert_eq!(value["id"], "project-a"); + assert_eq!( + value["worktree"], + context.worktree.to_string_lossy().as_ref() + ); + assert!(value.get("vcsDir").is_none()); + assert!(value.get("vcs").is_none()); + assert_eq!(value["time"]["created"], 1); + assert!(value.get("directory").is_none()); + } + + #[test] + fn scoped_path_rejects_traversal_and_sibling_prefixes() { + let directory = tempfile::tempdir().expect("temporary root"); + let workspace = directory.path().join("project"); + let sibling = directory.path().join("project-sibling"); + std::fs::create_dir_all(&workspace).expect("workspace"); + std::fs::create_dir_all(&sibling).expect("sibling"); + let context = instance(workspace, "instance-a", "project-a"); + + assert!(resolve_scoped_path(&context, "../project-sibling").is_err()); + assert!(resolve_scoped_path(&context, &sibling.to_string_lossy()).is_err()); + } + + #[test] + fn provider_projection_omits_credentials() { + let model = crate::service::config::AIModelConfig { + id: "model-a".to_string(), + name: "Model A".to_string(), + provider: "provider-a".to_string(), + model_name: "upstream-model-a".to_string(), + base_url: "https://example.invalid/v1".to_string(), + api_key: "secret-api-key".to_string(), + custom_headers: Some(std::collections::HashMap::from([( + "authorization".to_string(), + "secret-header".to_string(), + )])), + enabled: true, + ..Default::default() + }; + + let value = serde_json::to_string(&provider_projection( + &[model], + &bitfun_core_types::ProviderCatalog::default(), + true, + )) + .expect("provider projection"); + + assert!(!value.contains("secret-api-key")); + assert!(!value.contains("secret-header")); + assert!(!value.to_ascii_lowercase().contains("authorization")); + } + + #[test] + fn app_log_validates_body_and_supported_levels() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + + for level in ["debug", "info", "warn", "error"] { + let body = serde_json::to_vec(&json!({ + "service": "route-test", + "level": level, + "message": "line one\nline two", + })) + .expect("log body"); + assert_eq!(app_log(&context, &body).expect("accepted log"), json!(true)); + } + + for body in [ + json!({"service": "", "level": "info", "message": "message"}), + json!({"service": "route-test", "level": "trace", "message": "message"}), + json!({"service": "route-test", "level": "info", "message": ""}), + ] { + let body = serde_json::to_vec(&body).expect("invalid log body"); + assert!(app_log(&context, &body).is_err()); + } + assert!(app_log(&context, b"not-json").is_err()); + } + + #[tokio::test] + async fn handlers_reject_missing_required_inputs_before_service_access() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + let query = HashMap::new(); + + assert!(tool_list(&query).await.is_err()); + assert!(find_text(&context, &query).await.is_err()); + assert!(find_files(&context, &query).await.is_err()); + assert!(file_list(&context, &query).await.is_err()); + assert!(file_read(&context, &query).await.is_err()); + assert!( + session_create(&context, br#"{"parentID":"session-parent"}"#) + .await + .is_err() + ); + assert!(session_update(&context, "session-a", b"{}").await.is_err()); + assert!(pty_create(&context, br#"{"args":["--version"]}"#) + .await + .is_err()); + assert!(parse_pty_shell("unsupported-plugin-shell").is_err()); + } + + #[tokio::test] + async fn file_read_returns_workspace_text_content() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let file = directory.path().join("fixture.txt"); + tokio::fs::write(&file, "plugin route fixture") + .await + .expect("write fixture"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + let query = HashMap::from([("path".to_string(), vec!["fixture.txt".to_string()])]); + + let value = file_read(&context, &query).await.expect("file response"); + + assert_eq!(value["type"], "text"); + assert_eq!(value["content"], "plugin route fixture"); + } + + #[test] + fn assistant_parts_project_completed_tool_state() { + let tool = ToolItemData { + id: "tool-item".to_string(), + tool_name: "demo_tool".to_string(), + tool_call: ToolCallData { + input: json!({"value": 7}), + id: "call-1".to_string(), + }, + tool_result: Some(ToolResultData { + result: json!({"echo": 7}), + success: true, + result_for_assistant: Some("echoed".to_string()), + image_attachments: None, + error: None, + duration_ms: Some(2), + }), + ai_intent: Some("Echo value".to_string()), + start_time: 10, + end_time: Some(12), + duration_ms: Some(2), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(2), + order_index: Some(0), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_dialog_turn_id: None, + attempt_id: None, + attempt_index: None, + subagent_model_id: None, + subagent_model_display_name: None, + status: Some("completed".to_string()), + interruption_reason: None, + }; + let turn = crate::service::session::DialogTurnData { + turn_id: "assistant-message".to_string(), + turn_index: 0, + session_id: "session-a".to_string(), + timestamp: 10, + kind: Default::default(), + agent_type: Some("agentic".to_string()), + user_message: crate::service::session::UserMessageData { + id: "user-message".to_string(), + content: "hello".to_string(), + timestamp: 9, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-a".to_string(), + turn_id: "assistant-message".to_string(), + round_index: 0, + round_group_id: None, + timestamp: 10, + text_items: Vec::new(), + tool_items: vec![tool], + thinking_items: Vec::new(), + start_time: 10, + end_time: Some(12), + duration_ms: Some(2), + provider_id: Some("provider-a".to_string()), + model_config_id: Some("model-a".to_string()), + effective_model_name: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + attempt_diagnostics: Vec::new(), + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 10, + end_time: Some(12), + duration_ms: Some(2), + token_usage: None, + finish_reason: Some("stop".to_string()), + has_final_response: Some(true), + error: None, + error_detail: None, + recovery: None, + recovery_epoch: None, + status: crate::service::session::TurnStatus::Completed, + }; + + let parts = assistant_parts(&turn, "assistant-message"); + + assert_eq!(parts.len(), 1); + assert_eq!(parts[0]["type"], "tool"); + assert_eq!(parts[0]["state"]["status"], "completed"); + assert_eq!(parts[0]["state"]["input"]["value"], 7); + assert_eq!(parts[0]["state"]["output"], "echoed"); + } + + #[test] + fn pty_running_states_are_projected_as_running() { + for status in ["Starting", "Active", "Orphaned", "Restoring", "Terminating"] { + let value = pty_value(&SessionResponse { + id: "pty-a".to_string(), + name: "PTY A".to_string(), + shell_type: ShellType::Bash, + cwd: "/workspace".to_string(), + pid: Some(42), + status: status.to_string(), + cols: 80, + rows: 24, + source: SessionSource::default(), + }); + assert_eq!(value["status"], "running"); + } + } + + #[tokio::test] + async fn search_offsets_use_file_byte_positions() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let file = directory.path().join("search.txt"); + tokio::fs::write(&file, "abc\nxx needle yy\n") + .await + .expect("search fixture"); + let path = file.to_string_lossy().into_owned(); + let offsets = search_line_offsets(&[FileSearchResult { + path: path.clone(), + name: "search.txt".to_string(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: Some(2), + matched_content: Some("xx needle yy".to_string()), + preview_before: None, + preview_inside: None, + preview_after: None, + }]) + .await + .expect("line offsets"); + + assert_eq!(offsets.get(&(path, 2)), Some(&4)); + } +} diff --git a/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs b/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs new file mode 100644 index 0000000000..d5930a8ead --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs @@ -0,0 +1,1006 @@ +use crate::service::session::ToolItemIdentityExt; +use bitfun_runtime_ports::{GitPort, WorkspaceDiffFileStatus}; +use bitfun_services_core::filesystem::{FileSearchOptions, FileSearchResult, FileTreeNode}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use terminal_core::{ + CloseSessionRequest, CreateSessionRequest, ResizeRequest, SessionResponse, ShellType, + TerminalApi, +}; +use tokio::io::{AsyncBufReadExt, BufReader}; + +fn resolve_scoped_path(context: &PluginHostInstance, value: &str) -> Result { + let requested = PathBuf::from(value); + let path = if requested.is_absolute() { + requested + } else { + context.directory.join(requested) + }; + let canonical = + dunce::canonicalize(&path).map_err(|_| Failure::not_found("Path does not exist"))?; + if !canonical.starts_with(&context.directory) { + return Err(Failure::forbidden( + "Path is outside the plugin instance workspace", + )); + } + Ok(canonical) +} + +fn relative_path(context: &PluginHostInstance, path: &Path) -> String { + path.strip_prefix(&context.directory) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn session_value( + context: &PluginHostInstance, + metadata: &crate::service::session::SessionMetadata, +) -> Value { + let project_id = context.project_id.clone(); + let mut value = json!({ + "id": metadata.session_id, + "projectID": project_id, + "directory": context.directory.to_string_lossy(), + "title": metadata.session_name, + "version": env!("CARGO_PKG_VERSION"), + "time": {"created": metadata.created_at, "updated": metadata.last_active_at}, + }); + if let Some(parent_id) = metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_ref()) + { + value["parentID"] = json!(parent_id); + } + value +} + +fn coordinator() -> Result, Failure> { + crate::agentic::coordination::get_global_coordinator() + .ok_or_else(|| Failure::unavailable("Session coordinator is not initialized")) +} + +async fn session_metadata( + context: &PluginHostInstance, + session_id: &str, +) -> Result { + bitfun_core_types::validate_session_id(session_id) + .map_err(|error| Failure::bad_request(error.to_string()))?; + let coordinator = coordinator()?; + coordinator + .get_session_manager() + .load_session_metadata(&context.directory, session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))? + .ok_or_else(|| Failure::not_found("Session was not found in this workspace")) +} + +async fn session_list(context: &PluginHostInstance) -> RouteResult { + let coordinator = coordinator()?; + let summaries = coordinator + .list_sessions(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let persistence = coordinator.get_session_manager().persistence_manager(); + let metadata = persistence + .list_session_metadata(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let values = metadata + .into_iter() + .filter(|item| { + summaries + .iter() + .any(|summary| summary.session_id == item.session_id) + }) + .map(|item| session_value(context, &item)) + .collect::>(); + Ok(Value::Array(values)) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct SessionCreateBody { + parent_id: Option, + title: Option, +} + +async fn session_create(context: &PluginHostInstance, body: &[u8]) -> RouteResult { + let input: SessionCreateBody = body_as(body)?; + let coordinator = coordinator()?; + if input.parent_id.is_some() { + return Err(Failure::bad_request( + "parentID session creation is not supported by the BitFun session owner", + )); + } + let session = coordinator + .create_session_with_workspace( + None, + input + .title + .unwrap_or_else(|| "OpenCode Plugin Session".to_string()), + "agentic".to_string(), + crate::agentic::core::SessionConfig { + workspace_path: Some(context.directory.to_string_lossy().into_owned()), + project_workspace_path: Some(context.directory.to_string_lossy().into_owned()), + ..Default::default() + }, + context.directory.to_string_lossy().into_owned(), + ) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let metadata = coordinator + .get_session_manager() + .load_session_metadata(&context.directory, &session.session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))? + .ok_or_else(|| Failure::backend("Created session metadata is unavailable"))?; + Ok(session_value(context, &metadata)) +} + +async fn session_status(context: &PluginHostInstance) -> RouteResult { + let coordinator = coordinator()?; + let sessions = coordinator + .list_sessions(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let statuses = sessions + .into_iter() + .map(|summary| { + let status = match summary.state { + crate::agentic::core::SessionState::Processing { .. } => json!({"type": "busy"}), + crate::agentic::core::SessionState::Error { error, .. } => { + json!({"type": "retry", "attempt": 0, "message": error, "next": 0}) + } + crate::agentic::core::SessionState::Idle => json!({"type": "idle"}), + }; + (summary.session_id, status) + }) + .collect::>(); + Ok(Value::Object(statuses)) +} + +async fn session_get(context: &PluginHostInstance, session_id: &str) -> RouteResult { + Ok(session_value( + context, + &session_metadata(context, session_id).await?, + )) +} + +async fn session_delete(context: &PluginHostInstance, session_id: &str) -> RouteResult { + let coordinator = coordinator()?; + session_metadata(context, session_id).await?; + let _ = coordinator + .cancel_active_turn_for_session(session_id, std::time::Duration::from_secs(2)) + .await; + coordinator + .delete_session(&context.directory, session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(json!(true)) +} + +#[derive(Deserialize)] +struct SessionUpdateBody { + title: Option, +} + +async fn session_update( + context: &PluginHostInstance, + session_id: &str, + body: &[u8], +) -> RouteResult { + let input: SessionUpdateBody = body_as(body)?; + let title = input + .title + .ok_or_else(|| Failure::bad_request("Only title updates are supported"))?; + session_metadata(context, session_id).await?; + let title = coordinator()? + .update_session_title(session_id, &title) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let mut metadata = session_metadata(context, session_id).await?; + metadata.session_name = title; + Ok(session_value(context, &metadata)) +} + +async fn session_children(context: &PluginHostInstance, session_id: &str) -> RouteResult { + session_metadata(context, session_id).await?; + let persistence = coordinator()?.get_session_manager().persistence_manager(); + let metadata = persistence + .list_session_metadata_including_internal(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array( + metadata + .into_iter() + .filter(|item| { + item.relationship + .as_ref() + .and_then(|r| r.parent_session_id.as_deref()) + == Some(session_id) + }) + .map(|item| session_value(context, &item)) + .collect(), + )) +} + +async fn session_todo(context: &PluginHostInstance, session_id: &str) -> RouteResult { + Ok(session_metadata(context, session_id) + .await? + .todos + .unwrap_or_else(|| json!([]))) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct SessionForkBody { + message_id: Option, +} + +async fn session_fork(context: &PluginHostInstance, session_id: &str, body: &[u8]) -> RouteResult { + let input: SessionForkBody = body_as(body)?; + let result = crate::product_runtime::fork_session_for_plugin( + context.directory.clone(), + session_id.to_string(), + input.message_id, + ) + .await + .map_err(Failure::backend)?; + let metadata = session_metadata(context, &result.session_id).await?; + Ok(session_value(context, &metadata)) +} + +async fn session_abort(context: &PluginHostInstance, session_id: &str) -> RouteResult { + session_metadata(context, session_id).await?; + coordinator()? + .cancel_active_turn_for_session(session_id, std::time::Duration::from_secs(2)) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(json!(true)) +} + +async fn session_diff( + context: &PluginHostInstance, + session_id: &str, + query: &HashMap>, +) -> RouteResult { + session_metadata(context, session_id).await?; + let Some(message_id) = query_first(query, "messageID") else { + return Ok(json!([])); + }; + let manager = crate::service::snapshot::open_snapshot_manager_for_view(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let turns = coordinator()? + .load_visible_persisted_session_turns(&context.directory, session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let turn = turns + .iter() + .find(|turn| turn.user_message.id == message_id) + .ok_or_else(|| Failure::not_found("Message was not found in this session"))?; + let files = manager + .get_turn_files(session_id, turn.turn_index) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let max_turn_exclusive = Some(turn.turn_index + 1); + let mut result = Vec::new(); + for file in files { + let file_path = file.to_string_lossy(); + let diff = manager + .get_file_diff_before(session_id, &file_path, None, max_turn_exclusive) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let before = diff + .get("original_content") + .and_then(Value::as_str) + .ok_or_else(|| { + Failure::backend("Session diff did not contain string original content") + })?; + let after = diff + .get("modified_content") + .and_then(Value::as_str) + .ok_or_else(|| { + Failure::backend("Session diff did not contain string modified content") + })?; + let stats = manager + .get_session_file_diff_stats_before(session_id, &file_path, max_turn_exclusive) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + result.push(json!({"file": relative_path(context, &file), "before": before, "after": after, "additions": stats.lines_added, "deletions": stats.lines_removed})); + } + Ok(Value::Array(result)) +} + +struct MessageProjectionContext<'a> { + instance: &'a PluginHostInstance, + session: &'a crate::service::session::SessionMetadata, + models: &'a [crate::service::config::AIModelConfig], + catalog: &'a bitfun_core_types::ProviderCatalog, +} + +fn message_model_identity<'a>( + context: &'a MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> ( + String, + String, + Option<&'a crate::service::config::AIModelConfig>, +) { + let round = turn.model_rounds.last(); + let configured = round + .and_then(|round| round.model_config_id.as_deref()) + .and_then(|id| context.models.iter().find(|model| model.id == id)) + .or_else(|| { + context + .models + .iter() + .find(|model| model.id == context.session.model_name) + }); + let provider_id = round + .and_then(|round| round.provider_id.clone()) + .or_else(|| configured.map(|model| model.provider.clone())) + .unwrap_or_else(|| "unknown".to_string()); + let model_id = round + .and_then(|round| { + round + .model_config_id + .clone() + .or_else(|| round.effective_model_name.clone()) + }) + .or_else(|| configured.map(|model| model.id.clone())) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + (provider_id, model_id, configured) +} + +fn user_message_projection( + context: &MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> Value { + let (provider_id, model_id, _) = message_model_identity(context, turn); + json!({ + "info": { + "id": turn.user_message.id, + "sessionID": turn.session_id, + "role": "user", + "time": {"created": turn.user_message.timestamp}, + "agent": turn.agent_type.as_deref().unwrap_or(&context.session.agent_type), + "model": {"providerID": provider_id, "modelID": model_id}, + }, + "parts": [{ + "id": format!("{}:text", turn.user_message.id), + "sessionID": turn.session_id, + "messageID": turn.user_message.id, + "type": "text", + "text": crate::agentic::core::strip_prompt_markup(&turn.user_message.content), + "time": {"start": turn.user_message.timestamp}, + }], + }) +} + +fn token_detail(turn: &crate::service::session::DialogTurnData, key: &str) -> u64 { + turn.model_rounds + .iter() + .filter_map(|round| round.token_details.as_ref()) + .filter_map(|details| details.get(key)) + .filter_map(Value::as_u64) + .sum() +} + +fn assistant_cost( + context: &MessageProjectionContext<'_>, + configured: Option<&crate::service::config::AIModelConfig>, + input: u64, + output: u64, + cache_read: u64, + cache_write: u64, +) -> f64 { + let pricing = configured + .and_then(|model| matching_catalog_model(context.catalog, model)) + .and_then(|model| model.pricing.as_ref()); + let input_price = price(pricing.and_then(|value| value.input.as_deref())).unwrap_or(0.0); + let output_price = price(pricing.and_then(|value| value.output.as_deref())).unwrap_or(0.0); + let cache_read_price = + price(pricing.and_then(|value| value.cache_read.as_deref())).unwrap_or(0.0); + let cache_write_price = + price(pricing.and_then(|value| value.cache_write.as_deref())).unwrap_or(0.0); + ((input as f64 * input_price) + + (output as f64 * output_price) + + (cache_read as f64 * cache_read_price) + + (cache_write as f64 * cache_write_price)) + / 1_000_000.0 +} + +fn tool_part( + session_id: &str, + message_id: &str, + item: &crate::service::session::ToolItemData, +) -> Value { + let input = item.effective_input().clone(); + let tool = item.effective_name(); + let state = match item.tool_result.as_ref() { + Some(result) if result.success => json!({ + "status": "completed", + "input": input, + "output": result.result_for_assistant.clone().unwrap_or_else(|| result.result.to_string()), + "title": item.ai_intent.as_deref().unwrap_or(tool), + "metadata": {}, + "time": {"start": item.start_time, "end": item.end_time.unwrap_or(item.start_time)}, + }), + Some(result) => json!({ + "status": "error", + "input": input, + "error": result.error.clone().unwrap_or_else(|| result.result.to_string()), + "metadata": {}, + "time": {"start": item.start_time, "end": item.end_time.unwrap_or(item.start_time)}, + }), + None => json!({ + "status": "running", + "input": input, + "title": item.ai_intent.as_deref().unwrap_or(tool), + "metadata": {}, + "time": {"start": item.start_time}, + }), + }; + json!({ + "id": item.id, + "sessionID": session_id, + "messageID": message_id, + "type": "tool", + "callID": item.tool_call.id, + "tool": tool, + "state": state, + }) +} + +fn assistant_parts(turn: &crate::service::session::DialogTurnData, message_id: &str) -> Vec { + let mut parts = Vec::<(usize, usize, Value)>::new(); + let mut sequence = 0usize; + for round in &turn.model_rounds { + for item in &round.thinking_items { + parts.push(( + item.order_index.unwrap_or(usize::MAX), + sequence, + json!({ + "id": item.id, + "sessionID": turn.session_id, + "messageID": message_id, + "type": "reasoning", + "text": item.content, + "time": {"start": item.timestamp}, + }), + )); + sequence += 1; + } + for item in &round.text_items { + parts.push(( + item.order_index.unwrap_or(usize::MAX), + sequence, + json!({ + "id": item.id, + "sessionID": turn.session_id, + "messageID": message_id, + "type": "text", + "text": item.content, + "time": {"start": item.timestamp}, + }), + )); + sequence += 1; + } + for item in &round.tool_items { + parts.push(( + item.order_index.unwrap_or(usize::MAX), + sequence, + tool_part(&turn.session_id, message_id, item), + )); + sequence += 1; + } + } + parts.sort_by_key(|(order, sequence, _)| (*order, *sequence)); + parts.into_iter().map(|(_, _, part)| part).collect() +} + +fn assistant_message_projection( + context: &MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> Option { + if turn.model_rounds.is_empty() { + return None; + } + let (provider_id, model_id, configured) = message_model_identity(context, turn); + let message_id = turn.turn_id.clone(); + let input_tokens = turn + .token_usage + .as_ref() + .map_or(0, |usage| usage.input_tokens); + let output_tokens = turn + .token_usage + .as_ref() + .and_then(|usage| usage.output_tokens) + .unwrap_or(0); + let reasoning_tokens = token_detail(turn, "reasoningTokenCount"); + let cache_read = token_detail(turn, "cachedContentTokenCount"); + let cache_write = token_detail(turn, "cacheCreationTokenCount"); + let mut info = json!({ + "id": message_id, + "sessionID": turn.session_id, + "role": "assistant", + "time": {"created": turn.start_time, "completed": turn.end_time}, + "parentID": turn.user_message.id, + "modelID": model_id, + "providerID": provider_id, + "mode": turn.agent_type.as_deref().unwrap_or(&context.session.agent_type), + "path": {"cwd": context.instance.directory, "root": context.instance.worktree}, + "cost": assistant_cost(context, configured, input_tokens, output_tokens, cache_read, cache_write), + "tokens": { + "input": input_tokens, + "output": output_tokens, + "reasoning": reasoning_tokens, + "cache": {"read": cache_read, "write": cache_write}, + }, + "finish": turn.finish_reason, + }); + if let Some(error) = turn.error.as_ref() { + info["error"] = json!({"name": "UnknownError", "data": {"message": error}}); + } + Some(json!({"info": info, "parts": assistant_parts(turn, &message_id)})) +} + +fn project_turn_messages( + context: &MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> Vec { + let mut messages = vec![user_message_projection(context, turn)]; + if let Some(assistant) = assistant_message_projection(context, turn) { + messages.push(assistant); + } + messages +} + +async fn session_messages( + context: &PluginHostInstance, + session_id: &str, + query: &HashMap>, +) -> RouteResult { + let metadata = session_metadata(context, session_id).await?; + let limit = query_first(query, "limit") + .map(|value| { + value + .parse::() + .map_err(|_| Failure::bad_request("limit must be a positive integer")) + }) + .transpose()? + .unwrap_or(100) + .clamp(1, 1000); + let turns = coordinator()? + .load_visible_persisted_session_turns(&context.directory, session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let (models, _, catalog) = load_models().await?; + let projection = MessageProjectionContext { + instance: context, + session: &metadata, + models: &models, + catalog: &catalog, + }; + let messages = turns + .iter() + .flat_map(|turn| project_turn_messages(&projection, turn)) + .collect::>(); + Ok(Value::Array( + messages + .into_iter() + .rev() + .take(limit) + .collect::>() + .into_iter() + .rev() + .collect(), + )) +} + +async fn session_message( + context: &PluginHostInstance, + session_id: &str, + message_id: &str, +) -> RouteResult { + let metadata = session_metadata(context, session_id).await?; + let turns = coordinator()? + .load_visible_persisted_session_turns(&context.directory, session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let (models, _, catalog) = load_models().await?; + let projection = MessageProjectionContext { + instance: context, + session: &metadata, + models: &models, + catalog: &catalog, + }; + turns + .iter() + .flat_map(|turn| project_turn_messages(&projection, turn)) + .find(|message| message["info"]["id"].as_str() == Some(message_id)) + .ok_or_else(|| Failure::not_found("Message was not found in this session")) +} + +fn pty_value(session: &SessionResponse) -> Value { + let status = if matches!( + session.status.to_ascii_lowercase().as_str(), + "starting" | "active" | "orphaned" | "restoring" | "terminating" | "running" + ) { + "running" + } else { + "exited" + }; + json!({"id": session.id, "title": session.name, "command": session.shell_type.default_executable(), "args": [], "cwd": session.cwd, "status": status, "pid": session.pid.unwrap_or(0)}) +} + +async fn pty_list(context: &PluginHostInstance) -> RouteResult { + let api = + TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + let sessions = api + .list_sessions() + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let mut values = Vec::new(); + let mut live_ids = std::collections::HashSet::new(); + for session in sessions { + live_ids.insert(session.id.clone()); + if crate::plugin_host::plugin_host_pty_owned_by(&session.id, &context.instance_id).await { + values.push(pty_value(&session)); + } + } + for pty_id in crate::plugin_host::plugin_host_pty_ids_for_instance(&context.instance_id).await { + if !live_ids.contains(&pty_id) { + crate::plugin_host::prune_plugin_host_pty(&pty_id, &context.instance_id).await; + } + } + Ok(Value::Array(values)) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PtyCreateBody { + command: Option, + args: Option>, + cwd: Option, + title: Option, + env: Option>, +} + +async fn pty_create(context: &PluginHostInstance, body: &[u8]) -> RouteResult { + let input: PtyCreateBody = body_as(body)?; + if input.args.as_ref().is_some_and(|args| !args.is_empty()) { + return Err(Failure::bad_request( + "BitFun terminal sessions do not support arbitrary PTY arguments", + )); + } + let cwd = input + .cwd + .as_deref() + .map(|value| resolve_scoped_path(context, value)) + .transpose()? + .unwrap_or_else(|| context.directory.clone()); + let shell_type = input.command.as_deref().map(parse_pty_shell).transpose()?; + let api = + TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + let session = api + .create_session(CreateSessionRequest { + session_id: None, + name: input.title, + shell_type, + shell_id: None, + working_directory: Some(cwd.to_string_lossy().into_owned()), + env: input.env, + cols: None, + rows: None, + remote_connection_id: None, + source: None, + }) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + crate::plugin_host::register_plugin_host_pty(&session.id, &context.instance_id).await; + Ok(pty_value(&session)) +} + +fn parse_pty_shell(command: &str) -> Result { + let shell = ShellType::from_executable(command.trim()); + if matches!(shell, ShellType::Custom(_)) { + return Err(Failure::bad_request( + "PTY command must select a supported BitFun shell", + )); + } + Ok(shell) +} + +async fn pty_get(context: &PluginHostInstance, pty_id: &str) -> RouteResult { + if !crate::plugin_host::plugin_host_pty_owned_by(pty_id, &context.instance_id).await { + return Err(Failure::not_found( + "PTY was not found in this plugin instance", + )); + } + let api = + TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + let session = match api.get_session(pty_id).await { + Ok(session) => session, + Err(error) => { + crate::plugin_host::prune_plugin_host_pty(pty_id, &context.instance_id).await; + return Err(Failure::not_found(error.to_string())); + } + }; + Ok(pty_value(&session)) +} + +async fn pty_delete(context: &PluginHostInstance, pty_id: &str) -> RouteResult { + if !crate::plugin_host::plugin_host_pty_owned_by(pty_id, &context.instance_id).await { + return Err(Failure::not_found( + "PTY was not found in this plugin instance", + )); + } + let api = + TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + if let Err(error) = api.get_session(pty_id).await { + crate::plugin_host::prune_plugin_host_pty(pty_id, &context.instance_id).await; + return Err(Failure::not_found(error.to_string())); + } + api.close_session(CloseSessionRequest { + session_id: pty_id.to_string(), + immediate: Some(false), + }) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + crate::plugin_host::unregister_plugin_host_pty(pty_id, &context.instance_id).await; + Ok(json!(true)) +} + +#[derive(Deserialize)] +struct PtyUpdateBody { + title: Option, + size: Option, +} +#[derive(Deserialize)] +struct PtySize { + rows: u16, + cols: u16, +} + +async fn pty_update(context: &PluginHostInstance, pty_id: &str, body: &[u8]) -> RouteResult { + let input: PtyUpdateBody = body_as(body)?; + pty_get(context, pty_id).await?; + if input.title.is_some() { + return Err(Failure::bad_request( + "PTY title updates are not supported by the BitFun terminal owner", + )); + } + if let Some(size) = input.size { + TerminalApi::from_singleton() + .map_err(|error| Failure::unavailable(error.to_string()))? + .resize(ResizeRequest { + session_id: pty_id.to_string(), + cols: size.cols, + rows: size.rows, + }) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + } + pty_get(context, pty_id).await +} + +async fn find_text( + context: &PluginHostInstance, + query: &HashMap>, +) -> RouteResult { + let pattern = required_query(query, "pattern")?; + let options = FileSearchOptions { + include_content: true, + case_sensitive: false, + use_regex: false, + whole_word: false, + max_results: Some(1000), + file_extensions: None, + include_directories: false, + }; + let outcome = crate::service::filesystem::FileSystemService::default() + .search_file_contents(&context.directory.to_string_lossy(), pattern, options, None) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let matcher = regex::RegexBuilder::new(®ex::escape(pattern)) + .case_insensitive(true) + .build() + .map_err(|error| Failure::bad_request(error.to_string()))?; + let offsets = search_line_offsets(&outcome.results).await?; + let mut results = Vec::new(); + for result in outcome.results { + let Some(line_number) = result.line_number else { + continue; + }; + let Some(line) = result.matched_content else { + continue; + }; + let submatches = matcher + .find_iter(&line) + .map(|matched| { + json!({ + "match": {"text": matched.as_str()}, + "start": matched.start(), + "end": matched.end(), + }) + }) + .collect::>(); + if submatches.is_empty() { + continue; + } + let path = Path::new(&result.path); + let relative = relative_path(context, path); + let absolute_offset = *offsets + .get(&(result.path.clone(), line_number)) + .ok_or_else(|| Failure::backend("Search result line was outside the indexed file"))?; + results.push(json!({ + "path": {"text": relative}, + "lines": {"text": line}, + "line_number": line_number, + "absolute_offset": absolute_offset, + "submatches": submatches, + })); + } + Ok(Value::Array(results)) +} + +async fn search_line_offsets( + results: &[FileSearchResult], +) -> Result, Failure> { + let mut requested = BTreeMap::>::new(); + for result in results { + if let Some(line_number) = result.line_number { + requested + .entry(result.path.clone()) + .or_default() + .insert(line_number); + } + } + + let mut offsets = HashMap::new(); + for (path, line_numbers) in requested { + let file = tokio::fs::File::open(&path) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let mut reader = BufReader::new(file); + let last_line = line_numbers.iter().next_back().copied().unwrap_or(0); + let mut line_number = 1usize; + let mut offset = 0u64; + let mut buffer = Vec::new(); + while line_number <= last_line { + buffer.clear(); + let bytes = reader + .read_until(b'\n', &mut buffer) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + if bytes == 0 { + break; + } + if line_numbers.contains(&line_number) { + offsets.insert((path.clone(), line_number), offset); + } + offset = offset.saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX)); + line_number += 1; + } + } + Ok(offsets) +} + +async fn find_files( + context: &PluginHostInstance, + query: &HashMap>, +) -> RouteResult { + let pattern = required_query(query, "query")?; + let options = FileSearchOptions { + include_content: false, + case_sensitive: false, + use_regex: false, + whole_word: false, + max_results: Some(1000), + file_extensions: None, + include_directories: query_first(query, "dirs") == Some("true"), + }; + let outcome = crate::service::filesystem::FileSystemService::default() + .search_file_names(&context.directory.to_string_lossy(), pattern, options, None) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array( + outcome + .results + .into_iter() + .map(|result| Value::String(relative_path(context, Path::new(&result.path)))) + .collect(), + )) +} + +fn file_node(context: &PluginHostInstance, node: FileTreeNode) -> Value { + json!({"name": node.name, "path": relative_path(context, Path::new(&node.path)), "absolute": node.path, "type": if node.is_directory {"directory"} else {"file"}, "ignored": false}) +} + +async fn file_list( + context: &PluginHostInstance, + query: &HashMap>, +) -> RouteResult { + let value = required_query(query, "path")?; + let path = resolve_scoped_path(context, value)?; + let nodes = crate::service::filesystem::FileSystemService::default() + .get_directory_contents(&path.to_string_lossy()) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array( + nodes + .into_iter() + .map(|node| file_node(context, node)) + .collect(), + )) +} + +async fn file_read( + context: &PluginHostInstance, + query: &HashMap>, +) -> RouteResult { + let path = resolve_scoped_path(context, required_query(query, "path")?)?; + let result = crate::service::filesystem::FileSystemService::default() + .read_file(&path.to_string_lossy()) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + if result.is_binary { + Ok(json!({"type": "binary", "content": result.content, "encoding": "base64"})) + } else { + Ok(json!({"type": "text", "content": result.content})) + } +} + +async fn file_status(context: &PluginHostInstance) -> RouteResult { + let snapshot = bitfun_services_integrations::git::GitWorkspaceDiffPort::new(&context.directory) + .workspace_diff() + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array(snapshot.files.into_iter().map(|file| json!({ + "path": file.path, + "added": file.additions, + "removed": file.deletions, + "status": match file.status { + WorkspaceDiffFileStatus::Added => "added", + WorkspaceDiffFileStatus::Deleted => "deleted", + WorkspaceDiffFileStatus::Modified | WorkspaceDiffFileStatus::Renamed | WorkspaceDiffFileStatus::Conflicted => "modified", + }, + })).collect())) +} + +async fn mcp_status() -> RouteResult { + let service = crate::service::mcp::get_global_mcp_service() + .ok_or_else(|| Failure::unavailable("MCP service is not initialized"))?; + let statuses = service + .server_manager() + .get_all_server_statuses() + .await + .into_iter() + .map(|(name, status)| { + let value = match status { + crate::service::mcp::MCPServerStatus::Connected + | crate::service::mcp::MCPServerStatus::Healthy => json!({"status": "connected"}), + crate::service::mcp::MCPServerStatus::NeedsAuth => json!({"status": "needs_auth"}), + crate::service::mcp::MCPServerStatus::Failed => { + json!({"status": "failed", "error": "MCP server failed"}) + } + _ => json!({"status": "disabled"}), + }; + (name, value) + }) + .collect::>(); + Ok(Value::Object(statuses)) +} + +async fn lsp_status(_context: &PluginHostInstance) -> RouteResult { + Err(Failure::unsupported( + "LSP runtime is retired in the 1.0.0-explore product line", + )) +} diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 6e72465ebc..97c9fbb79d 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -628,6 +628,61 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { /// harnesses; plugin runtime bindings are deliberately not part of this API. pub struct CoreProductAgentRuntime; +pub(crate) async fn fork_session_for_plugin( + workspace_path: PathBuf, + source_session_id: String, + source_message_id: Option, +) -> Result { + let coordinator = crate::agentic::coordination::get_global_coordinator() + .ok_or_else(|| "Session coordinator is not initialized".to_string())?; + let scheduler = crate::agentic::coordination::get_global_scheduler() + .ok_or_else(|| "Dialog scheduler is not initialized".to_string())?; + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| error.to_string())?; + let token_usage_service = Arc::new( + TokenUsageService::new(path_manager) + .await + .map_err(|error| error.to_string())?, + ); + let operations = + CoreSessionOperationsPort::new(coordinator.clone(), scheduler, token_usage_service); + match source_message_id { + Some(message_id) => { + let source_turn_id = coordinator + .get_messages(&source_session_id) + .await + .map_err(|error| error.to_string())? + .into_iter() + .find(|message| message.id == message_id) + .and_then(|message| message.metadata.turn_id) + .ok_or_else(|| format!("Source message was not found: {message_id}"))?; + AgentSessionForkPort::fork_session_at_turn( + &operations, + AgentSessionForkAtTurnRequest { + workspace_path: workspace_path.to_string_lossy().into_owned(), + source_session_id, + source_turn_id, + remote_connection_id: None, + remote_ssh_host: None, + }, + ) + .await + .map_err(|error| error.to_string()) + } + None => AgentSessionForkPort::fork_session( + &operations, + AgentSessionForkRequest { + workspace_path: workspace_path.to_string_lossy().into_owned(), + source_session_id, + remote_connection_id: None, + remote_ssh_host: None, + }, + ) + .await + .map_err(|error| error.to_string()), + } +} + impl CoreProductAgentRuntime { /// Build a narrow session and interaction facade for an existing product /// owner. This does not assemble runtime services, harnesses, events, or a diff --git a/src/crates/assembly/core/src/service/config/normalization.rs b/src/crates/assembly/core/src/service/config/normalization.rs index 39c887bb7d..32d4140f42 100644 --- a/src/crates/assembly/core/src/service/config/normalization.rs +++ b/src/crates/assembly/core/src/service/config/normalization.rs @@ -48,6 +48,9 @@ pub fn normalize_config_value(config: Value) -> ConfigNormalizationResult { diagnostics, }; } + + normalize_incompatible_telemetry_value(&mut value, &mut diagnostics); + if previous_schema < u64::from(CURRENT_CONFIG_SCHEMA_VERSION) { if let Some(root) = value.as_object_mut() { root.insert( @@ -73,6 +76,33 @@ pub fn normalize_config_value(config: Value) -> ConfigNormalizationResult { } } +fn normalize_incompatible_telemetry_value( + config: &mut Value, + diagnostics: &mut Vec, +) { + let Some(telemetry) = config + .get_mut("app") + .and_then(Value::as_object_mut) + .and_then(|app| app.get_mut("telemetry")) + else { + return; + }; + + if telemetry.is_boolean() { + return; + } + + *telemetry = Value::Bool(false); + diagnostics.push(ConfigDiagnostic { + path: "app.telemetry".to_string(), + message: "Disabled an unsupported telemetry configuration during compatibility recovery" + .to_string(), + code: "CONFIG_TELEMETRY_DOWNGRADED".to_string(), + severity: ConfigDiagnosticSeverity::Warning, + recoverability: ConfigDiagnosticRecoverability::AutoFix, + }); +} + pub fn reject_unsupported_schema(diagnostics: &[ConfigDiagnostic]) -> BitFunResult<()> { if let Some(diagnostic) = diagnostics .iter() diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index c6823889f7..237624189c 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -7,12 +7,14 @@ use super::types::*; use crate::util::errors::*; use log::{info, warn}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::Arc; use tokio::sync::RwLock; /// Configuration service. pub struct ConfigService { manager: Arc>, + runtime_ai_models: Arc>>, } /// Configuration import/export format. @@ -59,6 +61,7 @@ impl ConfigService { let service = Self { manager: Arc::new(RwLock::new(manager)), + runtime_ai_models: Arc::new(RwLock::new(BTreeMap::new())), }; let recovered_with_defaults = service @@ -91,6 +94,43 @@ impl ConfigService { } } + pub async fn install_runtime_ai_model(&self, model: AIModelConfig) -> BitFunResult<()> { + if model.id.trim().is_empty() { + return Err(BitFunError::validation( + "Runtime model id is required".to_string(), + )); + } + self.runtime_ai_models + .write() + .await + .insert(model.id.clone(), model); + Ok(()) + } + + pub async fn get_runtime_ai_model(&self, model_id: &str) -> Option { + self.runtime_ai_models.read().await.get(model_id).cloned() + } + + pub async fn get_effective_ai_config(&self) -> BitFunResult { + let mut ai: AIConfig = self.get_config(Some("ai")).await?; + for runtime_model in self.runtime_ai_models.read().await.values() { + if let Some(model) = ai + .models + .iter_mut() + .find(|model| model.id == runtime_model.id) + { + *model = runtime_model.clone(); + } else { + ai.models.push(runtime_model.clone()); + } + } + Ok(ai) + } + + pub async fn remove_runtime_ai_model(&self, model_id: &str) { + self.runtime_ai_models.write().await.remove(model_id); + } + /// Sets a configuration value (supports dot-paths). /// /// When the path touches AI models / default model slots / agent-model @@ -620,6 +660,21 @@ mod tests { } } + fn runtime_model(id: &str, key: &str) -> AIModelConfig { + AIModelConfig { + id: id.to_string(), + name: "SDK fixture".to_string(), + provider: "openai".to_string(), + model_name: "fixture-model".to_string(), + base_url: "http://127.0.0.1:43123/v1".to_string(), + api_key: key.to_string(), + enabled: true, + category: ModelCategory::GeneralChat, + capabilities: vec![ModelCapability::TextChat], + ..AIModelConfig::default() + } + } + async fn test_service(name: &str) -> (ConfigService, tempfile::TempDir) { let dir = tempfile::tempdir().expect("tempdir"); let user_root = dir.path().join(name); @@ -636,6 +691,194 @@ mod tests { (service, dir) } + #[tokio::test] + async fn runtime_ai_model_is_effective_but_never_persisted() { + let (service, _dir) = test_service("runtime-overlay-test").await; + + service + .install_runtime_ai_model(runtime_model("sdk:openai:fixture", "fixture-secret")) + .await + .unwrap(); + let runtime = service + .get_runtime_ai_model("sdk:openai:fixture") + .await + .unwrap(); + assert_eq!(runtime.api_key, "fixture-secret"); + let effective_ai = service.get_effective_ai_config().await.unwrap(); + assert!(effective_ai + .models + .iter() + .any(|model| model.id == "sdk:openai:fixture")); + let persisted: GlobalConfig = service.get_config(None).await.unwrap(); + assert!(!persisted + .ai + .models + .iter() + .any(|model| model.id == "sdk:openai:fixture")); + let persisted_models: Vec = + service.get_config(Some("ai.models")).await.unwrap(); + assert!(!persisted_models + .iter() + .any(|model| model.id == "sdk:openai:fixture")); + let persisted_ai: AIConfig = service.get_config(Some("ai")).await.unwrap(); + assert!(!persisted_ai + .models + .iter() + .any(|model| model.id == "sdk:openai:fixture")); + + let export = service.export_config().await.unwrap(); + let export_json = serde_json::to_string(&export).unwrap(); + assert!(!export_json.contains("sdk:openai:fixture")); + assert!(!export_json.contains("fixture-secret")); + + service + .reconcile_models("runtime-overlay-test") + .await + .unwrap(); + service + .add_ai_model(model("persisted", true, ModelCategory::GeneralChat)) + .await + .unwrap(); + let app_file = service + .get_statistics() + .await + .config_directory + .join("app.json"); + let disk = tokio::fs::read_to_string(app_file).await.unwrap(); + assert!(!disk.contains("sdk:openai:fixture")); + assert!(!disk.contains("fixture-secret")); + + let backup = service.create_backup().await.unwrap(); + let backup_text = tokio::fs::read_to_string(backup).await.unwrap(); + assert!(!backup_text.contains("sdk:openai:fixture")); + assert!(!backup_text.contains("fixture-secret")); + + service.remove_runtime_ai_model("sdk:openai:fixture").await; + let effective = service.get_effective_ai_config().await.unwrap(); + assert!(!effective + .models + .iter() + .any(|model| model.id == "sdk:openai:fixture")); + assert!(service + .get_runtime_ai_model("sdk:openai:fixture") + .await + .is_none()); + } + + #[tokio::test] + async fn runtime_ai_model_overlays_a_persisted_duplicate_for_effective_reads() { + let (service, _dir) = test_service("runtime-overlay-duplicate-test").await; + service + .add_ai_model(runtime_model("sdk:openai:duplicate", "persisted-secret")) + .await + .unwrap(); + service + .install_runtime_ai_model(runtime_model("sdk:openai:duplicate", "runtime-secret")) + .await + .unwrap(); + + let effective = service.get_effective_ai_config().await.unwrap(); + let matches = effective + .models + .iter() + .filter(|model| model.id == "sdk:openai:duplicate") + .collect::>(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].api_key, "runtime-secret"); + + let persisted: AIConfig = service.get_config(Some("ai")).await.unwrap(); + let persisted = persisted + .models + .iter() + .find(|model| model.id == "sdk:openai:duplicate") + .unwrap(); + assert_eq!(persisted.api_key, "persisted-secret"); + } + + #[tokio::test] + async fn review_team_policy_config_survives_service_restart() { + let dir = tempfile::tempdir().expect("tempdir"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + dir.path().join("review-team-concurrency"), + )); + let settings = || ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 0, + }; + + let service = ConfigService::with_settings(settings()) + .await + .expect("config service should start"); + service + .set_config( + "ai.review_teams.default", + serde_json::json!({ + "max_retries_per_role": 2, + "max_parallel_reviewers": 1, + "max_queue_wait_seconds": 45, + "allow_provider_capacity_queue": false, + "allow_bounded_auto_retry": true, + "auto_retry_elapsed_guard_seconds": 240, + }), + ) + .await + .expect("review team concurrency config should save"); + + let persisted: serde_json::Value = serde_json::from_str( + &tokio::fs::read_to_string(path_manager.app_config_file()) + .await + .expect("review team config should be persisted"), + ) + .expect("persisted config should be valid JSON"); + let persisted_team = &persisted["ai"]["review_teams"]["default"]; + assert_eq!(persisted_team["max_retries_per_role"], serde_json::json!(2)); + assert_eq!( + persisted_team["max_parallel_reviewers"], + serde_json::json!(1) + ); + assert_eq!( + persisted_team["max_queue_wait_seconds"], + serde_json::json!(45) + ); + assert_eq!( + persisted_team["allow_provider_capacity_queue"], + serde_json::json!(false) + ); + assert_eq!( + persisted_team["allow_bounded_auto_retry"], + serde_json::json!(true) + ); + assert_eq!( + persisted_team["auto_retry_elapsed_guard_seconds"], + serde_json::json!(240) + ); + + drop(service); + let reloaded_service = ConfigService::with_settings(settings()) + .await + .expect("config service should reload"); + let reloaded: serde_json::Value = reloaded_service + .get_config(Some("ai.review_teams.default")) + .await + .expect("review team config should be readable after reload"); + assert_eq!(reloaded["max_retries_per_role"], serde_json::json!(2)); + assert_eq!(reloaded["max_parallel_reviewers"], serde_json::json!(1)); + assert_eq!(reloaded["max_queue_wait_seconds"], serde_json::json!(45)); + assert_eq!( + reloaded["allow_provider_capacity_queue"], + serde_json::json!(false) + ); + assert_eq!( + reloaded["allow_bounded_auto_retry"], + serde_json::json!(true) + ); + assert_eq!( + reloaded["auto_retry_elapsed_guard_seconds"], + serde_json::json!(240) + ); + } + #[tokio::test] async fn compare_and_set_json_config_rejects_a_stale_snapshot() { let (service, _dir) = test_service("config-cas").await; @@ -663,6 +906,86 @@ mod tests { assert!(current["mcpServers"].get("stale").is_none()); } + #[tokio::test] + async fn startup_downgrades_structured_telemetry_without_losing_models() { + let dir = tempfile::tempdir().expect("tempdir"); + let user_root = dir.path().join("structured-telemetry-compatibility"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(user_root)); + path_manager + .initialize_user_directories() + .await + .expect("user directories"); + + let mut config = GlobalConfig::default(); + config + .ai + .models + .push(model("configured-model", true, ModelCategory::GeneralChat)); + let mut config_value = serde_json::to_value(config).expect("serialize config"); + config_value["app"]["telemetry"] = serde_json::json!({ + "version": 2, + "level": "basic", + "sensitive_content_consent": false, + }); + let original = serde_json::to_string_pretty(&config_value).expect("format config"); + tokio::fs::write(path_manager.app_config_file(), &original) + .await + .expect("seed config"); + + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 5, + }) + .await + .expect("config service should recover the telemetry field"); + + let loaded: GlobalConfig = service.get_config(None).await.expect("loaded config"); + assert!(loaded + .ai + .models + .iter() + .any(|configured| configured.id == "configured-model")); + + let diagnostics = service.load_diagnostics().await; + assert!(!diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "CONFIG_DEFAULT_RECOVERY")); + let telemetry_diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "CONFIG_TELEMETRY_DOWNGRADED") + .expect("telemetry compatibility diagnostic"); + assert_eq!(telemetry_diagnostic.path, "app.telemetry"); + assert_eq!( + telemetry_diagnostic.recoverability, + ConfigDiagnosticRecoverability::AutoFix + ); + + let persisted: serde_json::Value = serde_json::from_str( + &tokio::fs::read_to_string(path_manager.app_config_file()) + .await + .expect("persisted config"), + ) + .expect("valid persisted config"); + assert_eq!(persisted["app"]["telemetry"], serde_json::json!(false)); + + let backups = std::fs::read_dir(path_manager.user_config_dir().join("backups")) + .expect("backup directory") + .collect::, _>>() + .expect("backup entries"); + assert_eq!(backups.len(), 1); + assert!(backups[0] + .file_name() + .to_string_lossy() + .contains("startup-normalization")); + assert_eq!( + tokio::fs::read_to_string(backups[0].path()) + .await + .expect("backup content"), + original + ); + } + #[tokio::test] async fn startup_repairs_speech_sentinels_and_creates_a_backup() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index a25fffc927..c1efe8dfe4 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -72,6 +72,9 @@ pub struct GlobalConfig { /// ACP client configuration (stored as `{ "acpClients": { ... } }`). #[serde(skip_serializing_if = "Option::is_none")] pub acp_clients: Option, + /// OpenCode-compatible plugin declarations loaded by the process-wide plugin host. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub plugin: Vec, /// Web UI appearance selection. The full package contract is owned by the frontend. pub appearance: AppearanceConfig, /// Web UI font size preferences (`get_config` / `set_config` path `font`). @@ -86,6 +89,44 @@ pub struct GlobalConfig { pub last_modified: chrono::DateTime, } +impl GlobalConfig { + pub fn has_configured_plugins(&self) -> bool { + self.plugin + .iter() + .any(PluginDeclarationConfig::has_non_empty_spec) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PluginDeclarationConfig { + Spec(String), + Detailed(PluginDeclarationDetails), +} + +impl PluginDeclarationConfig { + pub fn spec(&self) -> &str { + match self { + Self::Spec(spec) => spec, + Self::Detailed(details) => &details.spec, + } + } + + fn has_non_empty_spec(&self) -> bool { + !self.spec().trim().is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDeclarationDetails { + pub spec: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_directory: Option, +} + /// Project-scoped configuration overlay. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] @@ -716,6 +757,18 @@ pub struct ReviewTeamConfig { pub reviewer_file_split_threshold: usize, /// Maximum number of same-role reviewer instances per role when file splitting is active. pub max_same_role_instances: usize, + /// Maximum retries for a failed same-role reviewer instance. + pub max_retries_per_role: usize, + /// Maximum number of review instances that may run at the same time. + pub max_parallel_reviewers: usize, + /// Seconds to wait for provider capacity before skipping unstarted work. 0 skips immediately. + pub max_queue_wait_seconds: u64, + /// Whether unstarted review work may wait for provider capacity. + pub allow_provider_capacity_queue: bool, + /// Whether bounded automatic retry is allowed after a reviewer failure. + pub allow_bounded_auto_retry: bool, + /// Elapsed-seconds guard that blocks bounded automatic retry after this delay. + pub auto_retry_elapsed_guard_seconds: u64, } impl Default for ReviewTeamConfig { @@ -729,6 +782,12 @@ impl Default for ReviewTeamConfig { auto_fix_enabled: false, reviewer_file_split_threshold: 20, max_same_role_instances: 3, + max_retries_per_role: 1, + max_parallel_reviewers: 2, + max_queue_wait_seconds: 1200, + allow_provider_capacity_queue: true, + allow_bounded_auto_retry: false, + auto_retry_elapsed_guard_seconds: 180, } } } @@ -1530,6 +1589,7 @@ impl Default for GlobalConfig { tool_permissions: ToolPermissionConfig::default(), mcp_servers: None, acp_clients: None, + plugin: Vec::new(), appearance: AppearanceConfig::default(), font: None, schema_version: CURRENT_CONFIG_SCHEMA_VERSION, @@ -2050,6 +2110,43 @@ mod tests { ); } + #[test] + fn plugin_config_defaults_to_empty_when_missing() { + let config: GlobalConfig = serde_json::from_value(serde_json::json!({})) + .expect("empty global config should default"); + + assert!(config.plugin.is_empty()); + assert!(!config.has_configured_plugins()); + } + + #[test] + fn non_empty_plugin_config_requests_runtime_startup() { + let config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "plugin": [ + "file:///C:/plugins/demo.mjs", + { + "spec": "@my-org/custom-plugin", + "options": { "mode": "strict" }, + "baseDirectory": "C:/workspace" + } + ] + })) + .expect("plugin config should deserialize"); + + assert_eq!(config.plugin.len(), 2); + assert!(config.has_configured_plugins()); + } + + #[test] + fn empty_plugin_specs_do_not_request_runtime_startup() { + let config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "plugin": ["", " ", { "spec": "" }] + })) + .expect("empty plugin declarations should deserialize"); + + assert!(!config.has_configured_plugins()); + } + #[test] fn permission_request_notifications_default_to_enabled() { assert!(NotificationConfig::default().permission_request_notify); @@ -2960,6 +3057,80 @@ mod tests { ); } + #[test] + fn preserves_review_team_concurrency_fields_through_config_round_trip() { + let config: AIConfig = serde_json::from_value(serde_json::json!({ + "models": [], + "default_models": {}, + "agent_profiles": {}, + "review_teams": { + "default": { + "extra_subagent_ids": [], + "strategy_level": "normal", + "member_strategy_overrides": {}, + "reviewer_timeout_seconds": 3600, + "judge_timeout_seconds": 2400, + "reviewer_file_split_threshold": 20, + "max_same_role_instances": 3, + "max_retries_per_role": 1, + "max_parallel_reviewers": 1, + "max_queue_wait_seconds": 0, + "allow_provider_capacity_queue": true, + "allow_bounded_auto_retry": false, + "auto_retry_elapsed_guard_seconds": 180 + } + }, + "proxy": { + "enabled": false, + "url": "" + } + })) + .expect("review team concurrency config should deserialize"); + + let serialized = serde_json::to_value(&config).expect("config should serialize"); + let stored = &serialized["review_teams"]["default"]; + assert_eq!(stored["max_retries_per_role"], serde_json::json!(1)); + assert_eq!(stored["max_parallel_reviewers"], serde_json::json!(1)); + assert_eq!(stored["max_queue_wait_seconds"], serde_json::json!(0)); + assert_eq!( + stored["allow_provider_capacity_queue"], + serde_json::json!(true) + ); + assert_eq!(stored["allow_bounded_auto_retry"], serde_json::json!(false)); + assert_eq!( + stored["auto_retry_elapsed_guard_seconds"], + serde_json::json!(180) + ); + } + + #[test] + fn missing_review_team_concurrency_fields_use_product_defaults() { + let config: AIConfig = serde_json::from_value(serde_json::json!({ + "models": [], + "review_teams": { + "default": { + "strategy_level": "normal" + } + } + })) + .expect("legacy review team config should deserialize"); + + let serialized = serde_json::to_value(&config).expect("config should serialize"); + let stored = &serialized["review_teams"]["default"]; + assert_eq!(stored["max_retries_per_role"], serde_json::json!(1)); + assert_eq!(stored["max_parallel_reviewers"], serde_json::json!(2)); + assert_eq!(stored["max_queue_wait_seconds"], serde_json::json!(1200)); + assert_eq!( + stored["allow_provider_capacity_queue"], + serde_json::json!(true) + ); + assert_eq!(stored["allow_bounded_auto_retry"], serde_json::json!(false)); + assert_eq!( + stored["auto_retry_elapsed_guard_seconds"], + serde_json::json!(180) + ); + } + #[test] fn review_team_auxiliary_config_is_not_stored_inside_review_team_map() { let config: AIConfig = serde_json::from_value(serde_json::json!({ diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index d6e1bbf3b6..92873260fa 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -955,11 +955,17 @@ impl AgentSessionManagementPort for ScheduledSessionManagementPort { } } -#[async_trait::async_trait] -impl AgentSessionClosePort for ScheduledSessionManagementPort { - async fn discard_transient_session( +#[derive(Clone, Copy)] +enum SessionReleaseKind { + DiscardTransient, + UnloadPersisted, +} + +impl ScheduledSessionManagementPort { + async fn release_session( &self, - request: bitfun_runtime_ports::AgentTransientSessionDiscardRequest, + request: bitfun_runtime_ports::AgentSessionReleaseRequest, + kind: SessionReleaseKind, ) -> bitfun_runtime_ports::PortResult { bitfun_core_types::validate_session_id(&request.session_id).map_err(|message| { bitfun_runtime_ports::PortError::new( @@ -994,29 +1000,58 @@ impl AgentSessionClosePort for ScheduledSessionManagementPort { if cleanup_budget.is_zero() { return Err(bitfun_runtime_ports::PortError::new( bitfun_runtime_ports::PortErrorKind::Timeout, - "Session close deadline was exhausted before transient resource cleanup", + "Session close deadline was exhausted before resource release", )); } - tokio::time::timeout( - cleanup_budget, - self.coordinator.discard_transient_session( - std::path::Path::new(&request.workspace_path), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - &request.session_id, - ), - ) + tokio::time::timeout(cleanup_budget, async { + match kind { + SessionReleaseKind::DiscardTransient => { + self.coordinator + .discard_transient_session( + std::path::Path::new(&request.workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + &request.session_id, + ) + .await + } + SessionReleaseKind::UnloadPersisted => { + session_manager + .unload_session_from_memory(&request.session_id) + .await + } + } + }) .await .map_err(|_| { bitfun_runtime_ports::PortError::new( bitfun_runtime_ports::PortErrorKind::Timeout, - "Transient Session resource cleanup exceeded the Session close deadline", + "Session resource release exceeded the Session close deadline", ) })? .map_err(map_session_close_error) } } +#[async_trait::async_trait] +impl AgentSessionClosePort for ScheduledSessionManagementPort { + async fn discard_transient_session( + &self, + request: bitfun_runtime_ports::AgentSessionReleaseRequest, + ) -> bitfun_runtime_ports::PortResult { + self.release_session(request, SessionReleaseKind::DiscardTransient) + .await + } + + async fn unload_persisted_session( + &self, + request: bitfun_runtime_ports::AgentSessionReleaseRequest, + ) -> bitfun_runtime_ports::PortResult { + self.release_session(request, SessionReleaseKind::UnloadPersisted) + .await + } +} + fn map_session_close_error( error: crate::util::errors::BitFunError, ) -> bitfun_runtime_ports::PortError { diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 170979f33f..9374689468 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -122,6 +122,7 @@ pub struct ProductCapabilityPack { id: ProductCapabilityId, required_services: &'static [RuntimeServiceCapability], tool_provider_group_ids: &'static [&'static str], + agent_ids: &'static [&'static str], harness_provider_descriptors: &'static [HarnessProviderDescriptor], } @@ -130,12 +131,14 @@ impl ProductCapabilityPack { id: ProductCapabilityId, required_services: &'static [RuntimeServiceCapability], tool_provider_group_ids: &'static [&'static str], + agent_ids: &'static [&'static str], harness_provider_descriptors: &'static [HarnessProviderDescriptor], ) -> Self { Self { id, required_services, tool_provider_group_ids, + agent_ids, harness_provider_descriptors, } } @@ -152,6 +155,10 @@ impl ProductCapabilityPack { self.tool_provider_group_ids } + pub const fn agent_ids(self) -> &'static [&'static str] { + self.agent_ids + } + pub const fn harness_provider_descriptors(self) -> &'static [HarnessProviderDescriptor] { self.harness_provider_descriptors } @@ -371,6 +378,7 @@ impl ProductServiceCapabilityRequirement { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProductCapabilityAssembly { capability_ids: Vec, + agent_ids: Vec<&'static str>, feature_groups: Vec, service_requirements: Vec, tool_provider_group_plan: Vec, @@ -438,6 +446,10 @@ impl ProductAssemblyPlan { &self.capability_assembly } + pub fn agent_ids(&self) -> &[&'static str] { + self.capability_assembly.agent_ids() + } + pub fn extension_capabilities(&self) -> &ProductExtensionCapabilitySet { &self.extension_capabilities } @@ -731,6 +743,7 @@ fn default_plugin_runtime_binding_for_profile(profile: DeliveryProfile) -> Plugi impl ProductCapabilityAssembly { fn new( capability_ids: Vec, + agent_ids: Vec<&'static str>, feature_groups: Vec, service_requirements: Vec, tool_provider_group_plan: Vec, @@ -738,6 +751,7 @@ impl ProductCapabilityAssembly { ) -> Self { Self { capability_ids, + agent_ids, feature_groups, service_requirements, tool_provider_group_plan, @@ -749,6 +763,10 @@ impl ProductCapabilityAssembly { &self.capability_ids } + pub fn agent_ids(&self) -> &[&'static str] { + &self.agent_ids + } + pub fn feature_groups(&self) -> &[ProductFeatureGroup] { &self.feature_groups } @@ -868,6 +886,19 @@ impl ProductCapabilityRegistry { provider_ids } + pub fn agent_ids(self) -> Vec<&'static str> { + let mut seen = HashSet::new(); + let mut agent_ids = Vec::new(); + for pack in self.packs { + for agent_id in pack.agent_ids() { + if seen.insert(*agent_id) { + agent_ids.push(*agent_id); + } + } + } + agent_ids + } + pub fn try_tool_provider_group_plan( self, ) -> Result, ProductCapabilityBuildError> { @@ -919,6 +950,7 @@ impl ProductCapabilityRegistry { feature_groups_from_tool_provider_group_plan(&tool_provider_group_plan); Ok(ProductCapabilityAssembly::new( self.capability_ids(), + self.agent_ids(), feature_groups, self.service_requirements(), tool_provider_group_plan, @@ -1003,6 +1035,8 @@ const CODE_AGENT_SERVICES: &[RuntimeServiceCapability] = &[ RuntimeServiceCapability::Events, RuntimeServiceCapability::Clock, RuntimeServiceCapability::Terminal, + RuntimeServiceCapability::Git, + RuntimeServiceCapability::Network, ]; const DEEP_REVIEW_SERVICES: &[RuntimeServiceCapability] = &[ RuntimeServiceCapability::Workspace, @@ -1026,10 +1060,48 @@ const CANVAS_SERVICES: &[RuntimeServiceCapability] = &[ RuntimeServiceCapability::Events, ]; -const CODE_AGENT_TOOL_GROUPS: &[&str] = &["core.basic", "core.agent", "core.session"]; -const INTEGRATION_TOOL_GROUPS: &[&str] = &["core.integration"]; +const CODE_AGENT_TOOL_GROUPS: &[&str] = &[ + "core.basic", + "core.agent", + "core.session", + "core.git", + "core.web", + "core.mcp", + "core.computer-use", +]; +const DEEP_REVIEW_TOOL_GROUPS: &[&str] = &["core.review"]; +const DEEP_RESEARCH_TOOL_GROUPS: &[&str] = &["core.web", "core.mcp"]; +const MINIAPP_TOOL_GROUPS: &[&str] = &["core.miniapp"]; const CANVAS_TOOL_GROUPS: &[&str] = &["core.canvas"]; +const CODE_AGENT_IDS: &[&str] = &[ + "minimal", + "agentic", + "Cowork", + "Multitask", + "Plan", + "Claw", + "Team", + "Ultra", + "SwarmPlanner", + "SwarmWorker", + "SwarmReviewer", + "ComputerUse", + "Explore", + "GeneralPurpose", + "GenerateDoc", + "MemoryPhase2", +]; +const DEEP_REVIEW_AGENT_IDS: &[&str] = &[ + "ReviewWorker", + "ReviewJudge", + "ReviewFixer", + "CodeReview", + "DeepReview", +]; +const DEEP_RESEARCH_AGENT_IDS: &[&str] = &["DeepResearch", "ResearchSpecialist"]; +const NO_PRODUCT_AGENTS: &[&str] = &[]; + const DEEP_REVIEW_HARNESS_CAPABILITIES: &[HarnessCapability] = &[ HarnessCapability::Plan, HarnessCapability::ReviewGate, @@ -1076,46 +1148,45 @@ const CODE_AGENT_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack: ProductCapabilityId::CodeAgent, CODE_AGENT_SERVICES, CODE_AGENT_TOOL_GROUPS, + CODE_AGENT_IDS, NO_HARNESS_PROVIDERS, ); const DEEP_REVIEW_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( ProductCapabilityId::DeepReview, DEEP_REVIEW_SERVICES, - INTEGRATION_TOOL_GROUPS, + DEEP_REVIEW_TOOL_GROUPS, + DEEP_REVIEW_AGENT_IDS, DEEP_REVIEW_HARNESS_PROVIDERS, ); const DEEP_RESEARCH_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( ProductCapabilityId::DeepResearch, DEEP_RESEARCH_SERVICES, - INTEGRATION_TOOL_GROUPS, + DEEP_RESEARCH_TOOL_GROUPS, + DEEP_RESEARCH_AGENT_IDS, DEEP_RESEARCH_HARNESS_PROVIDERS, ); const MINIAPP_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( ProductCapabilityId::MiniApp, MINIAPP_SERVICES, - INTEGRATION_TOOL_GROUPS, + MINIAPP_TOOL_GROUPS, + NO_PRODUCT_AGENTS, MINIAPP_HARNESS_PROVIDERS, ); const CANVAS_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( ProductCapabilityId::Canvas, CANVAS_SERVICES, CANVAS_TOOL_GROUPS, + NO_PRODUCT_AGENTS, NO_HARNESS_PROVIDERS, ); const VOICE_INPUT_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( ProductCapabilityId::VoiceInput, &[], &[], + NO_PRODUCT_AGENTS, NO_HARNESS_PROVIDERS, ); -const CORE_COMPATIBILITY_CAPABILITY_PACKS: &[ProductCapabilityPack] = &[ - CODE_AGENT_CAPABILITY_PACK, - DEEP_REVIEW_CAPABILITY_PACK, - DEEP_RESEARCH_CAPABILITY_PACK, - MINIAPP_CAPABILITY_PACK, - CANVAS_CAPABILITY_PACK, -]; const DEFAULT_PRODUCT_CAPABILITY_PACKS: &[ProductCapabilityPack] = &[ CODE_AGENT_CAPABILITY_PACK, DEEP_REVIEW_CAPABILITY_PACK, @@ -1135,12 +1206,15 @@ pub fn default_product_capability_assembly() -> ProductCapabilityAssembly { } pub fn agent_runtime_baseline_tool_plan() -> ProductToolPlan { + let tool_provider_group_plan = + try_product_tool_provider_group_plan_for_ids(&["core.basic", "core.agent", "core.session"]) + .expect("Agent Runtime baseline tool groups must exist"); ProductToolPlan::new( vec![ ProductFeatureGroup::Basic, ProductFeatureGroup::AgentControl, ], - bitfun_tool_packs::product_tool_provider_group_plan().to_vec(), + tool_provider_group_plan, ) } @@ -1168,7 +1242,7 @@ fn product_capability_registry_for_profile(profile: DeliveryProfile) -> ProductC default_product_capability_registry() } DeliveryProfile::Cli | DeliveryProfile::Acp | DeliveryProfile::Sdk => { - ProductCapabilityRegistry::new(CORE_COMPATIBILITY_CAPABILITY_PACKS) + ProductCapabilityRegistry::new(&[CODE_AGENT_CAPABILITY_PACK]) } DeliveryProfile::Server | DeliveryProfile::Remote diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs index 91f73bb649..f6401251db 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs @@ -4,3 +4,5 @@ mod plugin_product_shape; mod product_capabilities; #[path = "product_capability_contracts/product_sdk_assembly.rs"] mod product_sdk_assembly; +#[path = "product_capability_contracts/runtime_boundary.rs"] +mod runtime_boundary; diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs index ef1ab330a0..b078913b12 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs @@ -38,13 +38,7 @@ fn agent_runtime_baseline_tool_plan_requests_only_baseline_feature_owners() { .iter() .map(|provider| provider.provider_id()) .collect::>(), - [ - "core.basic", - "core.agent", - "core.canvas", - "core.session", - "core.integration", - ] + ["core.basic", "core.agent", "core.session",] ); } @@ -143,9 +137,14 @@ fn default_capability_registry_preserves_product_tool_provider_order() { vec![ "core.basic", "core.agent", - "core.canvas", "core.session", - "core.integration", + "core.git", + "core.web", + "core.mcp", + "core.computer-use", + "core.review", + "core.miniapp", + "core.canvas", ] ); } @@ -198,7 +197,7 @@ async fn product_harness_provider_plans_legacy_facade_without_execution() { #[test] fn product_harness_registry_can_be_built_from_explicit_delivery_profile() { - let registry = product_harness_registry_for_profile(DeliveryProfile::Cli) + let registry = product_harness_registry_for_profile(DeliveryProfile::Desktop) .expect("profile-scoped product harness registry should build"); assert_eq!( @@ -273,24 +272,29 @@ fn capability_packs_describe_service_tool_and_harness_requirements() { #[test] fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_profiles() { - let shared_capabilities = vec![ + let full_capabilities = vec![ "code-agent", "deep-review", "deep-research", "miniapp", "canvas", ]; - let expected_tool_groups = vec![ + let full_tool_groups = vec![ "core.basic", "core.agent", - "core.canvas", "core.session", - "core.integration", + "core.git", + "core.web", + "core.mcp", + "core.computer-use", + "core.review", + "core.miniapp", + "core.canvas", ]; for profile in [DeliveryProfile::ProductFull, DeliveryProfile::Desktop] { let plan = product_assembly_plan_for_profile(profile); - let mut expected_capabilities = shared_capabilities.clone(); + let mut expected_capabilities = full_capabilities.clone(); expected_capabilities.push("voice-input"); assert_eq!(plan.profile(), profile); @@ -309,7 +313,7 @@ fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_pro .iter() .map(|group| group.provider_id()) .collect::>(), - expected_tool_groups, + full_tool_groups, "{profile} must preserve current tool provider groups" ); } @@ -328,8 +332,8 @@ fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_pro .iter() .map(|capability_id| capability_id.id()) .collect::>(), - shared_capabilities, - "{profile} must not select desktop-only voice input" + vec!["code-agent"], + "{profile} must select only the headless CodeAgent capability" ); assert_eq!( plan.capability_assembly() @@ -337,8 +341,16 @@ fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_pro .iter() .map(|group| group.provider_id()) .collect::>(), - expected_tool_groups, - "{profile} must preserve current tool provider groups" + vec![ + "core.basic", + "core.agent", + "core.session", + "core.git", + "core.web", + "core.mcp", + "core.computer-use", + ], + "{profile} must assemble only generic Agent Runtime tool groups" ); } } @@ -543,11 +555,11 @@ fn product_assembly_plan_exposes_build_feature_groups_explicitly() { ProductFeatureGroup::ImageAnalysis, ProductFeatureGroup::AgentControl, ProductFeatureGroup::Git, - ProductFeatureGroup::Canvas, ProductFeatureGroup::BrowserWeb, ProductFeatureGroup::Mcp, - ProductFeatureGroup::MiniApp, ProductFeatureGroup::ComputerUse, + ProductFeatureGroup::MiniApp, + ProductFeatureGroup::Canvas, ] ); assert_eq!( @@ -557,11 +569,11 @@ fn product_assembly_plan_exposes_build_feature_groups_explicitly() { "image-analysis", "agent-control", "git", - "canvas", "browser-web", "mcp", - "miniapp", "computer-use", + "miniapp", + "canvas", ] ); } @@ -581,16 +593,30 @@ fn product_assembly_plan_reports_service_availability_by_capability() { .filter(|entry| entry.status() == ProductServiceCapabilityStatus::Unavailable) .collect::>(); - assert_eq!(unavailable.len(), 2); + assert_eq!(unavailable.len(), 4); assert_eq!( unavailable[0].requirement(), ProductServiceCapabilityRequirement::new( - ProductCapabilityId::DeepReview, + ProductCapabilityId::CodeAgent, RuntimeServiceCapability::Git, ) ); assert_eq!( unavailable[1].requirement(), + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::CodeAgent, + RuntimeServiceCapability::Network, + ) + ); + assert_eq!( + unavailable[2].requirement(), + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::DeepReview, + RuntimeServiceCapability::Git, + ) + ); + assert_eq!( + unavailable[3].requirement(), ProductServiceCapabilityRequirement::new( ProductCapabilityId::DeepResearch, RuntimeServiceCapability::Network, @@ -612,6 +638,14 @@ fn product_runtime_assembly_reports_runtime_service_capability_gaps() { ProductCapabilityId::CodeAgent, RuntimeServiceCapability::Terminal, ), + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::CodeAgent, + RuntimeServiceCapability::Git, + ), + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::CodeAgent, + RuntimeServiceCapability::Network, + ), ProductServiceCapabilityRequirement::new( ProductCapabilityId::DeepReview, RuntimeServiceCapability::Git, @@ -652,10 +686,7 @@ fn product_assembler_builds_runtime_parts_from_explicit_profile_input() { .expect("complete service set should assemble product runtime parts"); assert_eq!(parts.plan().profile(), DeliveryProfile::Cli); - assert_eq!( - parts.harness_registry().provider_ids(), - vec!["core.deep_review", "core.deep_research", "core.miniapp"] - ); + assert!(parts.harness_registry().provider_ids().is_empty()); assert!(parts.missing_service_requirements().is_empty()); assert!(parts .services() @@ -780,6 +811,14 @@ fn product_assembler_reports_missing_services_without_building_runtime_parts() { ProductCapabilityId::CodeAgent, RuntimeServiceCapability::Terminal, ), + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::CodeAgent, + RuntimeServiceCapability::Git, + ), + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::CodeAgent, + RuntimeServiceCapability::Network, + ), ProductServiceCapabilityRequirement::new( ProductCapabilityId::DeepReview, RuntimeServiceCapability::Git, @@ -863,9 +902,14 @@ fn default_capability_assembly_keeps_service_tool_and_harness_facts_together() { vec![ "core.basic", "core.agent", - "core.canvas", "core.session", - "core.integration" + "core.git", + "core.web", + "core.mcp", + "core.computer-use", + "core.review", + "core.miniapp", + "core.canvas", ] ); @@ -894,6 +938,14 @@ fn capability_assembly_reports_missing_services_without_concrete_runtime_depende assert_eq!( missing, vec![ + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::CodeAgent, + RuntimeServiceCapability::Git, + ), + ProductServiceCapabilityRequirement::new( + ProductCapabilityId::CodeAgent, + RuntimeServiceCapability::Network, + ), ProductServiceCapabilityRequirement::new( ProductCapabilityId::DeepReview, RuntimeServiceCapability::Git, @@ -921,6 +973,7 @@ fn capability_registry_rejects_unknown_tool_provider_groups() { &[], BROKEN_TOOL_GROUPS, &[], + &[], )]; let registry = ProductCapabilityRegistry::new(BROKEN_PACKS); diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs index d03fee46d8..fad71b31db 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs @@ -80,10 +80,7 @@ async fn sdk_delivery_profile_builds_shared_runtime_owner_ceiling_without_bitfun "SDK and Headless CLI currently select the same assembly-plan ceiling without sharing product identity" ); assert!(parts.missing_service_requirements().is_empty()); - assert_eq!( - parts.harness_registry().provider_ids(), - vec!["core.deep_review", "core.deep_research", "core.miniapp"] - ); + assert!(parts.harness_registry().provider_ids().is_empty()); for capability in [ RuntimeServiceCapability::Terminal, RuntimeServiceCapability::Git, @@ -113,10 +110,7 @@ async fn sdk_delivery_profile_builds_shared_runtime_owner_ceiling_without_bitfun assert_eq!(handle.session_id, "product-sdk-session"); assert_eq!(handle.turn_id, "product-sdk-turn"); assert!(handle.accepted); - assert_eq!( - runtime.harness_provider_ids(), - vec!["core.deep_review", "core.deep_research", "core.miniapp"] - ); + assert!(runtime.harness_provider_ids().is_empty()); } #[tokio::test] @@ -149,10 +143,7 @@ async fn product_runtime_parts_can_build_agent_runtime_sdk_without_core() { assert!(runtime_services.has_capability(RuntimeServiceCapability::Terminal)); assert!(runtime_services.has_capability(RuntimeServiceCapability::Git)); assert!(runtime_services.has_capability(RuntimeServiceCapability::Network)); - assert_eq!( - runtime.harness_provider_ids(), - vec!["core.deep_review", "core.deep_research", "core.miniapp"] - ); + assert!(runtime.harness_provider_ids().is_empty()); let handle = runtime .run( diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs new file mode 100644 index 0000000000..3c8a583f95 --- /dev/null +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs @@ -0,0 +1,135 @@ +use bitfun_product_capabilities::{ + product_assembly_plan_for_profile, DeliveryProfile, ProductCapabilityId, +}; + +const HEADLESS_PROFILES: &[DeliveryProfile] = &[ + DeliveryProfile::Cli, + DeliveryProfile::Acp, + DeliveryProfile::Sdk, +]; + +#[test] +fn headless_agent_hosts_select_only_the_code_agent_product_capability() { + for profile in HEADLESS_PROFILES { + let plan = product_assembly_plan_for_profile(*profile); + + assert_eq!( + plan.capability_set().ids(), + &[ProductCapabilityId::CodeAgent], + "{profile} must not assemble desktop product workflows" + ); + assert!( + plan.capability_assembly() + .harness_provider_descriptors() + .is_empty(), + "{profile} must not register excluded product workflow Harness providers" + ); + } +} + +#[test] +fn code_agent_tools_are_selected_from_atomic_provider_groups() { + let plan = product_assembly_plan_for_profile(DeliveryProfile::Sdk); + let provider_ids = plan + .tool_plan() + .tool_provider_group_plan() + .iter() + .map(|provider| provider.provider_id()) + .collect::>(); + + assert_eq!( + provider_ids, + vec![ + "core.basic", + "core.agent", + "core.session", + "core.git", + "core.web", + "core.mcp", + "core.computer-use", + ] + ); + + let tool_names = plan + .tool_plan() + .tool_provider_group_plan() + .iter() + .flat_map(|provider| provider.tool_names()) + .copied() + .collect::>(); + + for explore_tool in [ + "AgentSpawn", + "AgentSendInput", + "AgentInterrupt", + "AgentList", + "AgentDelete", + "PortForward", + "BitFunControl", + ] { + assert!( + tool_names.contains(&explore_tool), + "{} must preserve explore tool {explore_tool}", + DeliveryProfile::Sdk, + ); + } + + for product_tool in [ + "LaunchReviewAgent", + "submit_code_review", + "GenerativeUI", + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "CreateCanvas", + "ReadCanvas", + ] { + assert!( + !tool_names.contains(&product_tool), + "{} must not expose {product_tool}", + DeliveryProfile::Sdk, + ); + } +} + +#[test] +fn headless_agent_hosts_keep_explore_code_agents_without_product_workflow_agents() { + for profile in HEADLESS_PROFILES { + let plan = product_assembly_plan_for_profile(*profile); + let agent_ids = plan.agent_ids(); + + for product_agent in [ + "DeepResearch", + "ResearchSpecialist", + "DeepReview", + "CodeReview", + "ReviewWorker", + "ReviewJudge", + "ReviewFixer", + "debug", + "FileFinder", + ] { + assert!( + !agent_ids.contains(&product_agent), + "{profile} must not register {product_agent}" + ); + } + + for code_agent in [ + "minimal", + "agentic", + "Plan", + "Explore", + "GeneralPurpose", + "Ultra", + "SwarmPlanner", + "SwarmWorker", + "SwarmReviewer", + ] { + assert!( + agent_ids.contains(&code_agent), + "{profile} must keep the explore code agent {code_agent}" + ); + } + } +} diff --git a/src/crates/contracts/runtime-ports/src/agent_api.rs b/src/crates/contracts/runtime-ports/src/agent_api.rs index bc091a7b49..e0cd6c8f2e 100644 --- a/src/crates/contracts/runtime-ports/src/agent_api.rs +++ b/src/crates/contracts/runtime-ports/src/agent_api.rs @@ -1501,11 +1501,10 @@ pub trait AgentWorkspaceReferencePort: Send + Sync { ) -> PortResult>; } -/// Deadline-bearing request for discarding a connection-scoped transient -/// Session. This is separate from [`AgentSessionDeleteRequest`] so adding Host -/// cleanup policy cannot break the established Rust Session-management API. +/// Deadline-bearing request for releasing a loaded Session from one runtime. +/// The lifecycle method decides whether persisted storage is preserved. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct AgentTransientSessionDiscardRequest { +pub struct AgentSessionReleaseRequest { pub workspace_path: String, pub session_id: String, pub remote_connection_id: Option, @@ -1513,7 +1512,10 @@ pub struct AgentTransientSessionDiscardRequest { pub wait_timeout_ms: u64, } -/// Runtime lifecycle owner for connection-scoped Session cleanup. +/// Compatibility name for callers that only discard transient Sessions. +pub type AgentTransientSessionDiscardRequest = AgentSessionReleaseRequest; + +/// Runtime lifecycle owner for releasing loaded Sessions. #[async_trait::async_trait] pub trait AgentSessionClosePort: Send + Sync { /// Quiesces and discards only a loaded transient Session owned by the @@ -1521,7 +1523,7 @@ pub trait AgentSessionClosePort: Send + Sync { /// remove persisted Session storage through this operation. async fn discard_transient_session( &self, - request: AgentTransientSessionDiscardRequest, + request: AgentSessionReleaseRequest, ) -> PortResult { let _ = request; Err(PortError::new( @@ -1529,6 +1531,19 @@ pub trait AgentSessionClosePort: Send + Sync { "transient session discard is not supported by this provider", )) } + + /// Quiesces and unloads a durable Session while preserving its persisted + /// state so another runtime can restore it later. + async fn unload_persisted_session( + &self, + request: AgentSessionReleaseRequest, + ) -> PortResult { + let _ = request; + Err(PortError::new( + PortErrorKind::NotAvailable, + "persisted session unload is not supported by this provider", + )) + } } #[async_trait::async_trait] @@ -1603,6 +1618,14 @@ pub struct AgentSessionRevertRequest { #[serde(rename_all = "camelCase")] pub struct AgentSessionRollbackToTurnRequest { pub workspace_path: String, + /// Stable workspace identity supplied by newer product surfaces. This is + /// optional so older clients keep using the path-based compatibility path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + /// Persisted workspace host identity. `localhost` is the local sentinel; + /// any other non-empty value identifies a remote workspace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_hostname: Option, pub session_id: String, pub target_turn_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1615,6 +1638,52 @@ pub struct AgentSessionRollbackToTurnRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentSessionWorkspaceLocation { + Local, + Remote, +} + +impl AgentSessionRollbackToTurnRequest { + /// Returns the structured workspace location when the request declares one. + /// Explicit remote facts win over conflicting local facts so malformed + /// requests cannot bypass remote snapshot restrictions. + pub fn explicit_workspace_location(&self) -> Option { + let workspace_id = self + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let workspace_hostname = self + .workspace_hostname + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + if self + .remote_connection_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || self + .remote_ssh_host + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || workspace_id.is_some_and(|value| value.starts_with("remote_")) + || workspace_hostname.is_some_and(|value| !value.eq_ignore_ascii_case("localhost")) + { + return Some(AgentSessionWorkspaceLocation::Remote); + } + + if workspace_id.is_some_and(|value| value.starts_with("local_")) + || workspace_hostname.is_some_and(|value| value.eq_ignore_ascii_case("localhost")) + { + return Some(AgentSessionWorkspaceLocation::Local); + } + + None + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum AgentSessionComposerUpdate { @@ -2024,6 +2093,8 @@ mod tests { fn targeted_session_rollback_contract_uses_camel_case_and_typed_outcomes() { let request = AgentSessionRollbackToTurnRequest { workspace_path: "E:/workspace".to_string(), + workspace_id: Some("local_workspace-1".to_string()), + workspace_hostname: Some("localhost".to_string()), session_id: "session-1".to_string(), target_turn_id: "turn-7".to_string(), expected_storage_turn_index: Some(7), @@ -2033,6 +2104,8 @@ mod tests { }; let request_json = serde_json::to_value(&request).expect("serialize rollback request"); assert_eq!(request_json["workspacePath"], "E:/workspace"); + assert_eq!(request_json["workspaceId"], "local_workspace-1"); + assert_eq!(request_json["workspaceHostname"], "localhost"); assert_eq!(request_json["targetTurnId"], "turn-7"); assert_eq!(request_json["expectedStorageTurnIndex"], 7); assert_eq!(request_json["expectedCatalogRevision"], "catalog-3"); @@ -2042,6 +2115,35 @@ mod tests { request ); + let legacy_request = + serde_json::from_value::(serde_json::json!({ + "workspacePath": "E:/workspace", + "sessionId": "session-1", + "targetTurnId": "turn-7" + })) + .expect("deserialize pre-workspace-identity rollback request"); + assert_eq!(legacy_request.workspace_id, None); + assert_eq!(legacy_request.workspace_hostname, None); + assert_eq!(legacy_request.explicit_workspace_location(), None); + + let local_request = AgentSessionRollbackToTurnRequest { + workspace_id: Some("local_workspace-1".to_string()), + workspace_hostname: None, + ..legacy_request.clone() + }; + assert_eq!( + local_request.explicit_workspace_location(), + Some(AgentSessionWorkspaceLocation::Local) + ); + let conflicting_request = AgentSessionRollbackToTurnRequest { + remote_connection_id: Some("connection-1".to_string()), + ..local_request + }; + assert_eq!( + conflicting_request.explicit_workspace_location(), + Some(AgentSessionWorkspaceLocation::Remote) + ); + let completed = AgentSessionRollbackToTurnOutcome::Completed { result: AgentSessionRevertResult { session_id: "session-1".to_string(), diff --git a/src/crates/execution/AGENTS-CN.md b/src/crates/execution/AGENTS-CN.md index fcfd412251..233097c1e7 100644 --- a/src/crates/execution/AGENTS-CN.md +++ b/src/crates/execution/AGENTS-CN.md @@ -2,16 +2,17 @@ # 执行原语层 -本层负责可复用的 agent、harness、stream、插件运行时客户端、typed-service 和 tool 执行原语。它不是完整 Agent Runtime SDK,也不是组装后的产品 runtime。由产品组装决定某个交付形态启用哪些 execution primitive、tool provider group、harness provider、adapter 和 service。 +本层负责可复用的 agent、命名工作流、harness、stream、插件运行时客户端、typed-service 和 tool 执行原语。它不是完整 Agent Runtime SDK,也不是组装后的产品 runtime。由产品组装决定某个交付形态启用哪些 execution primitive、tool provider group、工作流能力、harness provider、adapter 和 service。 ## 模块 | Crate | 职责 | 本地文档 | |---|---|---| -| `agent-runtime` | Agent registry、scheduler、session/config/context facts、prompt cache、hooks、goal、prompt facts、port-backed `AgentRuntime` facade、DeepReview provider-neutral state、DeepResearch citation renumbering,以及 runtime control / confirmation 契约 | [AGENTS.md](agent-runtime/AGENTS.md) | +| `agent-runtime` | 可移植 Agent / Session / Turn 生命周期事实、调度与取消决策、prompt/cache/context facts、hooks、goal、扩展契约和 port-backed `AgentRuntime` facade | [AGENTS.md](agent-runtime/AGENTS.md) | +| `agent-workflows` | 与 UI、协议和具体 I/O 无关的命名产品工作流策略;当前承载 DeepResearch 报告后处理 | [AGENTS.md](agent-workflows/AGENTS.md) | | `agent-stream` | Provider-neutral stream DTO、tool-call 累积和 replay 契约 | [AGENTS.md](agent-stream/AGENTS.md) | | `tool-contracts` | Tool 契约、execution gate、input validation 和 result presentation 契约;Cargo package 仍为 `bitfun-agent-tools` | [AGENTS.md](tool-contracts/AGENTS.md) | -| `harness` | Harness workflow 契约和 registry primitive | [AGENTS.md](harness/AGENTS.md) | +| `harness` | 为 SDK 注入保留的通用 Harness 工作流契约和 registry primitive;产品工作流归属仍在 `agent-workflows` 或当前产品 owner | [AGENTS.md](harness/AGENTS.md) | | `plugin-runtime-client` | `PluginRuntimeClient` 的默认实现,负责派发、重复请求结果和故障诊断;JS/TS Plugin Host 是经服务端口管理的子进程 | [AGENTS.md](plugin-runtime-client/AGENTS.md) | | `runtime-services` | Typed runtime service assembly 和 service availability facts | [AGENTS.md](runtime-services/AGENTS.md) | | `tool-provider-groups` | Tool provider group facts 和 product-full tool group composition;Cargo package 仍为 `bitfun-tool-packs` | [AGENTS.md](tool-provider-groups/AGENTS.md) | @@ -24,6 +25,7 @@ - 具体 filesystem、git、terminal、MCP server、remote SSH、OS 行为应放到 `services`,除非只是纯底层 tool primitive。 - 协议 projection 与外部 provider 请求整形放到 `adapters`。 - 产品 feature 选择和 delivery-profile 决策放到 `assembly`,不要放入 execution primitive。 +- 命名产品工作流策略放到 `agent-workflows`;Agent Runtime 不得依赖命名工作流。产品产物和呈现生命周期留在产品 owner。 - Tool packs 只描述 provider group 和所需服务;具体服务访问应通过 port 或 typed runtime service。 ## 依赖边界 diff --git a/src/crates/execution/AGENTS.md b/src/crates/execution/AGENTS.md index d8ab768587..9e3041f485 100644 --- a/src/crates/execution/AGENTS.md +++ b/src/crates/execution/AGENTS.md @@ -2,20 +2,21 @@ # Execution Primitives Layer -This layer owns reusable agent, harness, stream, plugin runtime client, typed-service, and tool +This layer owns reusable agent, named-workflow, harness, stream, plugin runtime client, typed-service, and tool execution primitives. It is not the complete Agent Runtime SDK and not the assembled product runtime. Product assembly decides which primitives, tool -provider groups, harness providers, adapters, and services are active for a +provider groups, workflow capabilities, harness providers, adapters, and services are active for a delivery form. ## Modules | Crate | Responsibility | Local doc | |---|---|---| -| `agent-runtime` | Agent registry, scheduler, session/config/context facts, prompt cache, hooks, goals, prompt facts, port-backed `AgentRuntime` facade, DeepReview provider-neutral state, DeepResearch citation renumbering, and runtime control / confirmation contracts | [AGENTS.md](agent-runtime/AGENTS.md) | +| `agent-runtime` | Portable Agent / Session / Turn lifecycle facts, scheduling and cancellation decisions, prompt/cache/context facts, hooks, goals, extension contracts, and the port-backed `AgentRuntime` facade | [AGENTS.md](agent-runtime/AGENTS.md) | +| `agent-workflows` | Named product workflow policy that is independent of UI, protocol, and concrete I/O; currently DeepResearch report post-processing | [AGENTS.md](agent-workflows/AGENTS.md) | | `agent-stream` | Provider-neutral stream DTOs, tool-call accumulation, and replay contracts | [AGENTS.md](agent-stream/AGENTS.md) | | `tool-contracts` | Tool contracts, execution gates, input validation, and result presentation contracts. Cargo package remains `bitfun-agent-tools`. | [AGENTS.md](tool-contracts/AGENTS.md) | -| `harness` | Harness workflow contracts and registry primitives | [AGENTS.md](harness/AGENTS.md) | +| `harness` | Generic Harness workflow contracts and registry primitives retained for SDK injection; product workflow ownership remains in `agent-workflows` or the current product owner | [AGENTS.md](harness/AGENTS.md) | | `plugin-runtime-client` | Default `PluginRuntimeClient` implementation for dispatch, duplicate-request results, and fault diagnostics; the JS/TS Plugin Host remains a child process managed through service ports | [AGENTS.md](plugin-runtime-client/AGENTS.md) | | `runtime-services` | Typed runtime service assembly and service availability facts | [AGENTS.md](runtime-services/AGENTS.md) | | `tool-provider-groups` | Tool provider group facts and product-full tool group composition. Cargo package remains `bitfun-tool-packs`. | [AGENTS.md](tool-provider-groups/AGENTS.md) | @@ -32,6 +33,9 @@ delivery form. - Keep protocol projection and external provider request shaping in `adapters`. - Keep product feature selection and delivery-profile decisions in `assembly`, not in execution primitives. +- Keep named product workflow policy in `agent-workflows`; Agent Runtime must + never depend on named workflows. Product artifact and presentation lifecycle + stays in its product owner. - Tool packs should describe provider groups and required services; concrete service access should flow through ports or typed runtime services. diff --git a/src/crates/execution/agent-runtime/AGENTS.md b/src/crates/execution/agent-runtime/AGENTS.md index 5025d990fb..4e580cc5ce 100644 --- a/src/crates/execution/agent-runtime/AGENTS.md +++ b/src/crates/execution/agent-runtime/AGENTS.md @@ -9,13 +9,12 @@ port-backed `sdk` / `AgentRuntime` facade that can be built and tested without ## Feature Boundaries -- `deep-research` exposes only provider-neutral citation renumbering. - `native-hook-settings` exposes Codex-compatible hook settings parsing and validation without process execution. - `native-hook-runtime` extends settings with payload, output, and managed child-process execution. - `agent-runtime` selects the complete portable runtime and includes - `native-hook-runtime`; it intentionally does not include `deep-research`. + `native-hook-runtime`. - `default` stays empty. Consumers select the smallest owner feature they use; do not add a compatibility `full` feature or rely on another workspace consumer to create a Cargo feature union. @@ -25,13 +24,13 @@ port-backed `sdk` / `AgentRuntime` facade that can be built and tested without - Do not depend on `bitfun-core`, app crates, Tauri, ACP protocol, web UI, concrete service crates, or product-domain implementations. - The `sdk` module may re-export only stable runtime request/response types, - runtime-port contracts, and the service/tool/harness registry types needed + runtime-port contracts, and the service/tool/harness/agent/hook registry types needed for dependency injection. It must not re-export raw PluginRuntimeClient types such as plugin runtime bindings, dispatch/read request types, status snapshots, plugin fault diagnostics, or host clients; Product Assembly uses the internal runtime builder when it needs to inject a plugin runtime. - `AgentRuntime` may depend on stable ports plus injected `RuntimeServices`, - tool registry, harness registry, and hook registry. Product assembly owns + tool registry, harness registry, agent registry, and hook registry. Product assembly owns concrete registration; this crate must not create concrete managers, app state, filesystem, terminal, MCP, remote, or AI clients. - The `runtime` module is internal / Product Assembly facing. Do not route @@ -50,13 +49,11 @@ port-backed `sdk` / `AgentRuntime` facade that can be built and tested without planning, agent-session reply planning, thread-goal accounting/mutation/continuation decisions, scheduled-job lifecycle state transitions, runtime event facts, registry visibility/availability, custom subagent schema/default decisions, - builtin agent definition catalog, skill catalog/root/mode/selection facts, + skill catalog/root/mode/selection facts, thread-goal metadata / event payload / token usage / scheduler delivery plans, thread-goal tool wire contracts, session config/defaults/summary and persisted session-state sidecar shape, - user-question validation/result/channel contracts, SessionControl input/cancel-route/result contracts, DeepReview - policy/manifest/budget/queue/report/cache/shared-context/task-execution - shaping decisions, DeepResearch citation renumbering, + user-question validation/result/channel contracts, SessionControl input/cancel-route/result contracts, custom subagent markdown front-matter IO, custom subagent discovery/loading, post-call hook routing/executor orchestration, tool confirmation gate/planning/failure/wait-result/channel mapping, light checkpoint @@ -70,14 +67,19 @@ port-backed `sdk` / `AgentRuntime` facade that can be built and tested without facts. - Keep concrete prompt fact collection, workspace context IO, prompt-cache persistence wiring, dynamic environment collection, concrete hook side - effects, DeepReview task launch/provider wait/report persistence, - DeepResearch storage IO/post-turn hook and concrete product tool execution + effects, named product workflow policy and execution, and concrete product tool execution outside this crate until a reviewed migration proves behavior equivalence. +- DeepReview compatibility modules and the built-in product Agent catalog still + present here and in `assembly/core` are migration debt, not Runtime scope. + Product Assembly already owns the selected Agent IDs. These compatibility + paths may receive fixes needed to preserve existing behavior, but new named + workflow policy belongs in `agent-workflows`; migrate one production path at + a time with equivalence tests before deleting the old owner. - Add focused tests before moving any runtime decision into this crate. ## Test Target Layout -Integration contracts use seven explicit Cargo targets so package-level checks +Integration contracts use six explicit Cargo targets so package-level checks do not relink the same feature-free dependency closure for every source file, while platform-specific process tests retain executable-level isolation: @@ -87,7 +89,6 @@ while platform-specific process tests retain executable-level isolation: | `agent_session_contracts` | Events, scheduling, sessions, SDK behavior, and workspace-reference ports | | `agent_interaction_contracts` | Permissions, questions, hook payloads, and post-call hook behavior (`agent-runtime`) | | `agent_long_horizon_contracts` | DeepReview and long-running thread-goal behavior (`agent-runtime`) | -| `deep_research_contracts` | Citation numbering without the complete Agent Runtime (`deep-research`) | | `native_hook_settings_contracts` | Hook settings parsing without process execution (`native-hook-settings`) | | `native_hook_execution_contracts` | Unix-only native process execution, timeout, and cleanup behavior | @@ -109,8 +110,7 @@ Use the focused contract form by default. Run the package-wide form only when a change crosses several runtime targets: ```bash -cargo test --locked -p bitfun-agent-runtime --no-default-features --features agent-runtime,deep-research --lib --tests -cargo test --locked -p bitfun-agent-runtime --no-default-features --features deep-research --test deep_research_contracts +cargo test --locked -p bitfun-agent-runtime --no-default-features --features agent-runtime --lib --tests cargo test --locked -p bitfun-agent-runtime --no-default-features --features native-hook-settings --test native_hook_settings_contracts cargo test --locked -p bitfun-agent-runtime --no-default-features --features agent-runtime --test :: ``` diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index dacf62db2e..d0842efa92 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -17,7 +17,6 @@ required-features = ["agent-runtime"] [features] default = [] -deep-research = ["dep:regex"] native-hook-settings = ["dep:regex", "dep:serde_json"] native-hook-runtime = [ "native-hook-settings", @@ -105,11 +104,6 @@ name = "native_hook_execution_contracts" path = "tests/native_hook_execution_contracts.rs" required-features = ["native-hook-runtime"] -[[test]] -name = "deep_research_contracts" -path = "tests/deep_research_contracts.rs" -required-features = ["deep-research"] - [[test]] name = "native_hook_settings_contracts" path = "tests/native_hook_settings_contracts.rs" diff --git a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs index 00a8d01ced..9a7ed2525f 100644 --- a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs +++ b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs @@ -49,7 +49,7 @@ impl AgentSubmissionPort for ExampleAgentProvider { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { let compatibility = AgentRuntimeSdkCompatibility::current(); - assert_eq!(compatibility.api_version, 6); + assert_eq!(compatibility.api_version, 8); let provider = Arc::new(ExampleAgentProvider::default()); let events = AgentEventStream::new(); diff --git a/src/crates/execution/agent-runtime/src/dialog_turn.rs b/src/crates/execution/agent-runtime/src/dialog_turn.rs index d0a2a68060..ed2b8ceee9 100644 --- a/src/crates/execution/agent-runtime/src/dialog_turn.rs +++ b/src/crates/execution/agent-runtime/src/dialog_turn.rs @@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use bitfun_events::AgenticEvent; + /// Generate a fresh turn id when callers do not supply one. pub fn new_turn_id(provided: Option) -> String { provided.unwrap_or_else(|| Uuid::new_v4().to_string()) @@ -22,3 +24,135 @@ pub struct TurnStats { pub total_tokens: usize, pub duration_ms: u64, } + +/// Token usage aggregated across all model rounds in one dialog turn. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TurnTokenUsage { + pub input_tokens: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + pub total_tokens: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub cached_tokens: Option, +} + +impl TurnTokenUsage { + fn merge_round(&mut self, round: Self) { + self.input_tokens = self.input_tokens.saturating_add(round.input_tokens); + self.output_tokens = self + .output_tokens + .zip(round.output_tokens) + .map(|(current, next)| current.saturating_add(next)); + self.total_tokens = self.total_tokens.saturating_add(round.total_tokens); + self.cached_tokens = self + .cached_tokens + .zip(round.cached_tokens) + .map(|(current, next)| current.saturating_add(next)); + } + + pub fn accumulate_event<'a>( + aggregate: &mut Option, + event: &'a AgenticEvent, + expected_turn_id: &str, + ) -> Option<&'a str> { + let AgenticEvent::TokenUsageUpdated { + turn_id, + model_config_id, + input_tokens, + output_tokens, + total_tokens, + cached_tokens, + .. + } = event + else { + return None; + }; + if turn_id != expected_turn_id { + return None; + } + + let round = Self { + input_tokens: *input_tokens, + output_tokens: *output_tokens, + total_tokens: *total_tokens, + cached_tokens: *cached_tokens, + }; + if let Some(total) = aggregate.as_mut() { + total.merge_round(round); + } else { + *aggregate = Some(round); + } + Some(model_config_id) + } +} + +#[cfg(test)] +mod tests { + use super::TurnTokenUsage; + use bitfun_events::AgenticEvent; + + #[test] + fn turn_usage_accumulates_rounds_and_ignores_other_turns() { + let events = [ + usage_event("turn-1", 100, Some(25), 125, Some(40)), + usage_event("turn-2", 900, Some(90), 990, Some(80)), + usage_event("turn-1", 200, Some(50), 250, Some(80)), + ]; + let mut usage = None; + + for event in &events { + TurnTokenUsage::accumulate_event(&mut usage, event, "turn-1"); + } + + assert_eq!( + usage, + Some(TurnTokenUsage { + input_tokens: 300, + output_tokens: Some(75), + total_tokens: 375, + cached_tokens: Some(120), + }) + ); + } + + #[test] + fn turn_usage_keeps_optional_totals_unknown_if_any_round_omits_them() { + let events = [ + usage_event("turn-1", 100, None, 100, Some(20)), + usage_event("turn-1", 50, Some(10), 60, None), + ]; + let mut usage = None; + + for event in &events { + TurnTokenUsage::accumulate_event(&mut usage, event, "turn-1"); + } + + let usage = usage.expect("matching usage"); + assert_eq!(usage.input_tokens, 150); + assert_eq!(usage.output_tokens, None); + assert_eq!(usage.total_tokens, 160); + assert_eq!(usage.cached_tokens, None); + } + + fn usage_event( + turn_id: &str, + input_tokens: usize, + output_tokens: Option, + total_tokens: usize, + cached_tokens: Option, + ) -> AgenticEvent { + AgenticEvent::TokenUsageUpdated { + session_id: "session-1".to_string(), + turn_id: turn_id.to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), + input_tokens, + output_tokens, + total_tokens, + max_context_tokens: Some(200_000), + is_subagent: false, + cached_tokens, + token_details: None, + } + } +} diff --git a/src/crates/execution/agent-runtime/src/evidence_ledger.rs b/src/crates/execution/agent-runtime/src/evidence_ledger.rs index 55035035b0..55eddb3bc6 100644 --- a/src/crates/execution/agent-runtime/src/evidence_ledger.rs +++ b/src/crates/execution/agent-runtime/src/evidence_ledger.rs @@ -2,10 +2,12 @@ use crate::checkpoint::LightCheckpoint; use bitfun_runtime_ports::{CompressionContract, CompressionContractItem}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; const MAX_PARTIAL_OUTPUT_BYTES: usize = 8_000; +pub const EVIDENCE_LEDGER_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum EvidenceLedgerTargetKind { @@ -19,7 +21,7 @@ pub enum EvidenceLedgerTargetKind { Artifact, #[serde(rename = "checkpoint")] Checkpoint, - #[serde(rename = "unknown")] + #[serde(other, rename = "unknown")] Unknown, } @@ -35,7 +37,7 @@ pub enum EvidenceLedgerEventStatus { PartialTimeout, #[serde(rename = "cancelled")] Cancelled, - #[serde(rename = "unknown")] + #[serde(other, rename = "unknown")] Unknown, } @@ -59,16 +61,54 @@ pub struct EvidenceLedgerEvent { pub target_kind: EvidenceLedgerTargetKind, pub target: String, pub status: EvidenceLedgerEventStatus, + #[serde(default)] pub exit_code_or_error_kind: Option, + #[serde(default)] pub touched_files: Vec, + #[serde(default)] pub artifact_path: Option, pub summary: String, + #[serde(default)] pub partial_output: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub checkpoint: Option, + #[serde(default)] pub created_at_ms: u64, } +/// Versioned sidecar owned by the runtime and written by product persistence. +/// +/// Keeping evidence separate from mutable Session state lets older builds +/// rewrite `state.json` without erasing evidence produced by a newer build. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersistedEvidenceLedgerFile { + #[serde(default = "default_evidence_ledger_schema_version")] + pub schema_version: u32, + pub session_id: String, + #[serde(default)] + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EvidenceLedgerPersistenceError { + #[error( + "unsupported evidence ledger schema version {actual}; maximum supported is {supported}" + )] + UnsupportedSchema { actual: u32, supported: u32 }, + #[error("evidence ledger session mismatch: expected {expected}, found {actual}")] + SessionMismatch { expected: String, actual: String }, + #[error("evidence ledger event has an empty event_id")] + EmptyEventId, + #[error("evidence ledger event {event_id} belongs to session {actual}, expected {expected}")] + EventSessionMismatch { + event_id: String, + expected: String, + actual: String, + }, + #[error("evidence ledger contains conflicting payloads for event {event_id}")] + ConflictingEvent { event_id: String }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EvidenceLedgerSummaryItem { pub event_id: String, @@ -188,6 +228,126 @@ impl From for EvidenceLedgerCheckpoint { } } +impl PersistedEvidenceLedgerFile { + pub fn new(session_id: impl Into) -> Self { + Self { + schema_version: EVIDENCE_LEDGER_SCHEMA_VERSION, + session_id: session_id.into(), + events: Vec::new(), + } + } + + pub fn append( + &mut self, + event: EvidenceLedgerEvent, + ) -> Result { + self.validate_header(&event.session_id)?; + self.validate_events()?; + validate_event(&event, &self.session_id)?; + + if let Some(existing) = self + .events + .iter() + .find(|existing| existing.event_id == event.event_id) + { + if existing == &event { + return Ok(false); + } + return Err(EvidenceLedgerPersistenceError::ConflictingEvent { + event_id: event.event_id, + }); + } + + self.events.push(event); + Ok(true) + } + + /// Keep only events whose turn is still part of the session history. + pub fn retain_turn_ids( + &mut self, + expected_session_id: &str, + surviving_turn_ids: &HashSet, + ) -> Result, EvidenceLedgerPersistenceError> { + self.validate_header(expected_session_id)?; + let retained = self + .validate_events()? + .into_iter() + .filter(|event| surviving_turn_ids.contains(&event.turn_id)) + .collect::>(); + self.events = retained.clone(); + Ok(retained) + } + + pub fn validated_events( + self, + expected_session_id: &str, + ) -> Result, EvidenceLedgerPersistenceError> { + self.validate_header(expected_session_id)?; + self.validate_events() + } + + fn validate_events(&self) -> Result, EvidenceLedgerPersistenceError> { + let mut unique_events = Vec::with_capacity(self.events.len()); + let mut event_indices = HashMap::::new(); + + for event in &self.events { + validate_event(event, &self.session_id)?; + if let Some(index) = event_indices.get(&event.event_id).copied() { + if unique_events[index] != *event { + return Err(EvidenceLedgerPersistenceError::ConflictingEvent { + event_id: event.event_id.clone(), + }); + } + continue; + } + event_indices.insert(event.event_id.clone(), unique_events.len()); + unique_events.push(event.clone()); + } + + Ok(unique_events) + } + + fn validate_header( + &self, + expected_session_id: &str, + ) -> Result<(), EvidenceLedgerPersistenceError> { + if self.schema_version > EVIDENCE_LEDGER_SCHEMA_VERSION { + return Err(EvidenceLedgerPersistenceError::UnsupportedSchema { + actual: self.schema_version, + supported: EVIDENCE_LEDGER_SCHEMA_VERSION, + }); + } + if self.session_id != expected_session_id { + return Err(EvidenceLedgerPersistenceError::SessionMismatch { + expected: expected_session_id.to_string(), + actual: self.session_id.clone(), + }); + } + Ok(()) + } +} + +fn default_evidence_ledger_schema_version() -> u32 { + EVIDENCE_LEDGER_SCHEMA_VERSION +} + +fn validate_event( + event: &EvidenceLedgerEvent, + expected_session_id: &str, +) -> Result<(), EvidenceLedgerPersistenceError> { + if event.event_id.is_empty() { + return Err(EvidenceLedgerPersistenceError::EmptyEventId); + } + if event.session_id != expected_session_id { + return Err(EvidenceLedgerPersistenceError::EventSessionMismatch { + event_id: event.event_id.clone(), + expected: expected_session_id.to_string(), + actual: event.session_id.clone(), + }); + } + Ok(()) +} + impl SessionEvidenceLedger { pub fn new() -> Self { Self::default() @@ -198,13 +358,67 @@ impl SessionEvidenceLedger { } pub fn append(&self, event: EvidenceLedgerEvent) -> EvidenceLedgerEvent { - self.events_by_session + let mut events = self + .events_by_session .entry(event.session_id.clone()) - .or_default() - .push(event.clone()); + .or_default(); + if let Some(existing) = events + .iter() + .find(|existing| existing.event_id == event.event_id) + { + return existing.clone(); + } + events.push(event.clone()); event } + pub fn retain_turn_ids( + &self, + session_id: &str, + surviving_turn_ids: &HashSet, + ) -> Vec { + let retained = self + .events_for_session(session_id) + .into_iter() + .filter(|event| surviving_turn_ids.contains(&event.turn_id)) + .collect::>(); + if retained.is_empty() { + self.events_by_session.remove(session_id); + } else { + self.events_by_session + .insert(session_id.to_string(), retained.clone()); + } + retained + } + + pub fn replace_session( + &self, + session_id: &str, + events: Vec, + ) -> Result<(), EvidenceLedgerPersistenceError> { + let events = PersistedEvidenceLedgerFile { + schema_version: EVIDENCE_LEDGER_SCHEMA_VERSION, + session_id: session_id.to_string(), + events, + } + .validated_events(session_id)?; + + if events.is_empty() { + self.events_by_session.remove(session_id); + } else { + self.events_by_session + .insert(session_id.to_string(), events); + } + Ok(()) + } + + pub fn events_for_session(&self, session_id: &str) -> Vec { + self.events_by_session + .get(session_id) + .map(|events| events.clone()) + .unwrap_or_default() + } + pub fn events_for_turn(&self, session_id: &str, turn_id: &str) -> Vec { self.events_by_session .get(session_id) @@ -375,9 +589,11 @@ fn truncate_string_at_char_boundary(value: &str, max_bytes: usize) -> String { mod tests { use super::{ CompressionContract, EvidenceLedgerCheckpoint, EvidenceLedgerEvent, - EvidenceLedgerEventStatus, EvidenceLedgerTargetKind, SessionEvidenceLedger, + EvidenceLedgerEventStatus, EvidenceLedgerPersistenceError, EvidenceLedgerTargetKind, + PersistedEvidenceLedgerFile, SessionEvidenceLedger, EVIDENCE_LEDGER_SCHEMA_VERSION, }; use crate::checkpoint::LightCheckpoint; + use std::collections::HashSet; #[test] fn ledger_reads_events_scoped_by_session_and_turn() { @@ -405,6 +621,171 @@ mod tests { assert!(ledger.events_for_turn("other-session", "turn-a").is_empty()); } + #[test] + fn persisted_ledger_append_is_idempotent_and_rejects_conflicting_replays() { + let event = EvidenceLedgerEvent::new( + "session-a", + "turn-a", + "Bash", + EvidenceLedgerTargetKind::Command, + "cargo test", + EvidenceLedgerEventStatus::Succeeded, + "Tests passed.", + ); + let mut file = PersistedEvidenceLedgerFile::new("session-a"); + + assert!(file.append(event.clone()).expect("first append")); + assert!(!file.append(event.clone()).expect("idempotent replay")); + + let mut conflicting = event.clone(); + conflicting.summary = "Different payload.".to_string(); + assert!(matches!( + file.append(conflicting), + Err(EvidenceLedgerPersistenceError::ConflictingEvent { .. }) + )); + assert_eq!(file.events, vec![event]); + } + + #[test] + fn persisted_ledger_retain_turn_ids_keeps_only_surviving_turn_evidence() { + let mut file = PersistedEvidenceLedgerFile::new("session-a"); + let turn_a = EvidenceLedgerEvent::new( + "session-a", + "turn-a", + "Bash", + EvidenceLedgerTargetKind::Command, + "cargo test", + EvidenceLedgerEventStatus::Succeeded, + "Tests passed.", + ); + let turn_b = EvidenceLedgerEvent::new( + "session-a", + "turn-b", + "Bash", + EvidenceLedgerTargetKind::Command, + "cargo check", + EvidenceLedgerEventStatus::Failed, + "Check failed.", + ); + file.append(turn_a.clone()).expect("turn-a append"); + file.append(turn_b.clone()).expect("turn-b append"); + + let retained = file + .retain_turn_ids("session-a", &HashSet::from(["turn-a".to_string()])) + .expect("retain should succeed"); + assert_eq!(retained, vec![turn_a.clone()]); + assert_eq!(file.events, vec![turn_a.clone()]); + + let retained = file + .retain_turn_ids("session-a", &HashSet::new()) + .expect("retain should clear"); + assert!(retained.is_empty()); + assert!(file.events.is_empty()); + } + + #[test] + fn in_memory_ledger_retain_turn_ids_removes_empty_sessions() { + let ledger = SessionEvidenceLedger::new(); + ledger.append(EvidenceLedgerEvent::new( + "session-a", + "turn-a", + "Task", + EvidenceLedgerTargetKind::Subagent, + "Review", + EvidenceLedgerEventStatus::Created, + "Started", + )); + ledger.append(EvidenceLedgerEvent::new( + "session-a", + "turn-b", + "Task", + EvidenceLedgerTargetKind::Subagent, + "Edit", + EvidenceLedgerEventStatus::Created, + "Started", + )); + + let retained = ledger.retain_turn_ids("session-a", &HashSet::from(["turn-a".to_string()])); + assert_eq!(retained.len(), 1); + assert_eq!(retained[0].turn_id, "turn-a"); + + let retained = ledger.retain_turn_ids("session-a", &HashSet::new()); + assert!(retained.is_empty()); + assert!(ledger.events_for_session("session-a").is_empty()); + } + + #[test] + fn persisted_ledger_rejects_newer_schema_and_cross_session_events() { + let newer = PersistedEvidenceLedgerFile { + schema_version: EVIDENCE_LEDGER_SCHEMA_VERSION + 1, + session_id: "session-a".to_string(), + events: Vec::new(), + }; + assert!(matches!( + newer.validated_events("session-a"), + Err(EvidenceLedgerPersistenceError::UnsupportedSchema { .. }) + )); + + let mut mismatched = PersistedEvidenceLedgerFile::new("session-a"); + mismatched.events.push(EvidenceLedgerEvent::new( + "session-b", + "turn-a", + "Bash", + EvidenceLedgerTargetKind::Command, + "cargo test", + EvidenceLedgerEventStatus::Succeeded, + "Tests passed.", + )); + assert!(matches!( + mismatched.validated_events("session-a"), + Err(EvidenceLedgerPersistenceError::EventSessionMismatch { .. }) + )); + } + + #[test] + fn legacy_evidence_event_payload_loads_with_additive_defaults() { + let file: PersistedEvidenceLedgerFile = serde_json::from_value(serde_json::json!({ + "session_id": "session-a", + "events": [{ + "event_id": "event-a", + "session_id": "session-a", + "turn_id": "turn-a", + "tool_name": "Bash", + "target_kind": "command", + "target": "cargo test", + "status": "succeeded", + "summary": "Tests passed." + }] + })) + .expect("legacy payload should deserialize"); + + assert_eq!(file.schema_version, EVIDENCE_LEDGER_SCHEMA_VERSION); + let events = file + .validated_events("session-a") + .expect("legacy payload should validate"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].created_at_ms, 0); + assert!(events[0].touched_files.is_empty()); + assert!(events[0].partial_output.is_none()); + + let round_trip: PersistedEvidenceLedgerFile = serde_json::from_value( + serde_json::to_value(PersistedEvidenceLedgerFile { + schema_version: EVIDENCE_LEDGER_SCHEMA_VERSION, + session_id: "session-a".to_string(), + events, + }) + .expect("upgraded payload should serialize"), + ) + .expect("upgraded payload should deserialize"); + assert_eq!( + round_trip + .validated_events("session-a") + .expect("round trip should validate")[0] + .event_id, + "event-a" + ); + } + #[test] fn deleting_a_session_releases_its_evidence_events() { let ledger = SessionEvidenceLedger::new(); diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index 6685e7657d..e1f335e4f0 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -13,8 +13,6 @@ pub mod context_profile; pub mod custom_agent; #[cfg(feature = "agent-runtime")] pub mod custom_subagent; -#[cfg(feature = "deep-research")] -pub mod deep_research; #[cfg(feature = "agent-runtime")] pub mod deep_review; #[cfg(feature = "agent-runtime")] diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 909cd35a04..534ff78506 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -25,11 +25,11 @@ use bitfun_runtime_ports::{ AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, AgentSessionModelPort, AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, - AgentSessionRenameRequest, AgentSessionRevertPort, AgentSessionRevertRequest, - AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, AgentSessionRollbackToTurnRequest, - AgentSessionSummary, AgentSessionUsagePort, AgentSessionUsageRequest, - AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, - AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentSessionReleaseRequest, AgentSessionRenameRequest, AgentSessionRevertPort, + AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, + AgentSessionRollbackToTurnRequest, AgentSessionSummary, AgentSessionUsagePort, + AgentSessionUsageRequest, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTransientSessionDiscardRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, @@ -1176,6 +1176,23 @@ impl AgentRuntime { .map_err(RuntimeError::from) } + pub async fn unload_persisted_session( + &self, + request: AgentSessionReleaseRequest, + ) -> Result { + self.session_close + .as_ref() + .ok_or_else(|| { + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent session close port is not registered", + )) + })? + .unload_persisted_session(request) + .await + .map_err(RuntimeError::from) + } + pub async fn list_sessions( &self, request: AgentSessionListRequest, diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 0ca19ff699..7cf45c2968 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -pub const AGENT_RUNTIME_SDK_API_VERSION: u32 = 6; +pub const AGENT_RUNTIME_SDK_API_VERSION: u32 = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -35,6 +35,7 @@ impl AgentRuntimeSdkCompatibility { } pub use crate::context_profile::{ContextProfile, ContextProfilePolicy, ModelCapabilityProfile}; +pub use crate::dialog_turn::TurnTokenUsage; pub use crate::event_source::{AgentEventReceiver, AgentEventSource, AgentSessionEventReceiver}; pub use crate::permission::{ PermissionReplyResolution, PermissionRequestEventReceiver, PermissionRequestManager, @@ -86,11 +87,11 @@ pub use bitfun_runtime_ports::{ AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, AgentSessionModelPort, AgentSessionModelSelection, AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, - AgentSessionRenameRequest, AgentSessionRevertPort, AgentSessionRevertRequest, - AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, AgentSessionRollbackToTurnRequest, - AgentSessionSummary, AgentSessionUsagePort, AgentSessionUsageRequest, - AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, - AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentSessionReleaseRequest, AgentSessionRenameRequest, AgentSessionRevertPort, + AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionRollbackToTurnOutcome, + AgentSessionRollbackToTurnRequest, AgentSessionSummary, AgentSessionUsagePort, + AgentSessionUsageRequest, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTransientSessionDiscardRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, @@ -491,6 +492,13 @@ impl AgentRuntime { self.inner.discard_transient_session(request).await } + pub async fn unload_persisted_session( + &self, + request: AgentSessionReleaseRequest, + ) -> Result { + self.inner.unload_persisted_session(request).await + } + pub async fn list_sessions( &self, request: AgentSessionListRequest, diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs index 7e453142c8..232a874abb 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs @@ -48,6 +48,7 @@ struct FakeSdkRuntimeEventSink; #[derive(Debug, Default)] struct FakeSessionClosePort { requests: Mutex>, + persisted_requests: Mutex>, } #[derive(Debug, Default)] @@ -75,7 +76,7 @@ impl AgentModeCatalogPort for FakeModeCatalog { fn sdk_facade_exposes_versioned_preview_compatibility_contract() { let compatibility = AgentRuntimeSdkCompatibility::current(); - assert_eq!(compatibility.api_version, 6); + assert_eq!(compatibility.api_version, 8); assert_eq!(compatibility.crate_version, env!("CARGO_PKG_VERSION")); assert_eq!(compatibility.stability, AgentRuntimeSdkStability::Preview); } @@ -142,6 +143,17 @@ impl AgentSessionClosePort for FakeSessionClosePort { self.requests.lock().unwrap().push(request.clone()); Ok(true) } + + async fn unload_persisted_session( + &self, + request: AgentTransientSessionDiscardRequest, + ) -> PortResult { + self.persisted_requests + .lock() + .unwrap() + .push(request.clone()); + Ok(true) + } } #[async_trait] @@ -459,6 +471,35 @@ async fn sdk_facade_delegates_connection_scoped_session_discard() { assert!(result); } +#[tokio::test] +async fn sdk_facade_delegates_persisted_session_unload() { + let provider = Arc::new(FakeSdkAgentProvider::default()); + let close_port = Arc::new(FakeSessionClosePort::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(provider) + .with_session_close_port(close_port.clone()) + .build() + .expect("sdk runtime"); + let request = AgentTransientSessionDiscardRequest { + workspace_path: "/workspace/project".to_string(), + session_id: "sdk-session-1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + wait_timeout_ms: 5_000, + }; + + let result = runtime + .unload_persisted_session(request.clone()) + .await + .expect("unload persisted session through SDK facade"); + + assert_eq!( + close_port.persisted_requests.lock().unwrap().as_slice(), + &[request] + ); + assert!(result); +} + #[tokio::test] async fn sdk_facade_reports_missing_session_close_capability() { let runtime = AgentRuntimeBuilder::new() diff --git a/src/crates/execution/agent-workflows/AGENTS.md b/src/crates/execution/agent-workflows/AGENTS.md new file mode 100644 index 0000000000..4658996e4f --- /dev/null +++ b/src/crates/execution/agent-workflows/AGENTS.md @@ -0,0 +1,16 @@ +# Agent Workflows + +`bitfun-agent-workflows` owns named BitFun workflow policy that is independent +from hosts and concrete IO, such as DeepResearch report shaping. + +It may build on Agent Runtime contracts. Agent Runtime must not depend on this +crate. Host selection belongs to Assembly; filesystem, network, and process IO +belong to Services; protocol translation belongs to Interfaces or Adapters. + +Keep workflow modules independent unless they share a proven runtime primitive. + +## Verification + +```bash +cargo test -p bitfun-agent-workflows +``` diff --git a/src/crates/execution/agent-workflows/Cargo.toml b/src/crates/execution/agent-workflows/Cargo.toml new file mode 100644 index 0000000000..f23ac06155 --- /dev/null +++ b/src/crates/execution/agent-workflows/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "bitfun-agent-workflows" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Named BitFun agent workflow policies" +autotests = false + +[lib] +name = "bitfun_agent_workflows" +crate-type = ["rlib"] + +[dependencies] +regex = { workspace = true } + +[[test]] +name = "deep_research_contracts" +path = "tests/deep_research_contracts.rs" + +[lints] +workspace = true diff --git a/src/crates/execution/agent-runtime/src/deep_research.rs b/src/crates/execution/agent-workflows/src/deep_research.rs similarity index 99% rename from src/crates/execution/agent-runtime/src/deep_research.rs rename to src/crates/execution/agent-workflows/src/deep_research.rs index 48413cdb40..c2812e3112 100644 --- a/src/crates/execution/agent-runtime/src/deep_research.rs +++ b/src/crates/execution/agent-workflows/src/deep_research.rs @@ -1,4 +1,4 @@ -//! Provider-neutral DeepResearch report post-processing. +//! DeepResearch report post-processing policy. //! //! This module owns deterministic citation renumbering for finalized research //! reports. It does not read or write files; callers provide the report body diff --git a/src/crates/execution/agent-workflows/src/lib.rs b/src/crates/execution/agent-workflows/src/lib.rs new file mode 100644 index 0000000000..bd85fdedaa --- /dev/null +++ b/src/crates/execution/agent-workflows/src/lib.rs @@ -0,0 +1,6 @@ +//! Named product workflow policies built on top of the Agent Runtime. +//! +//! This crate contains deterministic workflow behavior. It does not own host +//! assembly, transport protocols, filesystem access, or concrete services. + +pub mod deep_research; diff --git a/src/crates/execution/agent-runtime/tests/deep_research_contracts.rs b/src/crates/execution/agent-workflows/tests/deep_research_contracts.rs similarity index 95% rename from src/crates/execution/agent-runtime/tests/deep_research_contracts.rs rename to src/crates/execution/agent-workflows/tests/deep_research_contracts.rs index 4e13effc21..3e358a3641 100644 --- a/src/crates/execution/agent-runtime/tests/deep_research_contracts.rs +++ b/src/crates/execution/agent-workflows/tests/deep_research_contracts.rs @@ -1,7 +1,5 @@ //! DeepResearch report and citation contracts. -#![cfg(feature = "deep-research")] - -use bitfun_agent_runtime::deep_research::renumber_research_report; +use bitfun_agent_workflows::deep_research::renumber_research_report; #[test] fn deep_research_citation_renumber_owner_preserves_report_and_display_map_contracts() { diff --git a/src/crates/execution/tool-execution/src/search/glob_search.rs b/src/crates/execution/tool-execution/src/search/glob_search.rs index 506e8eba15..d94bd3e89b 100644 --- a/src/crates/execution/tool-execution/src/search/glob_search.rs +++ b/src/crates/execution/tool-execution/src/search/glob_search.rs @@ -179,7 +179,6 @@ fn create_command(program: &str) -> Command { #[cfg(not(windows))] fn create_command(program: &str) -> Command { - Command::new(program) } diff --git a/src/crates/execution/tool-execution/src/web_readable.rs b/src/crates/execution/tool-execution/src/web_readable.rs index 4e8c4ff370..0a5a0753b5 100644 --- a/src/crates/execution/tool-execution/src/web_readable.rs +++ b/src/crates/execution/tool-execution/src/web_readable.rs @@ -75,7 +75,10 @@ pub fn extract_markdown_with_text_fallback( // article, documentation, wiki, and forum pages showed `legible` gives the // best current quality/latency balance, with readability-js as fallback. #[cfg(not(target_env = "ohos"))] - let extractors: &[ExtractorFn] = &[attempt_legible as ExtractorFn, attempt_readability_js as ExtractorFn]; + let extractors: &[ExtractorFn] = &[ + attempt_legible as ExtractorFn, + attempt_readability_js as ExtractorFn, + ]; #[cfg(target_env = "ohos")] let extractors: &[ExtractorFn] = &[attempt_legible as ExtractorFn]; diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 9c3f5bb910..66715505fc 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -148,19 +148,16 @@ const CORE_BASIC_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ ToolPackFeatureGroup::Basic, ToolPackFeatureGroup::ImageAnalysis, ]; -const CORE_AGENT_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ - ToolPackFeatureGroup::AgentControl, - ToolPackFeatureGroup::Git, -]; +const CORE_AGENT_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::AgentControl]; +const CORE_REVIEW_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::AgentControl]; const CORE_CANVAS_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Canvas]; const CORE_SESSION_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::AgentControl]; -const CORE_INTEGRATION_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ - ToolPackFeatureGroup::BrowserWeb, - ToolPackFeatureGroup::Mcp, - ToolPackFeatureGroup::Git, - ToolPackFeatureGroup::MiniApp, - ToolPackFeatureGroup::ComputerUse, -]; +const CORE_GIT_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Git]; +const CORE_WEB_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::BrowserWeb]; +const CORE_MCP_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Mcp]; +const CORE_COMPUTER_USE_FEATURE_GROUPS: &[ToolPackFeatureGroup] = + &[ToolPackFeatureGroup::ComputerUse]; +const CORE_MINIAPP_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::MiniApp]; const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { @@ -194,7 +191,6 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "AgentList", "AgentDelete", "AgentWait", - "LaunchReviewAgent", "Skill", "AskUserQuestion", "TodoWrite", @@ -202,18 +198,11 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "create_goal", "update_goal", "CreatePlan", - "submit_code_review", "GetToolSpec", "CallDeferredTool", "BitFunControl", - "GetFileDiff", ], }, - ToolProviderGroupPlan { - provider_id: "core.canvas", - feature_groups: CORE_CANVAS_FEATURE_GROUPS, - tool_names: &["CreateCanvas", "ReadCanvas", "UpdateCanvas", "PatchCanvas"], - }, ToolProviderGroupPlan { provider_id: "core.session", feature_groups: CORE_SESSION_FEATURE_GROUPS, @@ -226,30 +215,54 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ], }, ToolProviderGroupPlan { - provider_id: "core.integration", - feature_groups: CORE_INTEGRATION_FEATURE_GROUPS, + provider_id: "core.git", + feature_groups: CORE_GIT_FEATURE_GROUPS, + tool_names: &["GetFileDiff", "Git", "Worktree", "ReviewPlatform"], + }, + ToolProviderGroupPlan { + provider_id: "core.web", + feature_groups: CORE_WEB_FEATURE_GROUPS, + tool_names: &["WebSearch", "WebFetch", "ControlHub"], + }, + ToolProviderGroupPlan { + provider_id: "core.mcp", + feature_groups: CORE_MCP_FEATURE_GROUPS, tool_names: &[ - "WebSearch", - "WebFetch", "ListMCPResources", "ReadMCPResource", "ListMCPPrompts", "GetMCPPrompt", + ], + }, + ToolProviderGroupPlan { + provider_id: "core.computer-use", + feature_groups: CORE_COMPUTER_USE_FEATURE_GROUPS, + tool_names: &["ComputerUse"], + }, + ToolProviderGroupPlan { + provider_id: "core.review", + feature_groups: CORE_REVIEW_FEATURE_GROUPS, + tool_names: &["LaunchReviewAgent", "submit_code_review"], + }, + ToolProviderGroupPlan { + provider_id: "core.miniapp", + feature_groups: CORE_MINIAPP_FEATURE_GROUPS, + tool_names: &[ "GenerativeUI", - "Git", - "Worktree", - "ReviewPlatform", "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", "PublishAppearance", "PageDeploy", "PagePublish", - "ControlHub", - "ComputerUse", "Playbook", ], }, + ToolProviderGroupPlan { + provider_id: "core.canvas", + feature_groups: CORE_CANVAS_FEATURE_GROUPS, + tool_names: &["CreateCanvas", "ReadCanvas", "UpdateCanvas", "PatchCanvas"], + }, ]; pub fn product_tool_provider_group_plan() -> &'static [ToolProviderGroupPlan] { @@ -446,7 +459,7 @@ mod tests { } #[test] - fn product_provider_group_plan_preserves_core_runtime_order() { + fn product_provider_group_plan_exposes_atomic_runtime_owners() { let provider_ids = product_tool_provider_group_plan() .iter() .map(|group| group.provider_id()) @@ -457,15 +470,20 @@ mod tests { vec![ "core.basic", "core.agent", - "core.canvas", "core.session", - "core.integration" + "core.git", + "core.web", + "core.mcp", + "core.computer-use", + "core.review", + "core.miniapp", + "core.canvas", ] ); } #[test] - fn product_provider_group_plan_preserves_builtin_tool_order() { + fn product_provider_group_plan_keeps_stable_atomic_owner_tool_listings() { let tool_names = product_tool_provider_group_plan() .iter() .flat_map(|group| group.tool_names().iter().copied()) @@ -495,7 +513,6 @@ mod tests { "AgentList", "AgentDelete", "AgentWait", - "LaunchReviewAgent", "Skill", "AskUserQuestion", "TodoWrite", @@ -503,39 +520,40 @@ mod tests { "create_goal", "update_goal", "CreatePlan", - "submit_code_review", "GetToolSpec", "CallDeferredTool", "BitFunControl", - "GetFileDiff", - "CreateCanvas", - "ReadCanvas", - "UpdateCanvas", - "PatchCanvas", "SessionControl", "SessionMessage", "SessionHistory", "Cron", "PortForward", + "GetFileDiff", + "Git", + "Worktree", + "ReviewPlatform", "WebSearch", "WebFetch", + "ControlHub", "ListMCPResources", "ReadMCPResource", "ListMCPPrompts", "GetMCPPrompt", + "ComputerUse", + "LaunchReviewAgent", + "submit_code_review", "GenerativeUI", - "Git", - "Worktree", - "ReviewPlatform", "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", "PublishAppearance", "PageDeploy", "PagePublish", - "ControlHub", - "ComputerUse", "Playbook", + "CreateCanvas", + "ReadCanvas", + "UpdateCanvas", + "PatchCanvas", ] ); } @@ -560,29 +578,30 @@ mod tests { feature_groups, vec![ ("core.basic", vec!["basic", "image-analysis"]), - ("core.agent", vec!["agent-control", "git"]), - ("core.canvas", vec!["canvas"]), + ("core.agent", vec!["agent-control"]), ("core.session", vec!["agent-control"]), - ( - "core.integration", - vec!["browser-web", "mcp", "git", "miniapp", "computer-use",] - ), + ("core.git", vec!["git"]), + ("core.web", vec!["browser-web"]), + ("core.mcp", vec!["mcp"]), + ("core.computer-use", vec!["computer-use"]), + ("core.review", vec!["agent-control"]), + ("core.miniapp", vec!["miniapp"]), + ("core.canvas", vec!["canvas"]), ] ); } #[test] fn product_provider_group_plan_selector_preserves_product_plan_order_for_requested_ids() { - let plan = - try_product_tool_provider_group_plan_for_ids(&["core.integration", "core.basic"]) - .expect("known provider groups should select"); + let plan = try_product_tool_provider_group_plan_for_ids(&["core.mcp", "core.basic"]) + .expect("known provider groups should select"); let provider_ids = plan .iter() .map(|group| group.provider_id()) .collect::>(); - assert_eq!(provider_ids, vec!["core.basic", "core.integration"]); + assert_eq!(provider_ids, vec!["core.basic", "core.mcp"]); } #[test] diff --git a/src/crates/interfaces/acp/Cargo.toml b/src/crates/interfaces/acp/Cargo.toml index 1c16534f54..53b3ef7201 100644 --- a/src/crates/interfaces/acp/Cargo.toml +++ b/src/crates/interfaces/acp/Cargo.toml @@ -36,8 +36,6 @@ server = [ "bitfun-core/tools-browser-web", "bitfun-core/tools-computer-use", "bitfun-core/tools-image-analysis", - "bitfun-core/tools-miniapp", - "bitfun-core/tools-canvas", "bitfun-core/tools-agent-control", ] diff --git a/src/crates/interfaces/app-server-protocol/Cargo.toml b/src/crates/interfaces/app-server-protocol/Cargo.toml index 2503e45d93..e0c4886917 100644 --- a/src/crates/interfaces/app-server-protocol/Cargo.toml +++ b/src/crates/interfaces/app-server-protocol/Cargo.toml @@ -9,7 +9,7 @@ description = "Behavior-light wire contracts and roles for the BitFun App Server name = "bitfun_app_server_protocol" [dependencies] -agent-client-protocol = { workspace = true } +agent-client-protocol = { workspace = true, optional = true } bitfun-events = { path = "../../contracts/events" } bitfun-core-types = { path = "../../contracts/core-types" } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["external-sources"] } @@ -19,7 +19,8 @@ serde_json = { workspace = true } ts-rs = { workspace = true, optional = true } [features] -default = [] +default = ["rpc"] +rpc = ["dep:agent-client-protocol"] ts = [ "dep:ts-rs", "bitfun-core-types/ts", diff --git a/src/crates/interfaces/app-server-protocol/src/config.rs b/src/crates/interfaces/app-server-protocol/src/config.rs index 741cd73c0c..d172bc7e2b 100644 --- a/src/crates/interfaces/app-server-protocol/src/config.rs +++ b/src/crates/interfaces/app-server-protocol/src/config.rs @@ -3,6 +3,7 @@ //! These payloads intentionally contain only wire-owned data. Server adapters //! translate them to the configuration service's domain request/result types. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; @@ -21,37 +22,44 @@ pub struct AgentProfileView { pub enabled_user_skills: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "config/getAgentProfileConfigs", response = GetAgentProfileConfigsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "config/getAgentProfileConfigs", response = GetAgentProfileConfigsResponse))] pub struct GetAgentProfileConfigsMessage {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GetAgentProfileConfigsResponse { pub profiles: HashMap, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "config/getAgentProfileConfig", response = GetAgentProfileConfigResponse)] +#[cfg_attr(feature = "rpc", request(method = "config/getAgentProfileConfig", response = GetAgentProfileConfigResponse))] pub struct GetAgentProfileConfigMessage { pub agent_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GetAgentProfileConfigResponse(pub AgentProfileView); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "config/getModelConfigs", response = GetModelConfigsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "config/getModelConfigs", response = GetModelConfigsResponse))] pub struct GetModelConfigsMessage {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GetModelConfigsResponse { pub models: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[serde(rename_all = "camelCase")] -#[request(method = "config/getConfig", response = GetConfigResponse)] +#[cfg_attr(feature = "rpc", request(method = "config/getConfig", response = GetConfigResponse))] pub struct GetConfigMessage { #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, @@ -59,56 +67,65 @@ pub struct GetConfigMessage { pub skip_retry_on_not_found: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GetConfigResponse(pub serde_json::Value); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[serde(rename_all = "camelCase")] -#[request(method = "config/getConfigs", response = GetConfigsResponse)] +#[cfg_attr(feature = "rpc", request(method = "config/getConfigs", response = GetConfigsResponse))] pub struct GetConfigsMessage { pub paths: Vec, #[serde(default, skip_serializing_if = "skip_if_false")] pub skip_retry_on_not_found: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GetConfigsResponse { pub configs: BTreeMap, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[serde(rename_all = "camelCase")] -#[request(method = "config/setConfig", response = SetConfigResponse)] +#[cfg_attr(feature = "rpc", request(method = "config/setConfig", response = SetConfigResponse))] pub struct SetConfigMessage { pub path: String, pub value: serde_json::Value, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SetConfigResponse {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] -#[request(method = "config/setAgentProfileConfig", response = SetAgentProfileConfigResponse)] +#[cfg_attr(feature = "rpc", request(method = "config/setAgentProfileConfig", response = SetAgentProfileConfigResponse))] pub struct SetAgentProfileConfigMessage { pub agent_id: String, pub config: serde_json::Value, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct SetAgentProfileConfigResponse(pub AgentProfileView); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] -#[request(method = "config/resetAgentProfileConfig", response = ResetAgentProfileConfigResponse)] +#[cfg_attr(feature = "rpc", request(method = "config/resetAgentProfileConfig", response = ResetAgentProfileConfigResponse))] pub struct ResetAgentProfileConfigMessage { pub agent_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct ResetAgentProfileConfigResponse(pub AgentProfileView); @@ -131,9 +148,10 @@ pub struct SaveCloudSpeechConfigRequest { pub api_key: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "config/saveCloudSpeechConfig", response = SaveCloudSpeechConfigResponse)] +#[cfg_attr(feature = "rpc", request(method = "config/saveCloudSpeechConfig", response = SaveCloudSpeechConfigResponse))] pub struct SaveCloudSpeechConfigMessage { pub request: SaveCloudSpeechConfigRequest, } @@ -146,15 +164,18 @@ pub struct SaveCloudSpeechConfigResult { pub created: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct SaveCloudSpeechConfigResponse(pub SaveCloudSpeechConfigResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "config/validateConfig", response = ValidateConfigResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "config/validateConfig", response = ValidateConfigResponse))] pub struct ValidateConfigMessage {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ValidateConfigResponse(pub serde_json::Value); #[cfg(test)] diff --git a/src/crates/interfaces/app-server-protocol/src/lib.rs b/src/crates/interfaces/app-server-protocol/src/lib.rs index 0a9f7c8011..1fac32d05e 100644 --- a/src/crates/interfaces/app-server-protocol/src/lib.rs +++ b/src/crates/interfaces/app-server-protocol/src/lib.rs @@ -6,12 +6,18 @@ //! Wire schemas live under [`schemas`] and are grouped into modules named after //! their JSON-RPC method domain; shared event envelopes remain in //! [`schemas::event`]. +//! The default `rpc` feature attaches ACP JSON-RPC traits and exposes the role +//! and transport helpers. The independent `ts` feature exports wire DTOs +//! without compiling that runtime integration. pub mod config; +#[cfg(feature = "rpc")] pub mod role; pub mod schemas; +#[cfg(feature = "rpc")] pub mod transport; +#[cfg(feature = "rpc")] pub use role::{AppClient, AppServer}; // Keep the established public domain paths while the schema sources live in a // single physical directory. diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/account.rs b/src/crates/interfaces/app-server-protocol/src/schemas/account.rs index 211f3db619..545475f544 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/account.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/account.rs @@ -1,5 +1,6 @@ //! Account and settings-sync App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; @@ -7,8 +8,9 @@ pub use bitfun_product_domains::account::{ AccountDevice, AccountInfo, SettingsSyncProgress, SettingsSyncStatus, }; -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "account/snapshot", response = AccountSnapshotResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "account/snapshot", response = AccountSnapshotResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccountSnapshotRequest { pub workspace_path: String, @@ -23,7 +25,8 @@ impl std::fmt::Debug for AccountSnapshotRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct AccountSnapshotResponse { pub logged_in: bool, @@ -35,8 +38,9 @@ pub struct AccountSnapshotResponse { pub sync: SettingsSyncProgress, } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "account/login", response = AccountLoginResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "account/login", response = AccountLoginResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccountLoginRequest { pub operation_id: String, @@ -57,7 +61,8 @@ impl std::fmt::Debug for AccountLoginRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct AccountLoginResponse { pub user_id: String, @@ -66,8 +71,9 @@ pub struct AccountLoginResponse { pub status_message: String, } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "account/finalizeLogin", response = AccountSnapshotResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "account/finalizeLogin", response = AccountSnapshotResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccountFinalizeLoginRequest { pub operation_id: String, @@ -93,8 +99,9 @@ impl std::fmt::Debug for AccountFinalizeLoginRequest { } } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "account/logout", response = AccountSnapshotResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "account/logout", response = AccountSnapshotResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccountLogoutRequest { pub operation_id: String, @@ -111,8 +118,9 @@ impl std::fmt::Debug for AccountLogoutRequest { } } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "settingsSync/start", response = SettingsSyncResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "settingsSync/start", response = SettingsSyncResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SettingsSyncStartRequest { pub operation_id: String, @@ -131,8 +139,9 @@ impl std::fmt::Debug for SettingsSyncStartRequest { } } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "settingsSync/snapshot", response = SettingsSyncResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "settingsSync/snapshot", response = SettingsSyncResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SettingsSyncSnapshotRequest { pub workspace_path: String, @@ -147,8 +156,9 @@ impl std::fmt::Debug for SettingsSyncSnapshotRequest { } } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "settingsSync/cancel", response = SettingsSyncResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "settingsSync/cancel", response = SettingsSyncResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SettingsSyncCancelRequest { pub operation_id: String, @@ -165,8 +175,9 @@ impl std::fmt::Debug for SettingsSyncCancelRequest { } } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "settingsSync/localChanged", response = SettingsSyncResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "settingsSync/localChanged", response = SettingsSyncResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SettingsSyncLocalChangedRequest { pub operation_id: String, @@ -183,7 +194,8 @@ impl std::fmt::Debug for SettingsSyncLocalChangedRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct SettingsSyncResponse { pub progress: SettingsSyncProgress, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs index 44cb3eae53..52bf602364 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs @@ -1,5 +1,6 @@ //! Agent-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; use bitfun_runtime_ports::{ @@ -14,13 +15,15 @@ use serde::{Deserialize, Serialize}; macro_rules! unit_response { ($name:ident) => { - #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] + #[derive(Debug, Clone, Serialize, Deserialize)] + #[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct $name {} }; } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/listModes", response = ListAgentModesResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/listModes", response = ListAgentModesResponse))] #[serde(rename_all = "camelCase")] pub struct ListAgentModesRequest { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -29,7 +32,8 @@ pub struct ListAgentModesRequest { pub include_external: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ListAgentModesResponse { pub modes: Vec, } @@ -45,35 +49,42 @@ pub struct AgentModeSummary { pub is_external: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/listSessions", response = ListSessionsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/listSessions", response = ListSessionsResponse))] pub struct ListSessionsRequest(pub AgentSessionListRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] /// `agent/listSessions` response body. pub struct ListSessionsResponse { pub sessions: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/steerTurn", response = SteerTurnResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/steerTurn", response = SteerTurnResponse))] pub struct SteerTurnRequest(pub AgentDialogSteerRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SteerTurnResponse { pub steering_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/runUserShellCommand", response = RunUserShellCommandResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/runUserShellCommand", response = RunUserShellCommandResponse))] pub struct RunUserShellCommandRequest(pub AgentUserShellCommandRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct RunUserShellCommandResponse(pub AgentUserShellCommandResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/submitUserAnswers", response = SubmitUserAnswersResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/submitUserAnswers", response = SubmitUserAnswersResponse))] pub struct SubmitUserAnswersRequest { pub tool_id: String, pub answers: serde_json::Value, @@ -81,21 +92,25 @@ pub struct SubmitUserAnswersRequest { unit_response!(SubmitUserAnswersResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/createSession", response = CreateSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/createSession", response = CreateSessionResponse))] pub struct CreateSessionRequest(pub AgentSessionCreateRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct CreateSessionResponse(pub AgentSessionCreateResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/deleteSession", response = DeleteSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/deleteSession", response = DeleteSessionResponse))] pub struct DeleteSessionRequest(pub AgentSessionDeleteRequest); unit_response!(DeleteSessionResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/submitDialogTurn", response = SubmitDialogTurnResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/submitDialogTurn", response = SubmitDialogTurnResponse))] pub struct SubmitDialogTurnRequest(pub SubmitDialogTurnBody); pub use SubmitDialogTurnRequest as SubmitDialogTurnMessage; @@ -173,7 +188,8 @@ impl From for SubmitDialogTurnRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase", tag = "status")] /// `agent/submitDialogTurn` response body. @@ -182,9 +198,10 @@ pub enum SubmitDialogTurnResponse { Queued { session_id: String, turn_id: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "agent/respondPermission", response = RespondPermissionResponse)] +#[cfg_attr(feature = "rpc", request(method = "agent/respondPermission", response = RespondPermissionResponse))] pub struct RespondPermissionRequest { pub request_id: String, pub reply: PermissionReply, @@ -192,20 +209,24 @@ pub struct RespondPermissionRequest { unit_response!(RespondPermissionResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/listPendingPermissionRequests", response = PendingPermissionsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/listPendingPermissionRequests", response = PendingPermissionsResponse))] pub struct PendingPermissionsRequest {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct PendingPermissionsResponse { pub requests: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/cancelTurn", response = CancelTurnResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/cancelTurn", response = CancelTurnResponse))] pub struct CancelTurnRequest(pub AgentTurnCancellationRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct CancelTurnResponse(pub AgentTurnCancellationResult); pub use CancelTurnRequest as CancelTurnMessage; @@ -213,15 +234,18 @@ pub use CreateSessionRequest as CreateSessionMessage; pub use DeleteSessionRequest as DeleteSessionMessage; pub use ListSessionsRequest as ListSessionsMessage; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/submitTurn", response = SubmitTurnResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/submitTurn", response = SubmitTurnResponse))] pub struct SubmitTurnMessage(pub AgentSubmissionRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SubmitTurnResponse(pub AgentSubmissionResult); /// `agent/run` response body. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct RunResponse { @@ -249,8 +273,9 @@ pub enum RunSessionSpec { } /// Compatibility request for `agent/run`. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/run", response = RunResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/run", response = RunResponse))] #[serde(rename_all = "camelCase")] pub struct RunMessage { pub session: RunSessionSpec, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/app.rs b/src/crates/interfaces/app-server-protocol/src/schemas/app.rs index 6338409797..812cd07af8 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/app.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/app.rs @@ -1,5 +1,6 @@ //! Connection initialization, capability, and health wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; @@ -42,15 +43,17 @@ pub struct CapabilityDescriptor { pub methods: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "app/initialize", response = InitializeResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "app/initialize", response = InitializeResponse))] #[serde(rename_all = "camelCase")] pub struct InitializeRequest { pub protocol_version: u32, pub client: ClientInfo, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct InitializeResponse { pub protocol_version: u32, @@ -76,11 +79,13 @@ impl InitializeResponse { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "app/health", response = HealthResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "app/health", response = HealthResponse))] pub struct HealthRequest {} -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct HealthResponse { pub status: HealthStatus, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/event.rs b/src/crates/interfaces/app-server-protocol/src/schemas/event.rs index 3a3e58f0a9..6fca0f9421 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/event.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/event.rs @@ -1,14 +1,16 @@ //! Authoritative, sequenced App Server event schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::{PermissionRequest, PermissionRequestEvent}; use serde::{Deserialize, Serialize}; /// Browser-facing projected runtime or permission event. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcNotification))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[notification(method = "agent/frontendEvent")] +#[cfg_attr(feature = "rpc", notification(method = "agent/frontendEvent"))] pub struct FrontendEventNotification { pub event: String, pub payload: serde_json::Value, @@ -31,32 +33,36 @@ pub struct EventCursor { pub sequence: u64, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -#[notification(method = "agent/event")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcNotification))] +#[cfg_attr(feature = "rpc", notification(method = "agent/event"))] #[serde(rename_all = "camelCase")] pub struct AgentEventNotification { pub cursor: EventCursor, pub event: AgenticEventEnvelope, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -#[notification(method = "agent/permissionEvent")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcNotification))] +#[cfg_attr(feature = "rpc", notification(method = "agent/permissionEvent"))] #[serde(rename_all = "camelCase")] pub struct PermissionEventNotification { pub cursor: EventCursor, pub event: PermissionRequestEvent, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -#[notification(method = "config/event")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcNotification))] +#[cfg_attr(feature = "rpc", notification(method = "config/event"))] #[serde(rename_all = "camelCase")] pub struct ConfigEventNotification { pub cursor: EventCursor, pub event: ConfigUpdate, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -#[notification(method = "app/eventStreamState")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcNotification))] +#[cfg_attr(feature = "rpc", notification(method = "app/eventStreamState"))] #[serde(rename_all = "camelCase")] pub struct EventStreamStateNotification { pub cursor: EventCursor, @@ -84,14 +90,16 @@ pub struct ResyncDirective { pub reason: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "app/syncEvents", response = SyncEventsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "app/syncEvents", response = SyncEventsResponse))] #[serde(rename_all = "camelCase")] pub struct SyncEventsRequest { pub streams: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct SyncEventsResponse { pub cursors: Vec, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs index 458328afb1..daf39e1f06 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs @@ -3,6 +3,7 @@ //! These DTOs reuse the stable product-domain projections. Executable source //! definitions and provider-private configuration never cross this boundary. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; use bitfun_product_domains::external_source_control::{ ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, @@ -18,15 +19,17 @@ use serde::{Deserialize, Serialize}; pub use bitfun_product_domains::external_sources::ExternalSourceConflictPreferences; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalSource/snapshot", response = ExternalSourceSnapshotResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalSource/snapshot", response = ExternalSourceSnapshotResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceSnapshotRequest { pub workspace_path: String, pub force_refresh: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceSnapshotResponse { pub control: ExternalSourceControlSnapshotV1, @@ -34,8 +37,9 @@ pub struct ExternalSourceSnapshotResponse { pub preferences: ExternalSourceConflictPreferences, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] -#[notification(method = "externalSource/event")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcNotification))] +#[cfg_attr(feature = "rpc", notification(method = "externalSource/event"))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceEventNotification { pub cursor: crate::event::EventCursor, @@ -43,23 +47,26 @@ pub struct ExternalSourceEventNotification { pub snapshot: ExternalSourcePublicSnapshot, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalSource/control", response = ExternalSourceControlResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalSource/control", response = ExternalSourceControlResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceControlRequest { pub workspace_path: String, pub request: ExternalSourceControlRequestV1, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceControlResponse { pub surface: ExternalSourceSurfaceSnapshotV1, pub snapshot: ExternalSourceSnapshotResponse, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalSource/review", response = ExternalSourceReviewResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalSource/review", response = ExternalSourceReviewResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceReviewRequest { pub workspace_path: String, @@ -114,13 +121,18 @@ pub enum ExternalSourceReviewAction { }, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ExternalSourceReviewResponse(pub ExternalSourceSnapshotResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request( - method = "externalSource/setNativeCommandChoice", - response = SetNativeCommandChoiceResponse +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr( + feature = "rpc", + request( + method = "externalSource/setNativeCommandChoice", + response = SetNativeCommandChoiceResponse + ) )] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetNativeCommandChoiceRequest { @@ -131,15 +143,17 @@ pub struct SetNativeCommandChoiceRequest { pub expected_preference_revision: u64, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetNativeCommandChoiceResponse { pub conflicts: NativePromptCommandConflictSnapshot, pub preferences: ExternalSourceConflictPreferences, } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalSource/expandCommand", response = ExpandExternalCommandResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalSource/expandCommand", response = ExpandExternalCommandResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExpandExternalCommandRequest { pub workspace_path: String, @@ -178,7 +192,8 @@ impl std::fmt::Debug for ExpandExternalCommandRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ExpandExternalCommandResponse(pub PromptCommandInvocationOutcome); #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/git.rs b/src/crates/interfaces/app-server-protocol/src/schemas/git.rs index 0193895b80..9731c9fbb2 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/git.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/git.rs @@ -1,20 +1,25 @@ //! Git-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "git/isRepository", response = GitIsRepositoryResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "git/isRepository", response = GitIsRepositoryResponse))] pub struct GitIsRepositoryMessage(pub GitRepositoryPathRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GitIsRepositoryResponse(pub bool); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "git/getStatus", response = GitGetStatusResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "git/getStatus", response = GitGetStatusResponse))] pub struct GitGetStatusMessage(pub GitRepositoryPathRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GitGetStatusResponse(pub GitStatus); /// Read-only ownership-trust probe. @@ -23,18 +28,22 @@ pub struct GitGetStatusResponse(pub GitStatus); /// server user's global Git configuration, and a browser client is not the /// machine that owns the repository. The probe is what lets such a client name /// the folder and hand over the exact command instead of a dead end. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "git/getRepositoryTrust", response = GitGetRepositoryTrustResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "git/getRepositoryTrust", response = GitGetRepositoryTrustResponse))] pub struct GitGetRepositoryTrustMessage(pub GitRepositoryPathRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GitGetRepositoryTrustResponse(pub GitTrustReport); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "git/getBranches", response = GitGetBranchesResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "git/getBranches", response = GitGetBranchesResponse))] pub struct GitGetBranchesMessage(pub GitBranchesRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GitGetBranchesResponse { pub branches: Vec, } diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs b/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs index 7788cdfb96..85c9e7de34 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/hook.rs @@ -4,6 +4,7 @@ //! inspection uses a protocol-owned projection so executable commands and host //! filesystem paths do not cross the wire. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use bitfun_product_domains::external_hook_import::{ ExternalHookImportApplyRequestV1, ExternalHookImportApplyResultV1, @@ -16,8 +17,9 @@ pub use bitfun_product_domains::native_hooks::{ NativeHookFileSummary, NativeHookHandlerSummary, NativeHookOverview, NativeHookRuleSummary, }; -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "nativeHook/overview", response = NativeHookOverviewResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "nativeHook/overview", response = NativeHookOverviewResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct NativeHookOverviewRequest { pub workspace_path: String, @@ -32,7 +34,8 @@ impl std::fmt::Debug for NativeHookOverviewRequest { } } -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct NativeHookOverviewResponse(pub NativeHookOverview); impl std::fmt::Debug for NativeHookOverviewResponse { @@ -49,8 +52,9 @@ impl std::fmt::Debug for NativeHookOverviewResponse { } } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalHook/snapshot", response = ExternalHookSnapshotResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalHook/snapshot", response = ExternalHookSnapshotResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookSnapshotRequest { pub workspace_path: String, @@ -67,11 +71,13 @@ impl std::fmt::Debug for ExternalHookSnapshotRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ExternalHookSnapshotResponse(pub ExternalHookImportSnapshotV1); -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalHook/plan", response = ExternalHookPlanResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalHook/plan", response = ExternalHookPlanResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookPlanRequest { pub workspace_path: String, @@ -88,11 +94,13 @@ impl std::fmt::Debug for ExternalHookPlanRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ExternalHookPlanResponse(pub ExternalHookImportPlanV1); -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalHook/apply", response = ExternalHookApplyResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalHook/apply", response = ExternalHookApplyResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookApplyRequest { pub workspace_path: String, @@ -112,11 +120,13 @@ impl std::fmt::Debug for ExternalHookApplyRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ExternalHookApplyResponse(pub ExternalHookImportApplyResultV1); -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "externalHook/mutate", response = ExternalHookMutationResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "externalHook/mutate", response = ExternalHookMutationResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalHookMutationRequest { pub workspace_path: String, @@ -136,7 +146,8 @@ impl std::fmt::Debug for ExternalHookMutationRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ExternalHookMutationResponse(pub ExternalHookImportSnapshotV1); #[cfg(test)] diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/i18n.rs b/src/crates/interfaces/app-server-protocol/src/schemas/i18n.rs index 7948742167..d373ccfa31 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/i18n.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/i18n.rs @@ -1,42 +1,50 @@ //! Internationalization App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "i18n/getCurrentLanguage", response = I18nGetCurrentLanguageResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "i18n/getCurrentLanguage", response = I18nGetCurrentLanguageResponse))] pub struct I18nGetCurrentLanguageMessage {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct I18nGetCurrentLanguageResponse { pub language: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "i18n/setLanguage", response = I18nSetLanguageResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "i18n/setLanguage", response = I18nSetLanguageResponse))] #[serde(rename_all = "camelCase")] pub struct I18nSetLanguageMessage { pub language: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct I18nSetLanguageResponse { pub language: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "i18n/getConfig", response = I18nGetConfigResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "i18n/getConfig", response = I18nGetConfigResponse))] pub struct I18nGetConfigMessage {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct I18nGetConfigResponse { pub current_language: String, pub fallback_language: String, pub auto_detect: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "i18n/setConfig", response = I18nSetConfigResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "i18n/setConfig", response = I18nSetConfigResponse))] #[serde(rename_all = "camelCase")] pub struct I18nSetConfigMessage { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -47,11 +55,13 @@ pub struct I18nSetConfigMessage { pub auto_detect: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct I18nSetConfigResponse {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "i18n/getSupportedLanguages", response = I18nGetSupportedLanguagesResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "i18n/getSupportedLanguages", response = I18nGetSupportedLanguagesResponse))] pub struct I18nGetSupportedLanguagesMessage {} #[derive(Debug, Clone, Serialize, Deserialize)] @@ -64,7 +74,8 @@ pub struct I18nLocaleMetadata { pub rtl: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct I18nGetSupportedLanguagesResponse { pub locales: Vec, } diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs b/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs index 0714805223..46459c788c 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/mcp.rs @@ -3,6 +3,7 @@ //! Mutation payloads can contain credentials and other sensitive values. Their //! custom `Debug` implementations expose only configuration metadata. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; @@ -12,35 +13,40 @@ pub use bitfun_product_domains::mcp::{ macro_rules! unit_response { ($name:ident) => { - #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] + #[derive(Debug, Clone, Serialize, Deserialize)] + #[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct $name {} }; } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "mcp/list", response = ListMcpServersResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "mcp/list", response = ListMcpServersResponse))] #[serde(rename_all = "camelCase")] pub struct ListMcpServersRequest { pub workspace_path: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ListMcpServersResponse { pub servers: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub config_path: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "mcp/toggle", response = ToggleMcpServerResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "mcp/toggle", response = ToggleMcpServerResponse))] pub struct ToggleMcpServerRequest { pub server_id: String, } unit_response!(ToggleMcpServerResponse); -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "mcp/add", response = AddMcpServerResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "mcp/add", response = AddMcpServerResponse))] pub struct AddMcpServerRequest { pub name: String, pub config: McpServerMutation, @@ -58,16 +64,18 @@ impl std::fmt::Debug for AddMcpServerRequest { unit_response!(AddMcpServerResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "mcp/delete", response = DeleteMcpServerResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "mcp/delete", response = DeleteMcpServerResponse))] pub struct DeleteMcpServerRequest { pub server_id: String, } unit_response!(DeleteMcpServerResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "mcp/externalDecision", response = ExternalMcpDecisionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "mcp/externalDecision", response = ExternalMcpDecisionResponse))] #[serde(rename_all = "camelCase")] pub struct ExternalMcpDecisionRequest { pub workspace_path: String, @@ -80,8 +88,9 @@ pub struct ExternalMcpDecisionRequest { unit_response!(ExternalMcpDecisionResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "mcp/conflictChoice", response = McpConflictChoiceResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "mcp/conflictChoice", response = McpConflictChoiceResponse))] #[serde(rename_all = "camelCase")] pub struct McpConflictChoiceRequest { pub workspace_path: String, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/model.rs b/src/crates/interfaces/app-server-protocol/src/schemas/model.rs index 9032e236d4..d034df29e4 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/model.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/model.rs @@ -4,6 +4,7 @@ //! payloads. Secret-bearing values are accepted only by mutation requests and //! are never returned by the server. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use bitfun_core_types::{ ProviderCatalog, ReasoningCatalogProjection, ReasoningCatalogProjectionRequest, @@ -16,16 +17,19 @@ pub use bitfun_core_types::model::{ macro_rules! unit_response { ($name:ident) => { - #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] + #[derive(Debug, Clone, Serialize, Deserialize)] + #[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct $name {} }; } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "model/list", response = ListModelsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "model/list", response = ListModelsResponse))] pub struct ListModelsRequest {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ListModelsResponse { pub models: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -36,42 +40,49 @@ pub struct ListModelsResponse { pub mode_default_model_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "model/get", response = GetModelResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "model/get", response = GetModelResponse))] pub struct GetModelRequest { pub model_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct GetModelResponse { pub model: ModelEditProjection, } /// Provider and reasoning facts needed by model configuration surfaces. /// API keys and provider-specific execution metadata remain host-owned. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "config/getTuiModelCatalog", response = TuiModelCatalogResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "config/getTuiModelCatalog", response = TuiModelCatalogResponse))] pub struct TuiModelCatalogRequest {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct TuiModelCatalogResponse { pub provider_catalog: ProviderCatalog, pub reasoning_presets_by_model: std::collections::BTreeMap>, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "model/projectReasoningCatalog", response = ProjectReasoningCatalogResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "model/projectReasoningCatalog", response = ProjectReasoningCatalogResponse))] #[serde(transparent)] pub struct ProjectReasoningCatalogRequest(pub ReasoningCatalogProjectionRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ProjectReasoningCatalogResponse { pub projection: ReasoningCatalogProjection, } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "model/add", response = AddModelResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "model/add", response = AddModelResponse))] pub struct AddModelRequest { pub model: ModelMutation, #[serde(default)] @@ -90,8 +101,9 @@ impl std::fmt::Debug for AddModelRequest { unit_response!(AddModelResponse); -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "model/update", response = UpdateModelResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "model/update", response = UpdateModelResponse))] pub struct UpdateModelRequest { pub model_id: String, pub model: ModelMutation, @@ -109,16 +121,18 @@ impl std::fmt::Debug for UpdateModelRequest { unit_response!(UpdateModelResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "model/delete", response = DeleteModelResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "model/delete", response = DeleteModelResponse))] pub struct DeleteModelRequest { pub model_id: String, } unit_response!(DeleteModelResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "model/setDefault", response = SetModelDefaultResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "model/setDefault", response = SetModelDefaultResponse))] pub struct SetModelDefaultRequest { pub slot: ModelDefaultSlot, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/permission.rs b/src/crates/interfaces/app-server-protocol/src/schemas/permission.rs index 07855997b4..954947d94d 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/permission.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/permission.rs @@ -1,5 +1,6 @@ //! Permission-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use bitfun_product_domains::tool_permissions::{ PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, @@ -8,14 +9,16 @@ use serde::{Deserialize, Serialize}; pub type RespondPermissionMessage = super::agent::RespondPermissionRequest; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/respondPermissionBatch", response = RespondPermissionBatchResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/respondPermissionBatch", response = RespondPermissionBatchResponse))] pub struct RespondPermissionBatchMessage { pub request_id: String, pub reply: PermissionReply, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct RespondPermissionBatchResponse { pub request_ids: Vec, } @@ -23,46 +26,54 @@ pub struct RespondPermissionBatchResponse { pub type ListPendingPermissionRequestsMessage = super::agent::PendingPermissionsRequest; pub type ListPendingPermissionRequestsResponse = super::agent::PendingPermissionsResponse; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/listProjectPermissionGrants", response = ListProjectPermissionGrantsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/listProjectPermissionGrants", response = ListProjectPermissionGrantsResponse))] pub struct ListProjectPermissionGrantsMessage { pub project_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct ListProjectPermissionGrantsResponse { pub grants: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/removeProjectPermissionGrant", response = RemoveProjectPermissionGrantResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/removeProjectPermissionGrant", response = RemoveProjectPermissionGrantResponse))] pub struct RemoveProjectPermissionGrantMessage(pub PermissionGrantKey); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct RemoveProjectPermissionGrantResponse { pub removed: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/clearProjectPermissionGrants", response = ClearProjectPermissionGrantsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/clearProjectPermissionGrants", response = ClearProjectPermissionGrantsResponse))] pub struct ClearProjectPermissionGrantsMessage { pub project_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ClearProjectPermissionGrantsResponse { pub cleared: usize, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "agent/listProjectPermissionAudit", response = ListProjectPermissionAuditResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "agent/listProjectPermissionAudit", response = ListProjectPermissionAuditResponse))] pub struct ListProjectPermissionAuditMessage { pub project_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ListProjectPermissionAuditResponse { pub records: Vec, } diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/search.rs b/src/crates/interfaces/app-server-protocol/src/schemas/search.rs index 75953bc6af..2e1faa698f 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/search.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/search.rs @@ -1,5 +1,6 @@ //! Product-search App Server wire schema. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; pub use bitfun_product_domains::product_search::{ SessionContentSearchRequest, SessionContentSearchResponse, SessionSearchDiagnostic, @@ -8,9 +9,10 @@ pub use bitfun_product_domains::product_search::{ }; use serde::{Deserialize, Serialize}; -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "search/sessionContent", response = SearchSessionContentResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "search/sessionContent", response = SearchSessionContentResponse)] #[serde(transparent)] pub struct SearchSessionContentMessage(pub SessionContentSearchRequest); @@ -25,7 +27,8 @@ impl std::fmt::Debug for SearchSessionContentMessage { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(transparent)] pub struct SearchSessionContentResponse(pub SessionContentSearchResponse); diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs index 5c0d51180a..3cfa78f820 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs @@ -1,5 +1,6 @@ //! Session-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use bitfun_core_types::SessionUsageReport; use bitfun_product_domains::tool_permissions::PermissionRequest; @@ -18,13 +19,15 @@ use serde::{Deserialize, Serialize}; macro_rules! unit_response { ($name:ident) => { - #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] + #[derive(Debug, Clone, Serialize, Deserialize)] + #[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct $name {} }; } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/sync", response = SyncSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/sync", response = SyncSessionResponse))] #[serde(rename_all = "camelCase")] pub struct SyncSessionRequest { pub workspace_path: String, @@ -37,7 +40,8 @@ pub struct SyncSessionRequest { pub remote_ssh_host: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct SyncSessionResponse { pub session: AgentSessionSummary, @@ -80,9 +84,10 @@ pub enum SessionProcessingPhase { } /// Compatibility request for `session/restore`. -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "session/restore", response = RestoreSessionResponse)] +#[cfg_attr(feature = "rpc", request(method = "session/restore", response = RestoreSessionResponse))] #[serde(rename_all = "camelCase")] pub struct RestoreSessionMessage { pub workspace_path: String, @@ -95,7 +100,8 @@ pub struct RestoreSessionMessage { pub remote_ssh_host: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] pub struct RestoreSessionResponse { @@ -103,104 +109,129 @@ pub struct RestoreSessionResponse { pub state: SessionRuntimeState, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/readTranscript", response = ReadTranscriptResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/readTranscript", response = ReadTranscriptResponse))] pub struct ReadTranscriptRequest(pub SessionTranscriptRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ReadTranscriptResponse(pub SessionTranscript); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/resolveWorkspace", response = ResolveWorkspaceResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/resolveWorkspace", response = ResolveWorkspaceResponse))] pub struct ResolveWorkspaceRequest(pub AgentSessionWorkspaceRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ResolveWorkspaceResponse(pub Option); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/rename", response = RenameSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/rename", response = RenameSessionResponse))] pub struct RenameSessionRequest(pub AgentSessionRenameRequest); unit_response!(RenameSessionResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/compact", response = CompactSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/compact", response = CompactSessionResponse))] pub struct CompactSessionRequest(pub AgentSessionCompactionRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct CompactSessionResponse(pub AgentSessionCompactionResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/undo", response = RevertSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/undo", response = RevertSessionResponse))] pub struct UndoSessionRequest(pub AgentSessionRevertRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/redo", response = RevertSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/redo", response = RevertSessionResponse))] pub struct RedoSessionRequest(pub AgentSessionRevertRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct RevertSessionResponse(pub AgentSessionRevertResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/reloadContext", response = ReloadContextResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/reloadContext", response = ReloadContextResponse))] pub struct ReloadContextRequest(pub AgentContextReloadRequest); unit_response!(ReloadContextResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/usage", response = SessionUsageResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/usage", response = SessionUsageResponse))] pub struct SessionUsageRequest(pub AgentSessionUsageRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SessionUsageResponse(pub SessionUsageReport); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/waitForSettlement", response = WaitForSettlementResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/waitForSettlement", response = WaitForSettlementResponse))] pub struct WaitForSettlementRequest(pub AgentTurnSettlementRequest); unit_response!(WaitForSettlementResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/lineage", response = SessionLineageResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/lineage", response = SessionLineageResponse))] pub struct SessionLineageRequest(pub AgentSessionLineageRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SessionLineageResponse(pub Option); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/inspectLineage", response = InspectLineageResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/inspectLineage", response = InspectLineageResponse))] pub struct InspectLineageRequest(pub AgentSessionLineageTranscriptRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct InspectLineageResponse(pub AgentSessionLineageInspection); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/cancelLineage", response = CancelLineageResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/cancelLineage", response = CancelLineageResponse))] pub struct CancelLineageRequest(pub AgentSessionLineageCancellationRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct CancelLineageResponse(pub AgentTurnCancellationResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/fork", response = ForkSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/fork", response = ForkSessionResponse))] pub struct ForkSessionRequest(pub AgentSessionForkRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/forkBeforeTurn", response = ForkSessionResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/forkBeforeTurn", response = ForkSessionResponse))] pub struct ForkSessionBeforeTurnRequest(pub AgentSessionForkBeforeTurnRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct ForkSessionResponse(pub AgentSessionForkResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/updateModel", response = UpdateSessionModelResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/updateModel", response = UpdateSessionModelResponse))] pub struct UpdateSessionModelRequest(pub AgentSessionModelUpdateRequest); unit_response!(UpdateSessionModelResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/updateMode", response = UpdateSessionModeResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "session/updateMode", response = UpdateSessionModeResponse))] pub struct UpdateSessionModeRequest(pub AgentSessionModeUpdateRequest); unit_response!(UpdateSessionModeResponse); @@ -211,14 +242,16 @@ pub use RenameSessionRequest as RenameSessionMessage; pub use UpdateSessionModeRequest as UpdateSessionModeMessage; pub use UpdateSessionModelRequest as UpdateSessionModelMessage; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "session/setArchived", response = SetSessionArchivedResponse)] +#[cfg_attr(feature = "rpc", request(method = "session/setArchived", response = SetSessionArchivedResponse))] pub struct SetSessionArchivedMessage(pub AgentSessionArchiveStateRequest); unit_response!(SetSessionArchivedResponse); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[request(method = "session/forkAtTurn", response = ForkSessionResponse)] +#[cfg_attr(feature = "rpc", request(method = "session/forkAtTurn", response = ForkSessionResponse))] pub struct ForkSessionAtTurnMessage(pub AgentSessionForkAtTurnRequest); diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs b/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs index 67ed39c8df..7d3779bb8a 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/skill.rs @@ -1,12 +1,14 @@ //! Skill-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; pub use bitfun_product_domains::agent_catalog::SkillSummary; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "skill/list", response = ListSkillsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "skill/list", response = ListSkillsResponse))] #[serde(rename_all = "camelCase")] pub struct ListSkillsRequest { pub workspace_path: String, @@ -15,13 +17,15 @@ pub struct ListSkillsRequest { pub manageable: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ListSkillsResponse { pub skills: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "skill/setEnabled", response = SetSkillEnabledResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "skill/setEnabled", response = SetSkillEnabledResponse))] #[serde(rename_all = "camelCase")] pub struct SetSkillEnabledRequest { pub workspace_path: String, @@ -32,5 +36,6 @@ pub struct SetSkillEnabledRequest { pub level: String, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SetSkillEnabledResponse {} diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs b/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs index 32fa29e65e..4373d9124e 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/subagent.rs @@ -1,12 +1,14 @@ //! Subagent-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; pub use bitfun_product_domains::agent_catalog::SubagentSummary; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "subagent/list", response = ListSubagentsResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "subagent/list", response = ListSubagentsResponse))] #[serde(rename_all = "camelCase")] pub struct ListSubagentsRequest { pub workspace_path: String, @@ -15,15 +17,17 @@ pub struct ListSubagentsRequest { pub management: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct ListSubagentsResponse { pub subagents: Vec, #[serde(default)] pub has_external: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "subagent/setEnabled", response = SetSubagentEnabledResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "subagent/setEnabled", response = SetSubagentEnabledResponse))] #[serde(rename_all = "camelCase")] pub struct SetSubagentEnabledRequest { pub workspace_path: String, @@ -32,5 +36,6 @@ pub struct SetSubagentEnabledRequest { pub enabled: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SetSubagentEnabledResponse {} diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/workspace.rs b/src/crates/interfaces/app-server-protocol/src/schemas/workspace.rs index ef0937137e..96c27c2f7a 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/workspace.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/workspace.rs @@ -1,5 +1,6 @@ //! Workspace-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use bitfun_runtime_ports::{ AgentMessageWorkspaceReferencesRequest, AgentWorkspaceReference, @@ -8,23 +9,29 @@ use bitfun_runtime_ports::{ }; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "workspace/diff", response = WorkspaceDiffResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "workspace/diff", response = WorkspaceDiffResponse))] pub struct WorkspaceDiffRequest {} -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct WorkspaceDiffResponse(pub WorkspaceDiffSnapshot); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "workspace/searchReferences", response = SearchWorkspaceReferencesResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "workspace/searchReferences", response = SearchWorkspaceReferencesResponse))] pub struct SearchWorkspaceReferencesRequest(pub AgentWorkspaceReferenceSearchRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct SearchWorkspaceReferencesResponse(pub AgentWorkspaceReferenceSearchResult); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "workspace/messageReferences", response = MessageReferencesResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "workspace/messageReferences", response = MessageReferencesResponse))] pub struct MessageReferencesRequest(pub AgentMessageWorkspaceReferencesRequest); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] pub struct MessageReferencesResponse(pub Vec); diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs b/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs index 2834fbac5a..4c6d972397 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/worktree.rs @@ -1,5 +1,6 @@ //! Worktree-domain App Server wire schemas. +#[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; pub use bitfun_core_types::WorktreeErrorCode; use bitfun_runtime_ports::AgentSessionWorkspaceBinding; @@ -7,8 +8,9 @@ use serde::{Deserialize, Serialize}; use crate::error::AppServerErrorData; -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "worktree/repositoryStatus", response = WorktreeRepositoryStatusResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "worktree/repositoryStatus", response = WorktreeRepositoryStatusResponse))] #[serde(rename_all = "camelCase")] pub struct WorktreeRepositoryStatusRequest { pub workspace_path: String, @@ -34,7 +36,8 @@ impl WorktreeRepositoryStatusRequest { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct WorktreeRepositoryStatusResponse { pub is_repository: bool, @@ -42,8 +45,9 @@ pub struct WorktreeRepositoryStatusResponse { pub current_branch: Option, } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "worktree/bindSession", response = WorktreeBindingResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "worktree/bindSession", response = WorktreeBindingResponse))] #[serde(rename_all = "camelCase")] pub struct WorktreeBindSessionRequest { pub operation_id: String, @@ -74,8 +78,9 @@ impl WorktreeBindSessionRequest { } } -#[derive(Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "worktree/releaseSession", response = WorktreeBindingResponse)] +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "worktree/releaseSession", response = WorktreeBindingResponse))] #[serde(rename_all = "camelCase")] pub struct WorktreeReleaseSessionRequest { pub operation_id: String, @@ -106,7 +111,8 @@ impl WorktreeReleaseSessionRequest { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonRpcResponse)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] pub struct WorktreeBindingResponse { pub workspace_binding: AgentSessionWorkspaceBinding, diff --git a/src/crates/interfaces/app-server-protocol/tests/legacy_wire_contracts.rs b/src/crates/interfaces/app-server-protocol/tests/legacy_wire_contracts.rs index bcbbe5bf10..f04c13c14a 100644 --- a/src/crates/interfaces/app-server-protocol/tests/legacy_wire_contracts.rs +++ b/src/crates/interfaces/app-server-protocol/tests/legacy_wire_contracts.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "rpc")] use agent_client_protocol::JsonRpcMessage; use bitfun_app_server_protocol::{ agent::{RunResponse, SubmitDialogTurnMessage, SubmitDialogTurnRequest}, @@ -8,7 +9,7 @@ use bitfun_app_server_protocol::{ use serde_json::json; #[test] -fn legacy_submit_dialog_contract_keeps_optional_policy_and_method() { +fn legacy_submit_dialog_contract_keeps_optional_policy() { let request: SubmitDialogTurnRequest = serde_json::from_value(json!({ "sessionId": "session-1", "message": "hello", @@ -20,15 +21,20 @@ fn legacy_submit_dialog_contract_keeps_optional_policy_and_method() { assert!(body.policy.is_none()); assert!(body.attachments.is_empty()); assert!(body.metadata.is_empty()); - assert!(SubmitDialogTurnMessage::matches_method( - "agent/submitDialogTurn" - )); assert_eq!( std::any::TypeId::of::(), std::any::TypeId::of::() ); } +#[cfg(feature = "rpc")] +#[test] +fn legacy_submit_dialog_method_stays_compatible() { + assert!(SubmitDialogTurnMessage::matches_method( + "agent/submitDialogTurn" + )); +} + #[test] fn legacy_restore_and_session_state_keep_their_wire_shape() { let request: RestoreSessionMessage = serde_json::from_value(json!({ diff --git a/src/crates/interfaces/app-server/AGENTS-CN.md b/src/crates/interfaces/app-server/AGENTS-CN.md index c8ab0b4cae..248aa75783 100644 --- a/src/crates/interfaces/app-server/AGENTS-CN.md +++ b/src/crates/interfaces/app-server/AGENTS-CN.md @@ -20,6 +20,9 @@ protocol/client crate。 `bitfun-app-server/ts` 只保留为兼容转发 feature。protocol crate 是唯一的 TypeScript schema 导出 owner;不要把 `ts-rs`、Runtime 实现类型或第二条导出命令重新加入本 crate。 +protocol wire DTO 与 serde 合同不启用 feature 也必须可用。protocol crate 默认的 `rpc` +feature 负责绑定 ACP JSON-RPC trait 并暴露 role/transport helper,以保持兼容;`ts` 必须与 +它正交,不得启用 `rpc` 或 `agent-client-protocol`。 ## 护栏 diff --git a/src/crates/interfaces/app-server/AGENTS.md b/src/crates/interfaces/app-server/AGENTS.md index 6078559e7b..6b7368d0bd 100644 --- a/src/crates/interfaces/app-server/AGENTS.md +++ b/src/crates/interfaces/app-server/AGENTS.md @@ -23,6 +23,10 @@ and client crates. The `bitfun-app-server/ts` feature is a compatibility forwarder only. The protocol crate is the sole TypeScript schema exporter; do not add `ts-rs`, runtime implementation types, or a second export command back to this crate. +Protocol wire DTOs and serde contracts remain available with no feature. The +protocol crate's default `rpc` feature attaches ACP JSON-RPC traits and exposes +its roles and transport helpers for compatibility; `ts` must remain orthogonal +and must not enable `rpc` or `agent-client-protocol`. ## Guardrails diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml index 974e606628..0f8c35f507 100644 --- a/src/crates/interfaces/app-server/Cargo.toml +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -18,6 +18,7 @@ bitfun-core = { path = "../../assembly/core", features = ["external-sources", "g bitfun-agent-runtime = { path = "../../execution/agent-runtime", features = ["agent-runtime"] } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["external-sources"] } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["agent-api", "product-search"] } +dunce = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/interfaces/app-server/src/lib.rs b/src/crates/interfaces/app-server/src/lib.rs index 929a4da7cb..5ae6ae43ae 100644 --- a/src/crates/interfaces/app-server/src/lib.rs +++ b/src/crates/interfaces/app-server/src/lib.rs @@ -65,6 +65,10 @@ pub use management::{ NATIVE_HOOKS_CAPABILITY, }; pub use role::{AppClient, AppServer}; +pub use server::host_policy::{ + AppServerDisconnect, AppServerHostLimits, AppServerHostPolicy, HostPolicyViolation, + DEFAULT_EVENT_BUFFER_CAPACITY, DEFAULT_MAX_FRAME_BYTES, +}; pub use server::BitfunAppServer; /// Convenience prelude for consumers building an app-server connection. diff --git a/src/crates/interfaces/app-server/src/server.rs b/src/crates/interfaces/app-server/src/server.rs index eadc91a7cf..83384b0820 100644 --- a/src/crates/interfaces/app-server/src/server.rs +++ b/src/crates/interfaces/app-server/src/server.rs @@ -7,6 +7,7 @@ mod event_forwarder; mod fallback; mod handlers; +pub mod host_policy; mod wire; use std::sync::atomic::{AtomicU64, Ordering}; @@ -82,10 +83,21 @@ impl ConnectionEventState { } /// BitFun agent kernel server over the generic app-server role. +/// +/// Hosts may inject an [`AppServerHostPolicy`] (identity + canonical +/// workspace scope + method allowlist, enforced fail-closed as the first +/// connection layer), the [`AppServerHostLimits`] advertised and enforced by +/// the Host transport, and a disconnect [`host_policy::AppServerDisconnect`] signal that ends +/// event forwarding and returns `serve` when the transport observes EOF or a +/// transport failure. Hosts that inject none of them keep the pre-existing +/// open surface. #[derive(Clone)] pub struct BitfunAppServer { runtime: Arc, management: Option>, + host_policy: Option>, + host_limits: host_policy::AppServerHostLimits, + disconnect: Option>, } impl BitfunAppServer { @@ -93,6 +105,9 @@ impl BitfunAppServer { Self { runtime: Arc::new(runtime), management: None, + host_policy: None, + host_limits: host_policy::AppServerHostLimits::default(), + disconnect: None, } } @@ -101,6 +116,29 @@ impl BitfunAppServer { self } + /// Inject the Host-owned connection policy (canonical workspace scope, + /// identity, and the explicit method allowlist). Every request and + /// notification is checked against it before any domain handler runs. + pub fn with_host_policy(mut self, policy: host_policy::AppServerHostPolicy) -> Self { + self.host_policy = Some(Arc::new(policy)); + self + } + + /// Select the transport limits advertised in `app/initialize`. The Host + /// is responsible for enforcing them at its transport reader. + pub fn with_host_limits(mut self, limits: host_policy::AppServerHostLimits) -> Self { + self.host_limits = limits; + self + } + + /// Observe a Host transport disconnect (for example stdin EOF). When the + /// signal fires, event forwarding stops and `serve` returns so the Host + /// can run its deterministic disconnect lifecycle. + pub fn with_disconnect(mut self, disconnect: Arc) -> Self { + self.disconnect = Some(disconnect); + self + } + /// Return the shared runtime used by this server. pub fn runtime(&self) -> &BitfunAppRuntime { &self.runtime @@ -110,15 +148,21 @@ impl BitfunAppServer { pub async fn serve(self, transport: impl ConnectTo + 'static) -> Result<()> { let runtime = self.runtime; let management = self.management; + let host_policy = self.host_policy; + let host_limits = self.host_limits; + let disconnect = self.disconnect; let event_state = Arc::new(ConnectionEventState::new()); AppServer .builder() .name("bitfun-app-server") + .with_connection_builder(host_policy::builder(host_policy.clone())) .with_connection_builder(handlers::app::builder( runtime.clone(), event_state.clone(), management.clone(), + host_policy, + host_limits, )) .with_connection_builder(handlers::agent::builder( runtime.clone(), @@ -141,7 +185,7 @@ impl BitfunAppServer { .with_connection_builder(handlers::i18n::builder()) .with_connection_builder(fallback::builder()) .connect_with(transport, async move |cx: ConnectionTo| { - event_forwarder::run(runtime, management, cx, event_state).await + event_forwarder::run(runtime, management, cx, event_state, disconnect).await }) .await } diff --git a/src/crates/interfaces/app-server/src/server/event_forwarder.rs b/src/crates/interfaces/app-server/src/server/event_forwarder.rs index 42c3c5bc59..ae878a8084 100644 --- a/src/crates/interfaces/app-server/src/server/event_forwarder.rs +++ b/src/crates/interfaces/app-server/src/server/event_forwarder.rs @@ -15,6 +15,7 @@ pub(super) async fn run( management: Option>, cx: ConnectionTo, event_state: Arc, + disconnect: Option>, ) -> Result<()> { let mut rx = runtime.event_source().subscribe(); let mut permission_rx = runtime.runtime().subscribe_permission_requests().ok(); @@ -68,6 +69,17 @@ pub(super) async fn run( } }; tokio::select! { + _ = async { + match &disconnect { + Some(disconnect) => disconnect.wait().await, + None => std::future::pending::<()>().await, + } + } => { + log::info!( + "App-server connection closed -- stopping event forwarding so the Host can run its disconnect lifecycle" + ); + break; + } recv = rx.recv() => match recv { Ok(envelope) => { let notification = SessionEventNotification { diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs index bf95d895b8..4ed490df6c 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/app.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -10,17 +10,21 @@ use bitfun_product_domains::product_search::PRODUCT_SEARCH_CAPABILITY_ID; use crate::management::EXTERNAL_SOURCES_CAPABILITY; use crate::role::{AppClient, AppServer}; - -const MAX_FRAME_BYTES: u64 = 16 * 1024 * 1024; -const EVENT_BUFFER_CAPACITY: u32 = 1024; +use crate::server::host_policy::{AppServerHostLimits, AppServerHostPolicy}; pub(in crate::server) fn builder( runtime: std::sync::Arc, event_state: std::sync::Arc, management: Option>, + host_policy: Option>, + limits: AppServerHostLimits, ) -> Builder> { - let capabilities = - registered_capabilities(runtime.product_search().is_some(), management.as_deref()); + let capabilities = registered_capabilities( + runtime.product_search().is_some(), + management.as_deref(), + runtime.context_reload().is_some(), + host_policy.as_deref(), + ); let external_source_snapshot_available = capabilities.iter().any(|capability| { capability.id == EXTERNAL_SOURCES_CAPABILITY && matches!(capability.availability, CapabilityAvailability::Available) @@ -51,8 +55,8 @@ pub(in crate::server) fn builder( }, capabilities.clone(), TransportLimits { - max_frame_bytes: MAX_FRAME_BYTES, - event_buffer_capacity: EVENT_BUFFER_CAPACITY, + max_frame_bytes: limits.max_frame_bytes, + event_buffer_capacity: limits.event_buffer_capacity, }, ))) }, @@ -92,8 +96,11 @@ pub(in crate::server) fn builder( fn registered_capabilities( product_search_available: bool, management: Option<&crate::management::AppManagementService>, + context_reload_available: bool, + host_policy: Option<&AppServerHostPolicy>, ) -> Vec { - let mut capabilities = [ + let mut capabilities = Vec::new(); + for (id, methods) in [ ( "agent", vec![ @@ -194,45 +201,67 @@ fn registered_capabilities( ], ), ("eventSync", vec!["app/syncEvents", "app/eventStreamState"]), - ] - .into_iter() - .map(|(id, methods)| CapabilityDescriptor { - id: id.to_string(), - availability: CapabilityAvailability::Available, - methods: methods.into_iter().map(str::to_string).collect(), - }) - .collect::>(); - capabilities.push(CapabilityDescriptor { - id: PRODUCT_SEARCH_CAPABILITY_ID.to_string(), - availability: if product_search_available { - CapabilityAvailability::Available - } else { - CapabilityAvailability::Unavailable { - reason: "The Host did not provide product search".to_string(), - } - }, - methods: vec!["search/sessionContent".to_string()], - }); - capabilities.extend( - management - .map(|service| service.capabilities()) - .unwrap_or_else(|| { - crate::management::AppManagementCapabilities::unavailable( - "The Host did not provide management owners", - ) - }) - .descriptors(), - ); + ] { + let mut methods = methods; + if !context_reload_available { + methods.retain(|method| *method != "session/reloadContext"); + } + if let Some(host_policy) = host_policy { + methods.retain(|method| host_policy.allows(method)); + } + if methods.is_empty() { + continue; + } + capabilities.push(CapabilityDescriptor { + id: id.to_string(), + availability: CapabilityAvailability::Available, + methods: methods.into_iter().map(str::to_string).collect(), + }); + } + const PRODUCT_SEARCH_METHOD: &str = "search/sessionContent"; + if host_policy + .map(|policy| policy.allows(PRODUCT_SEARCH_METHOD)) + .unwrap_or(true) + { + capabilities.push(CapabilityDescriptor { + id: PRODUCT_SEARCH_CAPABILITY_ID.to_string(), + availability: if product_search_available { + CapabilityAvailability::Available + } else { + CapabilityAvailability::Unavailable { + reason: "The Host did not provide product search".to_string(), + } + }, + methods: vec![PRODUCT_SEARCH_METHOD.to_string()], + }); + } + let management_capabilities = management + .map(|service| service.capabilities()) + .unwrap_or_else(|| { + crate::management::AppManagementCapabilities::unavailable( + "The Host did not provide management owners", + ) + }); + for mut descriptor in management_capabilities.descriptors() { + if let Some(host_policy) = host_policy { + descriptor + .methods + .retain(|method| host_policy.allows(method)); + } + if descriptor.methods.is_empty() { + continue; + } + capabilities.push(descriptor); + } capabilities } - #[cfg(test)] mod tests { use super::*; #[test] fn missing_host_management_service_declares_capabilities_unavailable() { - let capabilities = registered_capabilities(false, None); + let capabilities = registered_capabilities(false, None, true, None); for id in [ "tui.modes", "tui.models", @@ -258,7 +287,7 @@ mod tests { #[test] fn product_search_capability_reflects_the_injected_port() { for (available, expected_available) in [(false, false), (true, true)] { - let capabilities = registered_capabilities(available, None); + let capabilities = registered_capabilities(available, None, true, None); let search = capabilities .iter() .find(|capability| capability.id == PRODUCT_SEARCH_CAPABILITY_ID) @@ -270,4 +299,19 @@ mod tests { assert_eq!(search.methods, vec!["search/sessionContent"]); } } + + #[test] + fn host_policy_hides_product_search_when_method_is_not_allowed() { + let policy = AppServerHostPolicy::new( + "test-host", + std::env::temp_dir(), + ["app/initialize"], + ) + .expect("build host policy"); + let capabilities = registered_capabilities(true, None, true, Some(&policy)); + + assert!(capabilities + .iter() + .all(|capability| capability.id != PRODUCT_SEARCH_CAPABILITY_ID)); + } } diff --git a/src/crates/interfaces/app-server/src/server/host_policy.rs b/src/crates/interfaces/app-server/src/server/host_policy.rs new file mode 100644 index 0000000000..7415003b87 --- /dev/null +++ b/src/crates/interfaces/app-server/src/server/host_policy.rs @@ -0,0 +1,558 @@ +//! Host-injected connection policy: identity, canonical workspace scope, +//! transport limits, and the explicit method allowlist enforced before any +//! request reaches a domain handler. +//! +//! The policy carries no product behavior: the Host constructs it from its own +//! scope and limits, and the server only enforces it fail-closed. Hosts that +//! do not inject a policy keep the pre-existing open surface. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use agent_client_protocol::{Builder, ConnectionTo, Dispatch, Error, HandleDispatchFrom, Handled}; +use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; +use serde_json::Value; + +use crate::role::{AppClient, AppServer}; + +/// Default transport limits advertised by hosts that do not inject their own. +pub const DEFAULT_MAX_FRAME_BYTES: u64 = 16 * 1024 * 1024; +pub const DEFAULT_EVENT_BUFFER_CAPACITY: u32 = 1024; + +/// Host-transport disconnect signal shared by the Host reader and the server +/// event forwarder. +/// +/// The stored flag makes the signal loss-free: the Host transport may observe +/// EOF before the event forwarder registers its waiter, and `wait` still +/// returns immediately in that case. +#[derive(Debug, Default)] +pub struct AppServerDisconnect { + signaled: std::sync::atomic::AtomicBool, + notify: tokio::sync::Notify, +} + +impl AppServerDisconnect { + /// Mark the transport as disconnected and wake any waiters. + pub fn signal(&self) { + self.signaled + .store(true, std::sync::atomic::Ordering::Release); + self.notify.notify_waiters(); + } + + pub fn is_signaled(&self) -> bool { + self.signaled.load(std::sync::atomic::Ordering::Acquire) + } + + /// Wait until the transport signals a disconnect. Returns immediately if + /// the signal was already raised. + pub async fn wait(&self) { + loop { + let notified = self.notify.notified(); + if self.is_signaled() { + return; + } + notified.await; + } + } +} + +/// Host-selected transport limits advertised in `app/initialize` and enforced +/// by the Host transport where it owns the reader. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppServerHostLimits { + pub max_frame_bytes: u64, + pub event_buffer_capacity: u32, +} + +impl AppServerHostLimits { + /// Limits for the local stdio Server Host (`bitfun server`). + pub const fn local_stdio() -> Self { + Self { + max_frame_bytes: DEFAULT_MAX_FRAME_BYTES, + event_buffer_capacity: DEFAULT_EVENT_BUFFER_CAPACITY, + } + } +} + +impl Default for AppServerHostLimits { + fn default() -> Self { + Self::local_stdio() + } +} + +/// Wire keys that carry filesystem paths in App Server requests. +const PATH_KEYS: &[&str] = &[ + "workspacePath", + "workspace_path", + "repositoryPath", + "repository_path", + "projectWorkspacePath", + "project_workspace_path", +]; + +/// Wire keys that request remote execution, which a local Host cannot honor. +const REMOTE_KEYS: &[&str] = &[ + "remoteConnectionId", + "remote_connection_id", + "remoteSshHost", + "remote_ssh_host", +]; + +/// Immutable Host identity, canonical workspace scope, and method allowlist +/// for one App Server deployment. +#[derive(Debug, Clone)] +pub struct AppServerHostPolicy { + identity: String, + workspace_root: PathBuf, + allowed_methods: BTreeSet, +} + +/// Fail-closed violation reported before a request reaches a domain owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostPolicyViolation { + MethodNotAllowed { method: String }, + PathOutsideWorkspace { method: String, path: String }, + RemoteExecutionNotSupported { method: String }, + NonLocalExecutionTarget { method: String, kind: String }, +} + +impl HostPolicyViolation { + pub fn method(&self) -> &str { + match self { + Self::MethodNotAllowed { method } + | Self::PathOutsideWorkspace { method, .. } + | Self::RemoteExecutionNotSupported { method } + | Self::NonLocalExecutionTarget { method, .. } => method, + } + } + + /// Stable machine-readable reason surfaced in the wire error data. + pub fn reason(&self) -> &'static str { + match self { + Self::MethodNotAllowed { .. } => "method_not_allowed_by_host_policy", + Self::PathOutsideWorkspace { .. } => "path_outside_workspace_scope", + Self::RemoteExecutionNotSupported { .. } => "remote_execution_not_supported", + Self::NonLocalExecutionTarget { .. } => "non_local_execution_target", + } + } +} + +impl std::fmt::Display for HostPolicyViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MethodNotAllowed { method } => { + write!(f, "method `{method}` is not allowed by the Host policy") + } + Self::PathOutsideWorkspace { method, path } => write!( + f, + "method `{method}` carries a path outside the Host workspace scope: {path}" + ), + Self::RemoteExecutionNotSupported { method } => write!( + f, + "method `{method}` requests remote execution, which this Host does not provide" + ), + Self::NonLocalExecutionTarget { method, kind } => write!( + f, + "method `{method}` requests execution target kind `{kind}`, which this Host does not provide" + ), + } + } +} + +impl AppServerHostPolicy { + /// Build a policy from a Host-selected canonical workspace root and the + /// explicit list of methods this Host serves. Everything not listed here + /// fails closed. + pub fn new( + identity: impl Into, + workspace_root: impl AsRef, + allowed_methods: impl IntoIterator>, + ) -> Result { + let workspace_root = dunce::canonicalize(workspace_root.as_ref())?; + Ok(Self { + identity: identity.into(), + workspace_root, + allowed_methods: allowed_methods.into_iter().map(Into::into).collect(), + }) + } + + pub fn identity(&self) -> &str { + &self.identity + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn allows(&self, method: &str) -> bool { + self.allowed_methods.contains(method) + } + + pub fn allowed_methods(&self) -> impl Iterator { + self.allowed_methods.iter().map(String::as_str) + } + + /// Fail-closed check for one incoming message. + pub fn check(&self, method: &str, params: &Value) -> Result<(), HostPolicyViolation> { + if !self.allows(method) { + return Err(HostPolicyViolation::MethodNotAllowed { + method: method.to_string(), + }); + } + self.validate_scope(method, params) + } + + fn validate_scope(&self, method: &str, params: &Value) -> Result<(), HostPolicyViolation> { + match params { + Value::Object(map) => { + for (key, value) in map { + if PATH_KEYS.contains(&key.as_str()) { + self.validate_path(method, key, value)?; + } + if REMOTE_KEYS.contains(&key.as_str()) && !is_empty_string(value) { + return Err(HostPolicyViolation::RemoteExecutionNotSupported { + method: method.to_string(), + }); + } + if matches!(key.as_str(), "executionTarget" | "execution_target") + && !is_local_execution_target(value) + { + return Err(HostPolicyViolation::NonLocalExecutionTarget { + method: method.to_string(), + kind: value + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + }); + } + self.validate_scope(method, value)?; + } + } + Value::Array(items) => { + for item in items { + self.validate_scope(method, item)?; + } + } + _ => {} + } + Ok(()) + } + + fn validate_path( + &self, + method: &str, + key: &str, + value: &Value, + ) -> Result<(), HostPolicyViolation> { + let outside = |path: String| HostPolicyViolation::PathOutsideWorkspace { + method: method.to_string(), + path, + }; + let Some(path) = value.as_str() else { + return Err(outside(format!("{key}: {value}"))); + }; + let candidate = Path::new(path); + if !candidate.is_absolute() { + return Err(outside(path.to_string())); + } + let Ok(canonical) = dunce::canonicalize(candidate) else { + return Err(outside(path.to_string())); + }; + if canonical != self.workspace_root && !canonical.starts_with(&self.workspace_root) { + return Err(outside(path.to_string())); + } + Ok(()) + } +} + +/// Map a policy violation to the JSON-RPC error the client receives. +/// +/// Method denial is `method_not_found` (the Host does not serve it); scope and +/// execution-target violations are `invalid_params` because the method exists +/// but the supplied arguments leave the Host's execution domain. The wire +/// `data` carries the stable `AppServerErrorData` shape plus the machine +/// readable `reason` and the denied `method`. +pub fn violation_error(violation: &HostPolicyViolation) -> Error { + let (error, kind) = match violation { + HostPolicyViolation::MethodNotAllowed { .. } => { + (Error::method_not_found(), AppServerErrorKind::Unsupported) + } + HostPolicyViolation::PathOutsideWorkspace { .. } => { + (Error::invalid_params(), AppServerErrorKind::InvalidRequest) + } + HostPolicyViolation::RemoteExecutionNotSupported { .. } + | HostPolicyViolation::NonLocalExecutionTarget { .. } => { + (Error::invalid_params(), AppServerErrorKind::Unsupported) + } + }; + let mut data = serde_json::to_value(AppServerErrorData { + kind, + retryable: false, + outcome_unknown: false, + capability: None, + request_id: None, + }) + .unwrap_or(Value::Null); + if let Some(object) = data.as_object_mut() { + object.insert( + "reason".to_string(), + Value::String(violation.reason().to_string()), + ); + object.insert( + "method".to_string(), + Value::String(violation.method().to_string()), + ); + } + error.data(data) +} + +/// First connection-builder layer: the Host policy guard. +/// +/// Every incoming request and notification is checked fail-closed before any +/// domain handler sees it. Denied requests receive a typed error response, +/// denied notifications receive an error notification, and responses pass +/// through untouched. Without a Host policy the guard passes everything to the +/// existing open surface. +pub(super) fn builder( + policy: Option>, +) -> Builder> { + AppServer + .builder() + .name("host policy guard") + .on_receive_dispatch( + async move |message: Dispatch, cx: ConnectionTo| { + let Some(policy) = policy.as_ref() else { + return Ok(Handled::No { + message, + retry: false, + }); + }; + match message { + Dispatch::Request(request, responder) => { + match policy.check(request.method(), request.params()) { + Ok(()) => Ok(Handled::No { + message: Dispatch::Request(request, responder), + retry: false, + }), + Err(violation) => { + tracing::warn!( + method = violation.method(), + reason = violation.reason(), + "App-server host policy denied request" + ); + responder.respond_with_error(violation_error(&violation))?; + Ok(Handled::Yes) + } + } + } + Dispatch::Notification(notification) => { + match policy.check(notification.method(), notification.params()) { + Ok(()) => Ok(Handled::No { + message: Dispatch::Notification(notification), + retry: false, + }), + Err(violation) => { + tracing::warn!( + method = violation.method(), + reason = violation.reason(), + "App-server host policy denied notification" + ); + cx.send_error_notification(violation_error(&violation))?; + Ok(Handled::Yes) + } + } + } + Dispatch::Response(result, router) => Ok(Handled::No { + message: Dispatch::Response(result, router), + retry: false, + }), + } + }, + agent_client_protocol::on_receive_dispatch!(), + ) +} + +fn is_empty_string(value: &Value) -> bool { + match value { + Value::String(text) => text.is_empty(), + Value::Null => true, + _ => false, + } +} + +fn is_local_execution_target(value: &Value) -> bool { + match value.get("kind").and_then(Value::as_str) { + None => true, + Some(kind) => kind == "local", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy(root: &Path) -> AppServerHostPolicy { + AppServerHostPolicy::new( + "test-host", + root, + ["app/initialize", "git/isRepository", "agent/createSession"], + ) + .expect("build test policy") + } + + fn outside_path(root: &Path) -> PathBuf { + let candidate = root.parent().expect("root parent").join("outside.txt"); + std::fs::write(&candidate, "x").expect("write outside file"); + candidate + } + + #[test] + fn unlisted_methods_fail_closed() { + let root = std::env::temp_dir(); + let policy = policy(&root); + let violation = policy + .check("config/setConfig", &serde_json::json!({})) + .expect_err("unlisted method must be denied"); + assert_eq!( + violation, + HostPolicyViolation::MethodNotAllowed { + method: "config/setConfig".to_string() + } + ); + } + + #[test] + fn paths_must_canonicalize_inside_the_workspace_root() { + let root = std::env::temp_dir(); + let policy = policy(&root); + let outside = outside_path(&root); + let violation = policy + .check( + "git/isRepository", + &serde_json::json!({ "repositoryPath": outside.to_string_lossy() }), + ) + .expect_err("path outside the workspace must be denied"); + assert_eq!( + violation, + HostPolicyViolation::PathOutsideWorkspace { + method: "git/isRepository".to_string(), + path: outside.to_string_lossy().to_string(), + } + ); + + policy + .check( + "git/isRepository", + &serde_json::json!({ "repositoryPath": root.to_string_lossy() }), + ) + .expect("workspace root itself is in scope"); + } + + #[test] + fn nested_and_relative_paths_fail_closed() { + let root = std::env::temp_dir(); + let policy = policy(&root); + let violation = policy + .check( + "agent/createSession", + &serde_json::json!({ "workspacePath": "relative/workspace" }), + ) + .expect_err("relative paths must be denied"); + assert!(matches!( + violation, + HostPolicyViolation::PathOutsideWorkspace { .. } + )); + + let outside = outside_path(&root); + let violation = policy + .check( + "agent/createSession", + &serde_json::json!({ + "executionTarget": { "kind": "local" }, + "nested": { "projectWorkspacePath": outside.to_string_lossy() }, + }), + ) + .expect_err("nested path keys must be checked"); + assert!(matches!( + violation, + HostPolicyViolation::PathOutsideWorkspace { .. } + )); + } + + #[test] + fn remote_and_non_local_execution_fail_closed() { + let root = std::env::temp_dir(); + let policy = policy(&root); + let violation = policy + .check( + "agent/createSession", + &serde_json::json!({ "remoteConnectionId": "peer-1" }), + ) + .expect_err("remote connections must be denied"); + assert_eq!( + violation, + HostPolicyViolation::RemoteExecutionNotSupported { + method: "agent/createSession".to_string() + } + ); + + let violation = policy + .check( + "agent/createSession", + &serde_json::json!({ "executionTarget": { "kind": "managedWorktree" } }), + ) + .expect_err("non-local execution targets must be denied"); + assert_eq!( + violation, + HostPolicyViolation::NonLocalExecutionTarget { + method: "agent/createSession".to_string(), + kind: "managedWorktree".to_string(), + } + ); + + policy + .check( + "agent/createSession", + &serde_json::json!({ + "executionTarget": { "kind": "local" }, + "remoteConnectionId": null, + "remoteSshHost": "", + }), + ) + .expect("local execution without remote fields is in scope"); + } + + #[test] + fn violation_error_carries_stable_kind_and_reason() { + let violation = HostPolicyViolation::PathOutsideWorkspace { + method: "git/isRepository".to_string(), + path: "/outside".to_string(), + }; + let error = violation_error(&violation); + assert_eq!(error.code, agent_client_protocol::ErrorCode::InvalidParams); + let data = error + .data + .expect("policy violation error must carry structured data"); + assert_eq!(data["kind"], Value::String("invalid_request".to_string())); + assert_eq!( + data["reason"], + Value::String("path_outside_workspace_scope".to_string()) + ); + assert_eq!( + data["method"], + Value::String("git/isRepository".to_string()) + ); + + let violation = HostPolicyViolation::MethodNotAllowed { + method: "config/setConfig".to_string(), + }; + let error = violation_error(&violation); + assert_eq!(error.code, agent_client_protocol::ErrorCode::MethodNotFound); + assert_eq!( + error.data.expect("data")["kind"], + Value::String("unsupported".to_string()) + ); + } +} diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index 6f709c74c3..9f359b2d3e 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -1,20 +1,23 @@ //! Connection-scoped SDK Host request and Query lifecycle. use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use bitfun_agent_runtime::sdk::{ - AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSubmissionSource, AgentTransientSessionDiscardRequest, AgentTurnCancellationRequest, - AgentTurnSettlementRequest, DialogSubmissionPolicy, DialogSubmitOutcome, PermissionReply, - PermissionReplySource, PermissionRequest, PermissionRequestEvent, PortErrorKind, RuntimeError, - AUTO_APPROVE_ASK_CONTEXT_KEY, + AgentDialogTurnRequest, AgentInputAttachment, AgentRuntime, AgentSessionCreateRequest, + AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionModelUpdateRequest, + AgentSessionReleaseRequest, AgentSessionRestoreRequest, AgentSessionWorkspaceRequest, + AgentSubmissionSource, AgentTurnCancellationRequest, AgentTurnSettlementRequest, + DialogSubmissionPolicy, DialogSubmitOutcome, PermissionReply, PermissionReplySource, + PermissionRequest, PermissionRequestEvent, PermissionRequestSourceKind, PortError, + PortErrorKind, RuntimeError, TurnTokenUsage, AUTO_APPROVE_ASK_CONTEXT_KEY, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_core_types::ErrorCategory; -use bitfun_events::AgenticEvent; +use bitfun_events::{AgenticEvent, ToolEventData}; use futures_util::{stream::FuturesUnordered, FutureExt, StreamExt}; use tokio::sync::{mpsc, oneshot, Mutex, OwnedSemaphorePermit, Semaphore}; use tokio::task::JoinHandle; @@ -24,20 +27,25 @@ use tokio_util::sync::CancellationToken; use crate::protocol::{ ErrorCode, ErrorData, ErrorStage, InitializeParams, InitializeResult, JsonRpcErrorResponse, JsonRpcNotification, JsonRpcRequest, JsonRpcSuccessResponse, OutcomeCertainty, - QueryCancelParams, QueryCancelResult, QueryEvent, QueryEventParams, QueryOutput, - QueryResultError, QueryResultParams, QueryStartParams, QueryStartResult, QueryTerminalStatus, - RecoveryAction, RequestId, SessionCloseParams, SessionCloseResult, SessionCreateParams, - SessionCreateResult, SessionLifetime, ShutdownParams, ShutdownResult, JSON_RPC_VERSION, - METHOD_INITIALIZE, METHOD_QUERY_CANCEL, METHOD_QUERY_START, METHOD_SESSION_CLOSE, - METHOD_SESSION_CREATE, METHOD_SHUTDOWN, NOTIFICATION_QUERY_EVENT, NOTIFICATION_QUERY_RESULT, - PROTOCOL_VERSION, + PermissionDecision, PermissionRespondParams, PermissionRespondResult, PermissionSource, + PermissionSourceKind, QueryCancelParams, QueryCancelResult, QueryEvent, QueryEventParams, + QueryOutput, QueryResultError, QueryResultParams, QueryStartParams, QueryStartResult, + QueryTerminalStatus, QueryUsage, RecoveryAction, RequestId, SessionCloseParams, + SessionCloseResult, SessionCreateParams, SessionCreateResult, SessionLifetime, + SessionResumeParams, ShutdownParams, ShutdownResult, TemporaryModelConfig, ToolEventStatus, + JSON_RPC_VERSION, METHOD_INITIALIZE, METHOD_PERMISSION_RESPOND, METHOD_QUERY_CANCEL, + METHOD_QUERY_START, METHOD_SESSION_CLOSE, METHOD_SESSION_CREATE, METHOD_SESSION_RESUME, + METHOD_SHUTDOWN, NOTIFICATION_QUERY_EVENT, NOTIFICATION_QUERY_RESULT, PROTOCOL_VERSION, }; const DEFAULT_SESSION_NAME: &str = "BitFun SDK query"; const DEFAULT_AGENT: &str = "agentic"; const DEFAULT_TURN_SETTLEMENT_TIMEOUT_MS: u64 = 5_000; const PERMISSION_REJECTION_TIMEOUT_MS: u64 = 2_000; +const DEFAULT_PERMISSION_RESPONSE_TIMEOUT_MS: u64 = 120_000; +const MAX_PERMISSION_FEEDBACK_BYTES: usize = 4 * 1024; const MAX_SESSION_CLOSE_TIMEOUT_MS: u64 = 30_000; +const MAX_SESSION_PUBLICATION_WAIT_MS: u64 = 500; const MAX_QUERY_OUTPUT_WIRE_BYTES: usize = 768 * 1024; fn json_string_content_bytes(value: &str) -> usize { @@ -59,6 +67,7 @@ pub struct SdkHostConfig { pub max_in_flight_control_requests: usize, pub max_active_queries: usize, pub max_leased_sessions: usize, + pub permission_response_timeout: Duration, } impl Default for SdkHostConfig { @@ -68,6 +77,9 @@ impl Default for SdkHostConfig { max_in_flight_control_requests: 4, max_active_queries: 16, max_leased_sessions: 64, + permission_response_timeout: Duration::from_millis( + DEFAULT_PERMISSION_RESPONSE_TIMEOUT_MS, + ), } } } @@ -82,6 +94,22 @@ pub trait HostOutput: Send + Sync { async fn send(&self, value: serde_json::Value) -> Result<(), ()>; } +#[async_trait::async_trait] +pub trait TemporaryModelInstaller: Send + Sync { + async fn install( + &self, + model: TemporaryModelConfig, + ) -> Result; + async fn remove(&self, model_id: &str); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemporaryModelInstallError { + InvalidModel, + InvalidBaseUrl, + Internal, +} + struct ChannelHostOutput(mpsc::Sender); #[async_trait::async_trait] @@ -96,28 +124,42 @@ struct ConnectionInner { runtime_version: &'static str, default_cwd: String, output: Arc, + temporary_model_installer: Arc, state: Arc>, request_budget: Arc, control_request_budget: Arc, query_budget: Arc, session_budget: Arc, + permission_response_timeout: Duration, shutdown_started: CancellationToken, connection_failed: CancellationToken, } #[derive(Default)] struct ConnectionState { - initialized: bool, + initialization: InitializationState, + model_id: Option, + permission_responses: bool, shutting_down: bool, cleanup_failed: bool, sessions: HashMap, queries: HashMap>, + attaching_sessions: HashSet, + publishing_sessions: HashSet, starting_query_sessions: HashSet, active_query_sessions: HashSet, closing_sessions: HashSet, poisoned_sessions: HashSet, pending_session_tasks: Vec, - untracked_transient_cleanups: HashMap, + untracked_session_cleanups: HashMap, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum InitializationState { + #[default] + Uninitialized, + Installing, + Initialized, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -132,18 +174,60 @@ struct SessionLease { remote_connection_id: Option, remote_ssh_host: Option, exposed: bool, + lifetime: SessionLifetime, + unexposed_release: SessionReleaseKind, _budget: Arc, } struct PendingSessionTask { - transient_cleanup: Option, + session_cleanup: Option, + reservation: Option, task: JoinHandle<()>, } +enum SessionTaskReservation { + Attaching(String), + Publishing(String), +} + +fn release_session_task_reservation( + state: &mut ConnectionState, + reservation: &SessionTaskReservation, +) { + match reservation { + SessionTaskReservation::Attaching(session_id) => { + state.attaching_sessions.remove(session_id); + } + SessionTaskReservation::Publishing(session_id) => { + state.publishing_sessions.remove(session_id); + } + } +} + #[derive(Clone)] -struct TransientSessionCleanup { +struct SessionCleanup { session_id: String, workspace_path: String, + release_kind: SessionReleaseKind, +} + +#[derive(Clone, Copy)] +enum SessionReleaseKind { + DiscardTransient, + UnloadPersisted, + DeletePersisted, +} + +impl SessionLease { + fn release_kind(&self) -> SessionReleaseKind { + if !self.exposed { + return self.unexposed_release; + } + match self.lifetime { + SessionLifetime::Connection => SessionReleaseKind::DiscardTransient, + SessionLifetime::Durable => SessionReleaseKind::UnloadPersisted, + } + } } struct QueryLease { @@ -152,9 +236,11 @@ struct QueryLease { turn_id: String, operation_id: String, output: StdMutex, + usage: StdMutex>, terminal: AtomicBool, stop_forwarding: CancellationToken, emit_output: bool, + pending_permissions: StdMutex>, _budget: OwnedSemaphorePermit, } @@ -176,12 +262,14 @@ impl SdkHostConnection { default_cwd: impl Into, output: mpsc::Sender, config: SdkHostConfig, + temporary_model_installer: Arc, ) -> Self { Self::with_output( runtime, default_cwd, Arc::new(ChannelHostOutput(output)), config, + temporary_model_installer, ) } @@ -190,6 +278,7 @@ impl SdkHostConnection { default_cwd: impl Into, output: Arc, config: SdkHostConfig, + temporary_model_installer: Arc, ) -> Self { Self { inner: Arc::new(ConnectionInner { @@ -197,6 +286,7 @@ impl SdkHostConnection { runtime_version: env!("CARGO_PKG_VERSION"), default_cwd: default_cwd.into(), output, + temporary_model_installer, state: Arc::new(Mutex::new(ConnectionState::default())), request_budget: Arc::new(Semaphore::new(config.max_in_flight_requests.max(1))), control_request_budget: Arc::new(Semaphore::new( @@ -204,6 +294,7 @@ impl SdkHostConnection { )), query_budget: Arc::new(Semaphore::new(config.max_active_queries.max(1))), session_budget: Arc::new(Semaphore::new(config.max_leased_sessions.max(1))), + permission_response_timeout: config.permission_response_timeout, shutdown_started: CancellationToken::new(), connection_failed: CancellationToken::new(), }), @@ -249,7 +340,7 @@ impl SdkHostConnection { } else { let budget = if matches!( request.method.as_str(), - METHOD_QUERY_CANCEL | METHOD_SESSION_CLOSE + METHOD_QUERY_CANCEL | METHOD_PERMISSION_RESPOND | METHOD_SESSION_CLOSE ) { self.inner.control_request_budget.clone() } else { @@ -278,9 +369,9 @@ impl SdkHostConnection { let (initialized, shutting_down, cleanup_failed) = { let state = self.inner.state.lock().await; ( - state.initialized, + state.initialization == InitializationState::Initialized, state.shutting_down, - state.cleanup_failed || !state.untracked_transient_cleanups.is_empty(), + state.cleanup_failed || !state.untracked_session_cleanups.is_empty(), ) }; if !initialized { @@ -322,8 +413,10 @@ impl SdkHostConnection { match request.method.as_str() { METHOD_SESSION_CREATE => self.handle_session_create(request).await, + METHOD_SESSION_RESUME => self.handle_session_resume(request).await, METHOD_QUERY_START => self.handle_query_start(request).await, METHOD_QUERY_CANCEL => self.handle_query_cancel(request).await, + METHOD_PERMISSION_RESPOND => self.handle_permission_respond(request).await, METHOD_SESSION_CLOSE => self.handle_session_close(request).await, METHOD_SHUTDOWN => { if self @@ -376,7 +469,7 @@ impl SdkHostConnection { /// Reports whether this connection has completed the required initialize /// handshake so a transport can serialize re-initialization safely. pub async fn is_initialized(&self) -> bool { - self.inner.state.lock().await.initialized + self.inner.state.lock().await.initialization == InitializationState::Initialized } async fn reap_finished_pending_session_tasks(&self) { @@ -387,6 +480,9 @@ impl SdkHostConnection { active.push(pending); continue; } + if let Some(reservation) = &pending.reservation { + release_session_task_reservation(&mut state, reservation); + } match (&mut pending.task).now_or_never() { Some(Ok(())) => {} Some(Err(error)) => { @@ -394,9 +490,9 @@ impl SdkHostConnection { error = %error, "SDK Host Session ownership task failed" ); - if let Some(cleanup) = pending.transient_cleanup { + if let Some(cleanup) = pending.session_cleanup { state - .untracked_transient_cleanups + .untracked_session_cleanups .insert(cleanup.session_id.clone(), cleanup); } } @@ -410,7 +506,7 @@ impl SdkHostConnection { self.shutdown_connection_inner(None).await; } - /// Shuts down one connection without allowing a transient Session create + /// Shuts down one connection without allowing a Session ownership /// task to keep the Host process alive indefinitely. pub async fn shutdown_connection_bounded(&self, total_timeout: Duration) -> bool { self.shutdown_connection_inner(Some(total_timeout)).await @@ -434,10 +530,7 @@ impl SdkHostConnection { self.settle_pending_session_task(pending, graceful_deadline) .await; } - if !self - .compensate_registered_transient_sessions(deadline) - .await - { + if !self.compensate_registered_sessions(deadline).await { cleanup_complete = false; } let (queries, sessions) = { @@ -503,7 +596,7 @@ impl SdkHostConnection { let mut cleanup = sessions .into_iter() .map(|(session_id, session)| { - let runtime = self.inner.runtime.clone(); + let connection = self.clone(); let session_cleanup_timeout = deadline .map(|deadline| { deadline @@ -513,18 +606,20 @@ impl SdkHostConnection { .unwrap_or(Duration::from_millis(5_500)); async move { let reported_session_id = session_id.clone(); + let release_kind = session.release_kind(); let cleanup = async move { - runtime - .discard_transient_session(AgentTransientSessionDiscardRequest { - workspace_path: session.workspace_path, - session_id: session_id.clone(), - remote_connection_id: session.remote_connection_id, - remote_ssh_host: session.remote_ssh_host, - wait_timeout_ms: duration_ms( + connection + .release_runtime_session( + session_id.clone(), + session.workspace_path, + session.remote_connection_id, + session.remote_ssh_host, + release_kind, + duration_ms( session_cleanup_timeout .saturating_sub(Duration::from_millis(500)), ), - }) + ) .await?; Ok(()) }; @@ -555,6 +650,17 @@ impl SdkHostConnection { } } } + let model_id = { + let mut state = self.inner.state.lock().await; + let model_id = state.model_id.take(); + if model_id.is_some() { + state.initialization = InitializationState::Uninitialized; + } + model_id + }; + if let Some(model_id) = model_id { + self.inner.temporary_model_installer.remove(&model_id).await; + } cleanup_complete } @@ -564,7 +670,8 @@ impl SdkHostConnection { wait_deadline: Option, ) { let PendingSessionTask { - transient_cleanup, + session_cleanup, + reservation, mut task, } = pending; let completed = if task.is_finished() { @@ -586,39 +693,40 @@ impl SdkHostConnection { error = %error, "SDK Host Session ownership task failed" ); - if let Some(cleanup) = transient_cleanup { - self.register_untracked_transient_cleanup(cleanup).await; + if let Some(cleanup) = session_cleanup { + self.register_untracked_session_cleanup(cleanup).await; } } None => { task.abort(); let _ = task.await; - if let Some(cleanup) = transient_cleanup { - self.register_untracked_transient_cleanup(cleanup).await; + if let Some(cleanup) = session_cleanup { + self.register_untracked_session_cleanup(cleanup).await; } } } + if let Some(reservation) = reservation { + let mut state = self.inner.state.lock().await; + release_session_task_reservation(&mut state, &reservation); + } } - async fn register_untracked_transient_cleanup(&self, cleanup: TransientSessionCleanup) { + async fn register_untracked_session_cleanup(&self, cleanup: SessionCleanup) { self.inner .state .lock() .await - .untracked_transient_cleanups + .untracked_session_cleanups .insert(cleanup.session_id.clone(), cleanup); } - async fn compensate_registered_transient_sessions( - &self, - cleanup_deadline: Option, - ) -> bool { + async fn compensate_registered_sessions(&self, cleanup_deadline: Option) -> bool { let cleanups = self .inner .state .lock() .await - .untracked_transient_cleanups + .untracked_session_cleanups .values() .cloned() .collect::>(); @@ -629,7 +737,7 @@ impl SdkHostConnection { async move { let session_id = cleanup.session_id.clone(); let completed = connection - .compensate_untracked_transient_session(cleanup, cleanup_deadline) + .compensate_untracked_session(cleanup, cleanup_deadline) .await; (session_id, completed) } @@ -642,7 +750,7 @@ impl SdkHostConnection { .state .lock() .await - .untracked_transient_cleanups + .untracked_session_cleanups .remove(&session_id); } else { cleanup_complete = false; @@ -651,9 +759,9 @@ impl SdkHostConnection { cleanup_complete } - async fn compensate_untracked_transient_session( + async fn compensate_untracked_session( &self, - cleanup: TransientSessionCleanup, + cleanup: SessionCleanup, cleanup_deadline: Option, ) -> bool { let cleanup_timeout = cleanup_deadline @@ -661,17 +769,14 @@ impl SdkHostConnection { .unwrap_or(Duration::from_secs(5)); let result = timeout( cleanup_timeout, - self.inner - .runtime - .discard_transient_session(AgentTransientSessionDiscardRequest { - workspace_path: cleanup.workspace_path, - session_id: cleanup.session_id.clone(), - remote_connection_id: None, - remote_ssh_host: None, - wait_timeout_ms: duration_ms( - cleanup_timeout.saturating_sub(Duration::from_millis(500)), - ), - }), + self.release_runtime_session( + cleanup.session_id.clone(), + cleanup.workspace_path, + None, + None, + cleanup.release_kind, + duration_ms(cleanup_timeout.saturating_sub(Duration::from_millis(500))), + ), ) .await; match result { @@ -684,20 +789,68 @@ impl SdkHostConnection { tracing::warn!( session_id = %cleanup.session_id, error_kind = runtime_error_kind(&error), - "Failed to compensate an untracked transient SDK Host Session" + "Failed to compensate an untracked SDK Host Session" ); false } Err(_) => { tracing::warn!( session_id = %cleanup.session_id, - "Transient SDK Host Session compensation timed out" + "SDK Host Session compensation timed out" ); false } } } + async fn release_runtime_session( + &self, + session_id: String, + workspace_path: String, + remote_connection_id: Option, + remote_ssh_host: Option, + release_kind: SessionReleaseKind, + wait_timeout_ms: u64, + ) -> Result { + match release_kind { + SessionReleaseKind::DiscardTransient => { + self.inner + .runtime + .discard_transient_session(AgentSessionReleaseRequest { + workspace_path, + session_id, + remote_connection_id, + remote_ssh_host, + wait_timeout_ms, + }) + .await + } + SessionReleaseKind::UnloadPersisted => { + self.inner + .runtime + .unload_persisted_session(AgentSessionReleaseRequest { + workspace_path, + session_id, + remote_connection_id, + remote_ssh_host, + wait_timeout_ms, + }) + .await + } + SessionReleaseKind::DeletePersisted => self + .inner + .runtime + .delete_session(AgentSessionDeleteRequest { + workspace_path, + session_id, + remote_connection_id, + remote_ssh_host, + }) + .await + .map(|_| true), + } + } + async fn handle_initialize(&self, request: JsonRpcRequest) { let Some(params) = self .parse_params::(&request, ErrorStage::Initialize) @@ -729,28 +882,100 @@ impl SdkHostConnection { .await; return; } - { + let permission_responses = params.capabilities.permission_responses; + let initialization_error = { let mut state = self.inner.state.lock().await; - if state.initialized { + if state.shutting_down { + Some((ErrorCode::Cancelled, "SDK Host connection is shutting down")) + } else if state.initialization != InitializationState::Uninitialized { + Some(( + ErrorCode::AlreadyInitialized, + "SDK Host connection is already initialized", + )) + } else { + state.initialization = InitializationState::Installing; + None + } + }; + if let Some((code, message)) = initialization_error { + self.send_error( + request.id.clone(), + code, + ErrorStage::Initialize, + false, + None, + message, + ) + .await; + return; + } + let model_id = match self + .inner + .temporary_model_installer + .install(params.model) + .await + { + Ok(model_id) => model_id, + Err(error) => { + let mut state = self.inner.state.lock().await; + if state.initialization == InitializationState::Installing { + state.initialization = InitializationState::Uninitialized; + } drop(state); + let (code, message) = match error { + TemporaryModelInstallError::InvalidModel => ( + ErrorCode::InvalidRequest, + "model provider, model, and apiKey are required", + ), + TemporaryModelInstallError::InvalidBaseUrl => ( + ErrorCode::InvalidRequest, + "baseUrl must be an absolute http or https URL without credentials, query, or fragment", + ), + TemporaryModelInstallError::Internal => ( + ErrorCode::Internal, + "SDK Host could not install the temporary model", + ), + }; self.send_error( request.id.clone(), - ErrorCode::AlreadyInitialized, + code, ErrorStage::Initialize, false, None, - "SDK Host connection is already initialized", + message, ) .await; return; } - state.initialized = true; + }; + let remove_after_shutdown = { + let mut state = self.inner.state.lock().await; + if state.shutting_down { + state.initialization = InitializationState::Uninitialized; + true + } else { + state.model_id = Some(model_id.clone()); + state.permission_responses = permission_responses; + state.initialization = InitializationState::Initialized; + false + } + }; + if remove_after_shutdown { + self.inner.temporary_model_installer.remove(&model_id).await; + self.send_error( + request.id.clone(), + ErrorCode::Cancelled, + ErrorStage::Initialize, + false, + None, + "SDK Host connection is shutting down", + ) + .await; + return; } - self.send_success( - request.id.clone(), - InitializeResult::current(self.inner.runtime_version), - ) - .await; + let mut result = InitializeResult::current(self.inner.runtime_version, model_id); + result.capabilities.permission_responses = permission_responses; + self.send_success(request.id.clone(), result).await; } async fn handle_session_create(&self, request: JsonRpcRequest) { @@ -773,6 +998,7 @@ impl SdkHostConnection { return; }; let workspace_path = params.cwd.unwrap_or_else(|| self.inner.default_cwd.clone()); + let model_id = self.inner.state.lock().await.model_id.clone(); let result = self .create_leased_session( AgentSessionCreateRequest { @@ -786,18 +1012,24 @@ impl SdkHostConnection { workspace_id: None, remote_connection_id: None, remote_ssh_host: None, - model_id: params.model, + model_id, metadata: serde_json::Map::new(), }, workspace_path, session_budget, + SessionLifetime::Durable, ) .await; match result { Ok(created) => { let session_id = created.session_id.clone(); - self.deliver_session_create_response(request.id.clone(), created, session_id) - .await; + self.deliver_session_create_response( + request.id.clone(), + created, + session_id, + SessionLifetime::Durable, + ) + .await; } Err(error) => { self.send_runtime_error(request.id.clone(), ErrorStage::Session, error) @@ -806,6 +1038,69 @@ impl SdkHostConnection { } } + async fn handle_session_resume(&self, request: JsonRpcRequest) { + let Some(params) = self + .parse_params::(&request, ErrorStage::Session) + .await + else { + return; + }; + if params.session_id.trim().is_empty() { + self.send_invalid_params( + request.id.clone(), + ErrorStage::Session, + "sessionId must not be empty", + ) + .await; + return; + } + let Ok(session_budget) = self.inner.session_budget.clone().try_acquire_owned() else { + self.send_error( + request.id.clone(), + ErrorCode::Overloaded, + ErrorStage::Session, + true, + Some(RecoveryAction::Retry), + "SDK Host Session capacity is exhausted", + ) + .await; + return; + }; + let workspace_path = self.inner.default_cwd.clone(); + let Some(model_id) = self.inner.state.lock().await.model_id.clone() else { + self.send_runtime_error( + request.id.clone(), + ErrorStage::Session, + PortError::new( + PortErrorKind::NotAvailable, + "SDK Host model is not initialized", + ) + .into(), + ) + .await; + return; + }; + let session_id = params.session_id; + match self + .restore_leased_session(session_id.clone(), workspace_path, model_id, session_budget) + .await + { + Ok(restored) => { + self.deliver_session_create_response( + request.id.clone(), + restored, + session_id, + SessionLifetime::Durable, + ) + .await; + } + Err(error) => { + self.send_runtime_error(request.id.clone(), ErrorStage::Session, error) + .await; + } + } + } + async fn handle_query_start(&self, request: JsonRpcRequest) { let emit_output = request.id.is_some(); let Some(params) = self @@ -814,11 +1109,20 @@ impl SdkHostConnection { else { return; }; - if params.prompt.trim().is_empty() { + if params.prompt.trim().is_empty() && params.images.is_empty() { + self.send_invalid_params( + request.id.clone(), + ErrorStage::Query, + "prompt and images must not both be empty", + ) + .await; + return; + } + if params.images.iter().any(|path| !is_local_image_path(path)) { self.send_invalid_params( request.id.clone(), ErrorStage::Query, - "prompt must not be empty", + "images must contain non-empty local paths with png, jpg, jpeg, gif, or webp extensions", ) .await; return; @@ -895,6 +1199,7 @@ impl SdkHostConnection { .cwd .clone() .unwrap_or_else(|| self.inner.default_cwd.clone()); + let model_id = self.inner.state.lock().await.model_id.clone(); match self .create_leased_session( AgentSessionCreateRequest { @@ -912,11 +1217,12 @@ impl SdkHostConnection { workspace_id: None, remote_connection_id: None, remote_ssh_host: None, - model_id: params.model.clone(), + model_id, metadata: serde_json::Map::new(), }, workspace_path, session_budget, + SessionLifetime::Connection, ) .await { @@ -936,7 +1242,7 @@ impl SdkHostConnection { let session = match self.reserve_query_session(&session_id).await { Ok(session) => session, Err(reservation_error) => { - if created_session && self.delete_unexposed_session(&session_id).await.is_err() { + if created_session && self.release_unexposed_session(&session_id).await.is_err() { self.send_cleanup_required(request.id.clone(), ErrorStage::Query, &session_id) .await; return; @@ -965,13 +1271,13 @@ impl SdkHostConnection { return; } }; - let session_lifetime = SessionLifetime::Connection; + let session_lifetime = session.lifetime; let mut events = match self.inner.runtime.subscribe_session_events(&session_id) { Ok(events) => events, Err(error) => { self.release_query_session(&session_id).await; - if created_session && self.delete_unexposed_session(&session_id).await.is_err() { + if created_session && self.release_unexposed_session(&session_id).await.is_err() { self.send_cleanup_required(request.id.clone(), ErrorStage::Query, &session_id) .await; return; @@ -985,7 +1291,7 @@ impl SdkHostConnection { Ok(events) => events, Err(error) => { self.release_query_session(&session_id).await; - if created_session && self.delete_unexposed_session(&session_id).await.is_err() { + if created_session && self.release_unexposed_session(&session_id).await.is_err() { self.send_cleanup_required(request.id.clone(), ErrorStage::Query, &session_id) .await; return; @@ -1004,6 +1310,11 @@ impl SdkHostConnection { AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), serde_json::Value::Bool(false), ); + let attachments = params + .images + .iter() + .map(|image_path| local_image_attachment(image_path, &session.workspace_path)) + .collect(); let submitted = match self .inner .runtime @@ -1020,7 +1331,7 @@ impl SdkHostConnection { policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::SdkHost), reply_route: None, prepended_reminders: Vec::new(), - attachments: Vec::new(), + attachments, metadata: submission_metadata, }) .await @@ -1028,7 +1339,7 @@ impl SdkHostConnection { Ok(outcome) => outcome, Err(error) => { self.release_query_session(&session_id).await; - if created_session && self.delete_unexposed_session(&session_id).await.is_err() { + if created_session && self.release_unexposed_session(&session_id).await.is_err() { self.send_cleanup_required(request.id.clone(), ErrorStage::Query, &session_id) .await; return; @@ -1056,9 +1367,11 @@ impl SdkHostConnection { turn_id: turn_id.clone(), operation_id: operation_id.clone(), output: StdMutex::new(QueryOutputBuffer::default()), + usage: StdMutex::new(None), terminal: AtomicBool::new(false), stop_forwarding: CancellationToken::new(), emit_output, + pending_permissions: StdMutex::new(HashMap::new()), _budget: query_budget, }); { @@ -1109,16 +1422,33 @@ impl SdkHostConnection { &lease, ) => { - connection.reject_permission_and_finish(&lease, &request).await; - return; + if !connection + .forward_permission_request(&lease, &request, &mut sequence) + .await + { + return; + } + continue; + } + Ok(PermissionRequestEvent::Replied { request_id, .. }) + | Ok(PermissionRequestEvent::Cancelled { request_id, .. }) => { + if let Some(timeout_cancel) = lease + .pending_permissions + .lock() + .expect("SDK Host pending permission lock poisoned") + .remove(&request_id) + { + timeout_cancel.cancel(); + } + continue; } Ok(_) => continue, Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { match connection.inner.runtime.pending_permission_requests() { Ok(pending) => { - if let Some(request) = pending + let pending = pending .into_iter() - .find(|request| { + .filter(|request| { permission_request_targets_query( request, connection @@ -1133,9 +1463,36 @@ impl SdkHostConnection { &lease, ) }) + .collect::>(); + let authoritative = pending + .iter() + .map(|request| request.request_id.as_str()) + .collect::>(); { - connection.reject_permission_and_finish(&lease, &request).await; - return; + let mut tracked = lease + .pending_permissions + .lock() + .expect("SDK Host pending permission lock poisoned"); + tracked.retain(|request_id, timeout_cancel| { + let keep = authoritative + .contains(request_id.as_str()); + if !keep { + timeout_cancel.cancel(); + } + keep + }); + } + for request in pending { + if !connection + .forward_permission_request( + &lease, + &request, + &mut sequence, + ) + .await + { + return; + } } continue; } @@ -1204,41 +1561,50 @@ impl SdkHostConnection { if event_turn_id(&envelope.event) != Some(lease.turn_id.as_str()) { continue; } + TurnTokenUsage::accumulate_event( + &mut lease + .usage + .lock() + .expect("SDK Host Query usage lock poisoned"), + &envelope.event, + &lease.turn_id, + ); let terminal = terminal_fact(&envelope.event, &lease.turn_id, &lease.query_id); if lease.emit_output { if let Some(projected) = project_query_event(&envelope.event) { - let QueryEvent::AssistantTextDelta { text } = &projected; - let output_exceeded = { - let encoded_bytes = json_string_content_bytes(text); - let mut output = lease - .output - .lock() - .expect("SDK Host Query output lock poisoned"); - if encoded_bytes - > MAX_QUERY_OUTPUT_WIRE_BYTES.saturating_sub(output.wire_bytes) - { - true - } else { - output.text.push_str(text); - output.wire_bytes += encoded_bytes; - false + if let QueryEvent::AssistantTextDelta { text } = &projected { + let output_exceeded = { + let encoded_bytes = json_string_content_bytes(text); + let mut output = lease + .output + .lock() + .expect("SDK Host Query output lock poisoned"); + if encoded_bytes + > MAX_QUERY_OUTPUT_WIRE_BYTES.saturating_sub(output.wire_bytes) + { + true + } else { + output.text.push_str(text); + output.wire_bytes += encoded_bytes; + false + } + }; + if output_exceeded { + connection + .cancel_and_finish( + &lease, + QueryResultError::new( + ErrorCode::Overloaded, + false, + None, + &lease.query_id, + "SDK Host Query output exceeded the protocol size limit", + ), + true, + ) + .await; + return; } - }; - if output_exceeded { - connection - .cancel_and_finish( - &lease, - QueryResultError::new( - ErrorCode::Overloaded, - false, - None, - &lease.query_id, - "SDK Host Query output exceeded the protocol size limit", - ), - true, - ) - .await; - return; } sequence += 1; if !connection @@ -1374,60 +1740,247 @@ impl SdkHostConnection { } } - async fn handle_session_close(&self, request: JsonRpcRequest) { - let Some(params) = self - .parse_params::(&request, ErrorStage::Session) + async fn handle_permission_respond(&self, request: JsonRpcRequest) { + let Some(mut params) = self + .parse_params::(&request, ErrorStage::Query) .await else { return; }; + if params.decision != PermissionDecision::Reject && params.feedback.is_some() { + self.send_invalid_params( + request.id.clone(), + ErrorStage::Query, + "feedback is only valid when rejecting a permission request", + ) + .await; + return; + } if params - .wait_timeout_ms - .is_some_and(|timeout| timeout == 0 || timeout > MAX_SESSION_CLOSE_TIMEOUT_MS) + .feedback + .as_ref() + .is_some_and(|feedback| feedback.len() > MAX_PERMISSION_FEEDBACK_BYTES) { self.send_invalid_params( request.id.clone(), - ErrorStage::Session, - "waitTimeoutMs must be between 1 and 30000", + ErrorStage::Query, + "Permission rejection feedback exceeds the size limit", ) .await; return; } - let session = { - let mut state = self.inner.state.lock().await; - if state.closing_sessions.contains(¶ms.session_id) { - drop(state); - self.send_error( - request.id.clone(), - ErrorCode::Overloaded, - ErrorStage::Session, - true, - Some(RecoveryAction::Retry), - "Session close is already in progress", - ) - .await; - return; - } - if state.starting_query_sessions.contains(¶ms.session_id) { - drop(state); - self.send_error( - request.id.clone(), - ErrorCode::Overloaded, - ErrorStage::Session, - true, - Some(RecoveryAction::Retry), - "Session has a Query start in progress", - ) - .await; - return; - } - let session = state.sessions.get(¶ms.session_id).cloned(); - if session.is_some() { - state.closing_sessions.insert(params.session_id.clone()); - } - session + params.feedback = params + .feedback + .map(|feedback| feedback.trim().to_string()) + .filter(|feedback| !feedback.is_empty()); + let (permission_responses, lease) = { + let state = self.inner.state.lock().await; + ( + state.permission_responses, + state.queries.get(¶ms.query_id).cloned(), + ) }; - let Some(session) = session else { + if !permission_responses { + self.send_error( + request.id.clone(), + ErrorCode::CapabilityUnavailable, + ErrorStage::Query, + false, + None, + "permission responses were not negotiated for this connection", + ) + .await; + return; + } + let Some(lease) = lease else { + self.send_error( + request.id.clone(), + ErrorCode::NotFound, + ErrorStage::Query, + false, + None, + "Query is not active on this SDK Host connection", + ) + .await; + return; + }; + if lease.session_id != params.session_id + || lease.turn_id != params.turn_id + || lease.operation_id != params.operation_id + { + self.send_invalid_params( + request.id.clone(), + ErrorStage::Query, + "Permission response identity does not match the owning Query", + ) + .await; + return; + } + let timeout_cancel = lease + .pending_permissions + .lock() + .expect("SDK Host pending permission lock poisoned") + .remove(¶ms.request_id); + let Some(timeout_cancel) = timeout_cancel else { + self.send_error( + request.id.clone(), + ErrorCode::NotFound, + ErrorStage::Query, + false, + None, + "Permission request is unknown, expired, or already answered", + ) + .await; + return; + }; + timeout_cancel.cancel(); + let reply = match params.decision { + PermissionDecision::AllowOnce => PermissionReply::Once, + PermissionDecision::AllowAlways => PermissionReply::Always, + PermissionDecision::Reject => PermissionReply::Reject { + feedback: params.feedback, + }, + }; + match timeout( + Duration::from_millis(PERMISSION_REJECTION_TIMEOUT_MS), + self.inner.runtime.respond_permission_with_source( + ¶ms.request_id, + reply, + PermissionReplySource::User, + ), + ) + .await + { + Ok(Ok(())) => { + self.send_success( + request.id.clone(), + PermissionRespondResult { + request_id: params.request_id, + accepted: true, + }, + ) + .await; + } + Ok(Err(error)) => { + self.cancel_and_finish( + &lease, + query_error_from_runtime( + &lease.query_id, + error, + "SDK Host permission response failed", + ), + true, + ) + .await; + self.send_error( + request.id.clone(), + ErrorCode::Internal, + ErrorStage::Query, + false, + None, + "Permission response failed and the Query was cancelled", + ) + .await; + } + Err(_) => { + self.cancel_and_finish( + &lease, + QueryResultError::new( + ErrorCode::Timeout, + false, + None, + &lease.query_id, + "SDK Host permission response timed out", + ), + true, + ) + .await; + self.send_error( + request.id.clone(), + ErrorCode::Timeout, + ErrorStage::Query, + false, + None, + "Permission response outcome is unknown and the Query was cancelled", + ) + .await; + } + } + } + + async fn handle_session_close(&self, request: JsonRpcRequest) { + let Some(params) = self + .parse_params::(&request, ErrorStage::Session) + .await + else { + return; + }; + if params + .wait_timeout_ms + .is_some_and(|timeout| timeout == 0 || timeout > MAX_SESSION_CLOSE_TIMEOUT_MS) + { + self.send_invalid_params( + request.id.clone(), + ErrorStage::Session, + "waitTimeoutMs must be between 1 and 30000", + ) + .await; + return; + } + let close_timeout_ms = params.wait_timeout_ms.unwrap_or(5_000); + if !self + .wait_for_session_publication( + ¶ms.session_id, + Duration::from_millis(close_timeout_ms.min(MAX_SESSION_PUBLICATION_WAIT_MS)), + ) + .await + { + self.send_error( + request.id.clone(), + ErrorCode::Timeout, + ErrorStage::Session, + true, + Some(RecoveryAction::Retry), + "Timed out waiting for Session creation or resume response to commit", + ) + .await; + return; + } + let session = { + let mut state = self.inner.state.lock().await; + if state.closing_sessions.contains(¶ms.session_id) { + drop(state); + self.send_error( + request.id.clone(), + ErrorCode::Overloaded, + ErrorStage::Session, + true, + Some(RecoveryAction::Retry), + "Session close is already in progress", + ) + .await; + return; + } + if state.starting_query_sessions.contains(¶ms.session_id) { + drop(state); + self.send_error( + request.id.clone(), + ErrorCode::Overloaded, + ErrorStage::Session, + true, + Some(RecoveryAction::Retry), + "Session has a Query start in progress", + ) + .await; + return; + } + let session = state.sessions.get(¶ms.session_id).cloned(); + if session.is_some() { + state.closing_sessions.insert(params.session_id.clone()); + } + session + }; + let Some(session) = session else { self.send_error( request.id.clone(), ErrorCode::NotFound, @@ -1439,20 +1992,16 @@ impl SdkHostConnection { .await; return; }; - let close_timeout_ms = params.wait_timeout_ms.unwrap_or(5_000); let session_id = params.session_id.clone(); - let operation = async { - self.inner - .runtime - .discard_transient_session(AgentTransientSessionDiscardRequest { - workspace_path: session.workspace_path, - session_id: session_id.clone(), - remote_connection_id: session.remote_connection_id, - remote_ssh_host: session.remote_ssh_host, - wait_timeout_ms: close_timeout_ms, - }) - .await - }; + let release_kind = session.release_kind(); + let operation = self.release_runtime_session( + session_id, + session.workspace_path, + session.remote_connection_id, + session.remote_ssh_host, + release_kind, + close_timeout_ms, + ); match timeout(Duration::from_millis(close_timeout_ms + 500), operation).await { Ok(Ok(unloaded)) => { let queries = { @@ -1532,6 +2081,27 @@ impl SdkHostConnection { state.cleanup_failed = true; } + async fn wait_for_session_publication(&self, session_id: &str, wait_timeout: Duration) -> bool { + timeout(wait_timeout, async { + loop { + self.reap_finished_pending_session_tasks().await; + if !self + .inner + .state + .lock() + .await + .publishing_sessions + .contains(session_id) + { + return; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .is_ok() + } + async fn ensure_session_lease(&self, session_id: &str) -> Result { self.inner .state @@ -1543,7 +2113,7 @@ impl SdkHostConnection { .ok_or_else(|| { bitfun_runtime_ports::PortError::new( PortErrorKind::NotAvailable, - "sessionId must belong to the same SDK Host connection; durable Session resume is not available in this protocol version", + "sessionId must be created or resumed on this SDK Host connection before starting a Query", ) .into() }) @@ -1554,6 +2124,7 @@ impl SdkHostConnection { request: AgentSessionCreateRequest, workspace_path: String, session_budget: OwnedSemaphorePermit, + lifetime: SessionLifetime, ) -> Result { let session_id = uuid::Uuid::new_v4().to_string(); let runtime = self.inner.runtime.clone(); @@ -1571,9 +2142,18 @@ impl SdkHostConnection { let task_session_id = session_id.clone(); let cleanup_workspace_path = workspace_path.clone(); let creation = tokio::spawn(async move { - let result = runtime - .create_transient_session_with_id(task_session_id, request) - .await; + let result = match lifetime { + SessionLifetime::Connection => { + runtime + .create_transient_session_with_id(task_session_id, request) + .await + } + SessionLifetime::Durable => { + runtime + .create_session_with_id(task_session_id, request) + .await + } + }; if let Ok(created) = &result { task_state.lock().await.sessions.insert( created.session_id.clone(), @@ -1582,6 +2162,11 @@ impl SdkHostConnection { remote_connection_id: None, remote_ssh_host: None, exposed: false, + lifetime, + unexposed_release: match lifetime { + SessionLifetime::Connection => SessionReleaseKind::DiscardTransient, + SessionLifetime::Durable => SessionReleaseKind::DeletePersisted, + }, _budget: Arc::new(session_budget), }, ); @@ -1591,10 +2176,15 @@ impl SdkHostConnection { connection_state .pending_session_tasks .push(PendingSessionTask { - transient_cleanup: Some(TransientSessionCleanup { + session_cleanup: Some(SessionCleanup { session_id, workspace_path: cleanup_workspace_path, + release_kind: match lifetime { + SessionLifetime::Connection => SessionReleaseKind::DiscardTransient, + SessionLifetime::Durable => SessionReleaseKind::DeletePersisted, + }, }), + reservation: None, task: creation, }); drop(connection_state); @@ -1606,11 +2196,157 @@ impl SdkHostConnection { })? } + async fn restore_leased_session( + &self, + session_id: String, + workspace_path: String, + model_id: String, + session_budget: OwnedSemaphorePermit, + ) -> Result { + let runtime = self.inner.runtime.clone(); + let state = self.inner.state.clone(); + let task_state = state.clone(); + let (result_tx, result_rx) = oneshot::channel(); + let mut connection_state = state.lock().await; + if connection_state.shutting_down { + return Err(PortError::new( + PortErrorKind::Cancelled, + "SDK Host connection is shutting down", + ) + .into()); + } + if connection_state.sessions.contains_key(&session_id) + || !connection_state + .attaching_sessions + .insert(session_id.clone()) + { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Session is already attached to this SDK Host connection", + ) + .into()); + } + let task_session_id = session_id.clone(); + let task_workspace_path = workspace_path.clone(); + let cleanup_workspace_path = workspace_path.clone(); + let restoration = tokio::spawn(async move { + let restored = runtime + .restore_session(AgentSessionRestoreRequest { + workspace_path: task_workspace_path.clone(), + session_id: task_session_id.clone(), + include_internal: false, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await; + let result = match restored { + Err(error) => Err(error), + Ok(restored) => { + let prepared = async { + runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: task_session_id.clone(), + model_id: model_id.clone(), + }) + .await?; + let binding = runtime + .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { + session_id: task_session_id.clone(), + }) + .await?; + let mut result = AgentSessionCreateResult::new( + restored.session.session_id, + restored.session.session_name, + restored.session.agent_type, + ); + result.model_id = Some(model_id); + if let Some(binding) = binding { + result.workspace_path = Some(binding.workspace_path); + result.workspace_id = binding.workspace_id; + result.project_workspace_path = binding.project_workspace_path; + result.execution_target = binding.execution_target; + } else { + result.workspace_path = Some(task_workspace_path.clone()); + } + Ok(result) + } + .await; + if prepared.is_err() { + let release = runtime + .unload_persisted_session(AgentSessionReleaseRequest { + workspace_path: task_workspace_path.clone(), + session_id: task_session_id.clone(), + remote_connection_id: None, + remote_ssh_host: None, + wait_timeout_ms: 4_500, + }) + .await; + if release.is_err() { + task_state.lock().await.untracked_session_cleanups.insert( + task_session_id.clone(), + SessionCleanup { + session_id: task_session_id.clone(), + workspace_path: task_workspace_path.clone(), + release_kind: SessionReleaseKind::UnloadPersisted, + }, + ); + Err(PortError::new( + PortErrorKind::CleanupRequired, + "Restored Session could not be prepared or released", + ) + .into()) + } else { + prepared + } + } else { + prepared + } + } + }; + let mut connection_state = task_state.lock().await; + if let Ok(restored) = &result { + connection_state.sessions.insert( + restored.session_id.clone(), + SessionLease { + workspace_path, + remote_connection_id: None, + remote_ssh_host: None, + exposed: false, + lifetime: SessionLifetime::Durable, + unexposed_release: SessionReleaseKind::UnloadPersisted, + _budget: Arc::new(session_budget), + }, + ); + } + drop(connection_state); + let _ = result_tx.send(result); + }); + connection_state + .pending_session_tasks + .push(PendingSessionTask { + session_cleanup: Some(SessionCleanup { + session_id: session_id.clone(), + workspace_path: cleanup_workspace_path, + release_kind: SessionReleaseKind::UnloadPersisted, + }), + reservation: Some(SessionTaskReservation::Attaching(session_id)), + task: restoration, + }); + drop(connection_state); + result_rx.await.map_err(|_| { + RuntimeError::from(PortError::new( + PortErrorKind::Backend, + "SDK Host Session restore task ended without a result", + )) + })? + } + async fn deliver_session_create_response( &self, request_id: Option, created: AgentSessionCreateResult, session_id: String, + lifetime: SessionLifetime, ) -> bool { let connection = self.clone(); let (delivered_tx, delivered_rx) = oneshot::channel(); @@ -1618,11 +2354,13 @@ impl SdkHostConnection { if state.shutting_down { return false; } + state.publishing_sessions.insert(session_id.clone()); + let publishing_session_id = session_id.clone(); let delivery = tokio::spawn(async move { let delivered = connection .send_success( request_id, - SessionCreateResult::from_runtime(created, SessionLifetime::Connection), + SessionCreateResult::from_runtime(created, lifetime), ) .await; if delivered { @@ -1630,13 +2368,14 @@ impl SdkHostConnection { } else { tokio::select! { _ = connection.inner.shutdown_started.cancelled() => {} - _ = connection.delete_unexposed_session(&session_id) => {} + _ = connection.release_unexposed_session(&session_id) => {} } } let _ = delivered_tx.send(delivered); }); state.pending_session_tasks.push(PendingSessionTask { - transient_cleanup: None, + session_cleanup: None, + reservation: Some(SessionTaskReservation::Publishing(publishing_session_id)), task: delivery, }); drop(state); @@ -1678,7 +2417,9 @@ impl SdkHostConnection { ) .await; if created_session { - let _ = connection.delete_unexposed_session(&lease.session_id).await; + let _ = connection + .release_unexposed_session(&lease.session_id) + .await; } }; tokio::select! { @@ -1689,7 +2430,8 @@ impl SdkHostConnection { let _ = delivered_tx.send(delivered); }); state.pending_session_tasks.push(PendingSessionTask { - transient_cleanup: None, + session_cleanup: None, + reservation: None, task: delivery, }); drop(state); @@ -1727,12 +2469,13 @@ impl SdkHostConnection { } async fn mark_session_exposed(&self, session_id: &str) { - if let Some(session) = self.inner.state.lock().await.sessions.get_mut(session_id) { + let mut state = self.inner.state.lock().await; + if let Some(session) = state.sessions.get_mut(session_id) { session.exposed = true; } } - async fn delete_unexposed_session(&self, session_id: &str) -> Result<(), ()> { + async fn release_unexposed_session(&self, session_id: &str) -> Result<(), ()> { let lease = self .inner .state @@ -1744,17 +2487,17 @@ impl SdkHostConnection { let Some(lease) = lease else { return Ok(()); }; + let release_kind = lease.release_kind(); match timeout( Duration::from_millis(5_000), - self.inner - .runtime - .discard_transient_session(AgentTransientSessionDiscardRequest { - workspace_path: lease.workspace_path, - session_id: session_id.to_string(), - remote_connection_id: lease.remote_connection_id, - remote_ssh_host: lease.remote_ssh_host, - wait_timeout_ms: 4_500, - }), + self.release_runtime_session( + session_id.to_string(), + lease.workspace_path, + lease.remote_connection_id, + lease.remote_ssh_host, + release_kind, + 4_500, + ), ) .await { @@ -1766,7 +2509,7 @@ impl SdkHostConnection { tracing::warn!( session_id = %session_id, error_kind = runtime_error_kind(&error), - "Failed to delete an unexposed SDK Host Session" + "Failed to release an unexposed SDK Host Session" ); self.inner.state.lock().await.cleanup_failed = true; Err(()) @@ -1774,7 +2517,7 @@ impl SdkHostConnection { Err(_) => { tracing::warn!( session_id = %session_id, - "Timed out while deleting an unexposed SDK Host Session" + "Timed out while releasing an unexposed SDK Host Session" ); self.inner.state.lock().await.cleanup_failed = true; Err(()) @@ -1812,6 +2555,15 @@ impl SdkHostConnection { if !lease.finish_once() { return; } + lease.stop_forwarding.cancel(); + for (_, timeout_cancel) in lease + .pending_permissions + .lock() + .expect("SDK Host pending permission lock poisoned") + .drain() + { + timeout_cancel.cancel(); + } let settlement = timeout( Duration::from_millis(DEFAULT_TURN_SETTLEMENT_TIMEOUT_MS + 500), self.inner @@ -1863,6 +2615,17 @@ impl SdkHostConnection { .expect("SDK Host Query output lock poisoned") .text .clone(); + let usage = lease + .usage + .lock() + .expect("SDK Host Query usage lock poisoned") + .as_ref() + .map(|usage| QueryUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + cached_tokens: usage.cached_tokens, + }); self.send_notification( NOTIFICATION_QUERY_RESULT, QueryResultParams { @@ -1872,6 +2635,7 @@ impl SdkHostConnection { operation_id: lease.operation_id.clone(), status, output: QueryOutput { text: output_text }, + usage, error, }, ) @@ -1987,6 +2751,145 @@ impl SdkHostConnection { .await; } + async fn forward_permission_request( + &self, + lease: &Arc, + request: &PermissionRequest, + sequence: &mut u64, + ) -> bool { + if !self.inner.state.lock().await.permission_responses { + self.reject_permission_and_finish(lease, request).await; + return false; + } + let timeout_cancel = { + let mut pending = lease + .pending_permissions + .lock() + .expect("SDK Host pending permission lock poisoned"); + if pending.contains_key(&request.request_id) { + return true; + } + let timeout_cancel = CancellationToken::new(); + pending.insert(request.request_id.clone(), timeout_cancel.clone()); + timeout_cancel + }; + *sequence += 1; + let delivered = self + .send_notification( + NOTIFICATION_QUERY_EVENT, + QueryEventParams { + query_id: lease.query_id.clone(), + session_id: lease.session_id.clone(), + turn_id: lease.turn_id.clone(), + operation_id: lease.operation_id.clone(), + sequence: *sequence, + event: QueryEvent::PermissionRequest { + request_id: request.request_id.clone(), + action: request.action.clone(), + resources: request.resources.clone(), + source: PermissionSource { + kind: match request.source.kind { + PermissionRequestSourceKind::ToolCall => { + PermissionSourceKind::ToolCall + } + PermissionRequestSourceKind::Provider => { + PermissionSourceKind::Provider + } + PermissionRequestSourceKind::Extension => { + PermissionSourceKind::Extension + } + }, + identity: request.source.identity.clone(), + }, + tool_call_id: request.tool_call_id.clone(), + response_timeout_ms: duration_ms(self.inner.permission_response_timeout), + }, + }, + ) + .await; + if !delivered { + if let Some(timeout_cancel) = lease + .pending_permissions + .lock() + .expect("SDK Host pending permission lock poisoned") + .remove(&request.request_id) + { + timeout_cancel.cancel(); + } + self.reject_permission_and_finish(lease, request).await; + return false; + } + self.spawn_permission_timeout(lease.clone(), request.request_id.clone(), timeout_cancel); + true + } + + fn spawn_permission_timeout( + &self, + lease: Arc, + request_id: String, + timeout_cancel: CancellationToken, + ) { + let connection = self.clone(); + tokio::spawn(async move { + tokio::select! { + _ = lease.stop_forwarding.cancelled() => return, + _ = timeout_cancel.cancelled() => return, + _ = tokio::time::sleep(connection.inner.permission_response_timeout) => {} + } + let expired = lease + .pending_permissions + .lock() + .expect("SDK Host pending permission lock poisoned") + .remove(&request_id) + .is_some(); + if !expired { + return; + } + let rejection = timeout( + Duration::from_millis(PERMISSION_REJECTION_TIMEOUT_MS), + connection.inner.runtime.respond_permission_with_source( + &request_id, + PermissionReply::Reject { + feedback: Some("SDK permission response timed out".to_string()), + }, + PermissionReplySource::System, + ), + ) + .await; + match rejection { + Ok(Ok(())) => {} + Ok(Err(error)) => { + connection + .cancel_and_finish( + &lease, + query_error_from_runtime( + &lease.query_id, + error, + "SDK Host could not reject an expired permission request", + ), + true, + ) + .await; + } + Err(_) => { + connection + .cancel_and_finish( + &lease, + QueryResultError::new( + ErrorCode::Timeout, + true, + Some(RecoveryAction::RestartHost), + &lease.query_id, + "SDK Host permission timeout rejection did not settle", + ), + true, + ) + .await; + } + } + }); + } + async fn parse_params(&self, request: &JsonRpcRequest, stage: ErrorStage) -> Option where T: serde::de::DeserializeOwned, @@ -2168,7 +3071,47 @@ fn event_turn_id(event: &AgenticEvent) -> Option<&str> { AgenticEvent::DialogTurnCompleted { turn_id, .. } | AgenticEvent::DialogTurnCancelled { turn_id, .. } | AgenticEvent::DialogTurnFailed { turn_id, .. } - | AgenticEvent::TextChunk { turn_id, .. } => Some(turn_id), + | AgenticEvent::TokenUsageUpdated { turn_id, .. } + | AgenticEvent::TextChunk { turn_id, .. } + | AgenticEvent::ToolEvent { turn_id, .. } => Some(turn_id), + _ => None, + } +} + +fn is_local_image_path(path: &str) -> bool { + !path.trim().is_empty() + && !path.contains("://") + && local_image_mime_type(Path::new(path)).is_some() +} + +fn local_image_attachment(image_path: &str, workspace_path: &str) -> AgentInputAttachment { + let image_path = PathBuf::from(image_path); + let image_path = if image_path.is_absolute() { + image_path + } else { + Path::new(workspace_path).join(image_path) + }; + let mime_type = local_image_mime_type(&image_path).expect("validated local image extension"); + AgentInputAttachment::image_context( + format!("sdk-image-{}", uuid::Uuid::new_v4()), + Some(image_path.to_string_lossy().into_owned()), + None, + mime_type, + None, + ) +} + +fn local_image_mime_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("png") => Some("image/png"), + Some("jpg" | "jpeg") => Some("image/jpeg"), + Some("gif") => Some("image/gif"), + Some("webp") => Some("image/webp"), _ => None, } } @@ -2182,6 +3125,31 @@ fn project_query_event(event: &AgenticEvent) -> Option { AgenticEvent::TextChunk { text, .. } => { Some(QueryEvent::AssistantTextDelta { text: text.clone() }) } + AgenticEvent::ToolEvent { tool_event, .. } => { + let (status, progress, duration_ms) = match tool_event { + ToolEventData::Started { .. } => (ToolEventStatus::Started, None, None), + ToolEventData::Progress { percentage, .. } => { + (ToolEventStatus::Progress, Some(*percentage), None) + } + ToolEventData::Completed { duration_ms, .. } => { + (ToolEventStatus::Completed, None, Some(*duration_ms)) + } + ToolEventData::Failed { duration_ms, .. } => { + (ToolEventStatus::Failed, None, *duration_ms) + } + ToolEventData::Cancelled { duration_ms, .. } => { + (ToolEventStatus::Cancelled, None, *duration_ms) + } + _ => return None, + }; + Some(QueryEvent::ToolEvent { + tool_call_id: tool_event.tool_id().to_string(), + tool_name: tool_event.effective_tool_name().to_string(), + status, + progress, + duration_ms, + }) + } _ => None, } } @@ -2365,10 +3333,18 @@ fn runtime_error_kind(error: &RuntimeError) -> &'static str { #[cfg(test)] mod runtime_error_tests { - use super::{runtime_error_facts, runtime_error_kind}; + use super::{is_local_image_path, runtime_error_facts, runtime_error_kind}; use crate::protocol::{ErrorCode, RecoveryAction}; use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; + #[test] + fn local_image_input_accepts_supported_paths_and_rejects_urls() { + assert!(is_local_image_path("screenshots/failure.PNG")); + assert!(!is_local_image_path("https://example.com/failure.png")); + assert!(!is_local_image_path("screenshots/failure.svg")); + assert!(!is_local_image_path(" ")); + } + #[test] fn session_writer_conflict_uses_existing_action_required_response() { let error = RuntimeError::Port(PortError::new( diff --git a/src/crates/interfaces/sdk-host/src/protocol.rs b/src/crates/interfaces/sdk-host/src/protocol.rs index 8c72312203..2bc786c5c0 100644 --- a/src/crates/interfaces/sdk-host/src/protocol.rs +++ b/src/crates/interfaces/sdk-host/src/protocol.rs @@ -6,12 +6,14 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; pub const JSON_RPC_VERSION: &str = "2.0"; -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 5; pub const METHOD_INITIALIZE: &str = "initialize"; pub const METHOD_SESSION_CREATE: &str = "session/create"; +pub const METHOD_SESSION_RESUME: &str = "session/resume"; pub const METHOD_QUERY_START: &str = "query/start"; pub const METHOD_QUERY_CANCEL: &str = "query/cancel"; +pub const METHOD_PERMISSION_RESPOND: &str = "permission/respond"; pub const METHOD_SESSION_CLOSE: &str = "session/close"; pub const METHOD_SHUTDOWN: &str = "shutdown"; pub const NOTIFICATION_QUERY_EVENT: &str = "query/event"; @@ -33,7 +35,7 @@ impl RequestId { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct JsonRpcRequest { pub jsonrpc: String, @@ -50,6 +52,18 @@ pub struct JsonRpcRequest { pub params: serde_json::Value, } +impl std::fmt::Debug for JsonRpcRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("JsonRpcRequest") + .field("jsonrpc", &self.jsonrpc) + .field("id", &self.id) + .field("method", &self.method) + .field("params", &"") + .finish() + } +} + impl JsonRpcRequest { pub fn params_as(&self) -> Result { serde_json::from_value(self.params.clone()) @@ -164,13 +178,36 @@ impl JsonRpcNotification { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct InitializeParams { pub protocol_version: u32, pub client_info: ClientInfo, pub capabilities: ClientCapabilities, + pub model: TemporaryModelConfig, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "snake_case")] +pub enum TemporaryModelProvider { + Openai, + Responses, + Anthropic, + Gemini, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TemporaryModelConfig { + pub provider: TemporaryModelProvider, + pub model: String, + pub api_key: String, + #[cfg_attr(feature = "ts", ts(optional = nullable))] + #[serde(default)] + pub base_url: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -186,6 +223,7 @@ pub struct ClientInfo { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ClientCapabilities { pub server_notifications: bool, + pub permission_responses: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -196,15 +234,17 @@ pub struct InitializeResult { pub runtime_version: String, pub stability: Stability, pub capabilities: HostCapabilities, + pub model_id: String, } impl InitializeResult { - pub fn current(runtime_version: impl Into) -> Self { + pub fn current(runtime_version: impl Into, model_id: impl Into) -> Self { Self { protocol_version: PROTOCOL_VERSION, runtime_version: runtime_version.into(), stability: Stability::NotDelivered, capabilities: HostCapabilities::current(), + model_id: model_id.into(), } } } @@ -223,14 +263,17 @@ pub enum Stability { pub struct HostCapabilities { pub session_create: bool, pub session_create_lifetime: SessionLifetime, + pub session_resume: bool, pub query: bool, pub query_cancel: bool, pub session_close: bool, pub event_stream: bool, + pub tool_events: bool, + pub image_input: bool, pub structured_output: bool, pub usage: bool, pub custom_tools: bool, - pub permission_callbacks: bool, + pub permission_responses: bool, pub hooks: bool, pub mcp_configuration: bool, pub prestarted_transport: bool, @@ -240,15 +283,18 @@ impl HostCapabilities { pub const fn current() -> Self { Self { session_create: true, - session_create_lifetime: SessionLifetime::Connection, + session_create_lifetime: SessionLifetime::Durable, + session_resume: true, query: true, query_cancel: true, session_close: true, event_stream: true, + tool_events: true, + image_input: true, structured_output: false, - usage: false, + usage: true, custom_tools: false, - permission_callbacks: false, + permission_responses: true, hooks: false, mcp_configuration: false, prestarted_transport: false, @@ -263,6 +309,8 @@ impl HostCapabilities { pub enum SessionLifetime { /// Created and deleted by this Host connection. Connection, + /// Persisted by the Agent Runtime and resumable by a later Host process. + Durable, } fn deserialize_optional_request_id<'de, D>(deserializer: D) -> Result, D::Error> @@ -367,6 +415,13 @@ pub struct SessionCreateParams { pub model: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SessionResumeParams { + pub session_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(rename_all = "camelCase")] @@ -409,6 +464,8 @@ impl SessionCreateResult { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct QueryStartParams { pub prompt: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, #[cfg_attr(feature = "ts", ts(optional = nullable))] #[serde(default)] pub session_id: Option, @@ -460,6 +517,55 @@ pub struct QueryCancelResult { pub requested: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "snake_case")] +pub enum PermissionDecision { + AllowOnce, + AllowAlways, + Reject, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PermissionRespondParams { + pub query_id: String, + pub session_id: String, + pub turn_id: String, + pub operation_id: String, + pub request_id: String, + pub decision: PermissionDecision, + #[cfg_attr(feature = "ts", ts(optional = nullable))] + #[serde(default)] + pub feedback: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct PermissionRespondResult { + pub request_id: String, + pub accepted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "snake_case")] +pub enum PermissionSourceKind { + ToolCall, + Provider, + Extension, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct PermissionSource { + pub kind: PermissionSourceKind, + pub identity: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -502,11 +608,49 @@ pub struct QueryEventParams { pub event: QueryEvent, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] pub enum QueryEvent { - AssistantTextDelta { text: String }, + AssistantTextDelta { + text: String, + }, + ToolEvent { + tool_call_id: String, + tool_name: String, + status: ToolEventStatus, + #[cfg_attr(feature = "ts", ts(optional))] + #[serde(skip_serializing_if = "Option::is_none")] + progress: Option, + #[cfg_attr(feature = "ts", ts(optional))] + #[serde(skip_serializing_if = "Option::is_none")] + duration_ms: Option, + }, + PermissionRequest { + request_id: String, + action: String, + resources: Vec, + source: PermissionSource, + #[cfg_attr(feature = "ts", ts(optional))] + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, + response_timeout_ms: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "snake_case")] +pub enum ToolEventStatus { + Started, + Progress, + Completed, + Failed, + Cancelled, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -530,9 +674,26 @@ pub struct QueryResultParams { pub output: QueryOutput, #[cfg_attr(feature = "ts", ts(optional))] #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[cfg_attr(feature = "ts", ts(optional))] + #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(rename_all = "camelCase")] +pub struct QueryUsage { + pub input_tokens: usize, + #[cfg_attr(feature = "ts", ts(optional))] + #[serde(skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + pub total_tokens: usize, + #[cfg_attr(feature = "ts", ts(optional))] + #[serde(skip_serializing_if = "Option::is_none")] + pub cached_tokens: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct QueryOutput { diff --git a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs index d66dd608da..4ba8aabf77 100644 --- a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs +++ b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -7,21 +9,26 @@ use bitfun_agent_runtime::sdk::{ AgentDialogTurnPort, AgentDialogTurnRequest, AgentEventSource, AgentRuntimeBuilder, AgentSessionClosePort, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionManagementPort, - AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, - AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, - AgentTransientSessionDiscardRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, - AgentTurnCancellationResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, - DialogSubmitOutcome, PermissionRequest, PermissionRequestManager, PermissionRequestSource, - PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, + AgentSessionModelPort, AgentSessionModelSelectionUpdateRequest, AgentSessionRestorePort, + AgentSessionRestoreRequest, AgentSessionRestoreResult, AgentSessionSummary, + AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, + AgentSubmissionRequest, AgentSubmissionResult, AgentTransientSessionDiscardRequest, + AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentTurnCancellationResult, + AgentTurnSettlementPort, AgentTurnSettlementRequest, DialogSubmitOutcome, PermissionRequest, + PermissionRequestManager, PermissionRequestSource, PermissionRequestSourceKind, PortError, + PortErrorKind, PortResult, SessionState, }; use bitfun_core_types::ErrorCategory; -use bitfun_events::AgenticEvent; +use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use bitfun_runtime_ports::{ ClockPort, PermissionAuditRecord, PermissionAuditStorePort, PermissionGrant, PermissionReplyStorePort, RuntimeServiceCapability, RuntimeServicePort, }; -use bitfun_sdk_host::host::{ConnectionControl, HostOutput, SdkHostConfig, SdkHostConnection}; -use bitfun_sdk_host::protocol::{JsonRpcRequest, PROTOCOL_VERSION}; +use bitfun_sdk_host::host::{ + ConnectionControl, HostOutput, SdkHostConfig, SdkHostConnection, TemporaryModelInstallError, + TemporaryModelInstaller, +}; +use bitfun_sdk_host::protocol::{JsonRpcRequest, TemporaryModelConfig, PROTOCOL_VERSION}; use tokio::sync::{mpsc, Notify}; use tokio::time::timeout; @@ -29,10 +36,16 @@ use tokio::time::timeout; struct FakeOwner { queue: Mutex>>, created_session_ids: Mutex>, + created_model_ids: Mutex>>, cancel_requests: Mutex>, discard_requests: Mutex>, + unload_requests: Mutex>, + delete_requests: Mutex>, + restored_session_ids: Mutex>, + updated_models: Mutex>, settlement_requests: Mutex>, dialog_metadata: Mutex>>, + dialog_requests: Mutex>, emit_terminal: bool, fail_dialog_submit: bool, fail_delete: bool, @@ -40,12 +53,15 @@ struct FakeOwner { queue_dialog: bool, dialog_session_override: Option, output_text: Option, + emit_tool_events: bool, block_dialog_submit: bool, block_agent_resolution: bool, block_first_cancel: bool, block_delete: bool, block_session_create: bool, + block_first_session_restore: AtomicBool, panic_after_session_create: bool, + fail_model_update: bool, dialog_submit_started: Notify, release_dialog_submit: Notify, agent_resolution_started: Notify, @@ -56,6 +72,8 @@ struct FakeOwner { release_delete: Notify, session_create_started: Notify, release_session_create: Notify, + session_restore_started: Notify, + release_session_restore: Notify, } impl FakeOwner { @@ -68,6 +86,12 @@ impl FakeOwner { .unwrap() .iter() .any(|created| created == session_id) + || self + .restored_session_ids + .lock() + .unwrap() + .iter() + .any(|restored| restored == session_id) } fn last_created_session_id(&self) -> String { @@ -104,6 +128,15 @@ impl FakeOwner { } } + fn with_tool_events(queue: Arc) -> Self { + Self { + queue: Mutex::new(Some(queue)), + emit_terminal: true, + emit_tool_events: true, + ..Self::default() + } + } + fn failing_dialog(queue: Arc) -> Self { Self { queue: Mutex::new(Some(queue)), @@ -181,6 +214,25 @@ impl FakeOwner { } } + fn blocking_first_session_restore(queue: Arc) -> Self { + Self { + queue: Mutex::new(Some(queue)), + emit_terminal: true, + block_first_session_restore: AtomicBool::new(true), + ..Self::default() + } + } + + fn failing_resume_preparation_and_unload(queue: Arc) -> Self { + Self { + queue: Mutex::new(Some(queue)), + emit_terminal: true, + fail_delete: true, + fail_model_update: true, + ..Self::default() + } + } + fn panicking_session_create(queue: Arc, fail_delete: bool) -> Self { Self { queue: Mutex::new(Some(queue)), @@ -199,6 +251,58 @@ impl FakeOwner { } } +#[derive(Default)] +struct FakeTemporaryModelInstaller { + installed: Mutex>, + removed: Mutex>, + block_first_install: AtomicBool, + fail_first_install: AtomicBool, + install_started: Notify, + release_install: Notify, +} + +impl FakeTemporaryModelInstaller { + fn blocking_first_install() -> Self { + Self { + block_first_install: AtomicBool::new(true), + ..Self::default() + } + } + + fn failing_first_install() -> Self { + Self { + fail_first_install: AtomicBool::new(true), + ..Self::default() + } + } +} + +#[async_trait] +impl TemporaryModelInstaller for FakeTemporaryModelInstaller { + async fn install( + &self, + model: TemporaryModelConfig, + ) -> Result { + self.installed.lock().unwrap().push(model); + if self.fail_first_install.swap(false, Ordering::AcqRel) { + return Err(TemporaryModelInstallError::InvalidModel); + } + if self.block_first_install.swap(false, Ordering::AcqRel) { + self.install_started.notify_one(); + self.release_install.notified().await; + } + Ok("sdk:openai:resolved".to_string()) + } + + async fn remove(&self, model_id: &str) { + self.removed.lock().unwrap().push(model_id.to_string()); + } +} + +fn fake_installer() -> Arc { + Arc::new(FakeTemporaryModelInstaller::default()) +} + #[async_trait] impl AgentSubmissionPort for FakeOwner { async fn create_session( @@ -210,6 +314,10 @@ impl AgentSubmissionPort for FakeOwner { self.release_session_create.notified().await; } let session_id = "session-fixture".to_string(); + self.created_model_ids + .lock() + .unwrap() + .push(request.model_id.clone()); self.created_session_ids .lock() .unwrap() @@ -234,8 +342,12 @@ impl AgentSubmissionPort for FakeOwner { .lock() .unwrap() .push(session_id.clone()); + self.created_model_ids + .lock() + .unwrap() + .push(request.model_id.clone()); if self.panic_after_session_create { - panic!("fixture panics after creating the transient Session"); + panic!("fixture panics after creating the Session"); } Ok(AgentSessionCreateResult::new( session_id, @@ -283,6 +395,7 @@ impl AgentDialogTurnPort for FakeOwner { &self, request: AgentDialogTurnRequest, ) -> PortResult { + self.dialog_requests.lock().unwrap().push(request.clone()); self.dialog_metadata .lock() .unwrap() @@ -312,6 +425,44 @@ impl AgentDialogTurnPort for FakeOwner { }); } let queue = self.queue.lock().unwrap().clone().unwrap(); + if self.emit_tool_events { + queue + .enqueue( + AgenticEvent::ToolEvent { + session_id: request.session_id.clone(), + turn_id: turn_id.clone(), + round_id: "round-fixture".to_string(), + attempt_id: Some("attempt-fixture".to_string()), + attempt_index: Some(0), + tool_event: ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-fixture", "Read"), + params: serde_json::json!({ "path": "must-not-leak.txt" }), + timeout_seconds: None, + }, + }, + None, + ) + .await + .unwrap(); + queue + .enqueue( + AgenticEvent::ToolEvent { + session_id: request.session_id.clone(), + turn_id: turn_id.clone(), + round_id: "round-fixture".to_string(), + attempt_id: Some("attempt-fixture".to_string()), + attempt_index: Some(0), + tool_event: ToolEventData::Progress { + identity: ToolEventIdentity::direct("tool-fixture", "Read"), + message: "must-not-leak-progress".to_string(), + percentage: 50.0, + }, + }, + None, + ) + .await + .unwrap(); + } queue .enqueue( AgenticEvent::TextChunk { @@ -329,6 +480,32 @@ impl AgentDialogTurnPort for FakeOwner { ) .await .unwrap(); + if self.emit_tool_events { + queue + .enqueue( + AgenticEvent::ToolEvent { + session_id: request.session_id.clone(), + turn_id: turn_id.clone(), + round_id: "round-fixture".to_string(), + attempt_id: Some("attempt-fixture".to_string()), + attempt_index: Some(0), + tool_event: ToolEventData::Completed { + identity: ToolEventIdentity::direct("tool-fixture", "Read"), + result: serde_json::json!({ "content": "must-not-leak" }), + result_for_assistant: None, + image_attachments: None, + duration_ms: 12, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(12), + }, + }, + None, + ) + .await + .unwrap(); + } if self.emit_terminal { queue .enqueue( @@ -381,7 +558,8 @@ impl AgentSessionManagementPort for FakeOwner { Ok(Vec::new()) } - async fn delete_session(&self, _request: AgentSessionDeleteRequest) -> PortResult<()> { + async fn delete_session(&self, request: AgentSessionDeleteRequest) -> PortResult<()> { + self.delete_requests.lock().unwrap().push(request); if self.fail_delete { return Err(PortError::new( PortErrorKind::CleanupRequired, @@ -409,6 +587,61 @@ impl AgentSessionManagementPort for FakeOwner { } } +#[async_trait] +impl AgentSessionRestorePort for FakeOwner { + async fn restore_session( + &self, + request: AgentSessionRestoreRequest, + ) -> PortResult { + if self + .block_first_session_restore + .swap(false, Ordering::AcqRel) + { + self.session_restore_started.notify_one(); + self.release_session_restore.notified().await; + } + self.restored_session_ids + .lock() + .unwrap() + .push(request.session_id.clone()); + Ok(AgentSessionRestoreResult { + session: AgentSessionSummary { + session_id: request.session_id, + session_name: "Persisted".to_string(), + agent_type: "agentic".to_string(), + model_id: Some("sdk:openai:previous".to_string()), + reasoning_preset: None, + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + turn_count: 1, + created_at_ms: 1, + last_active_at_ms: 2, + }, + state: SessionState::Idle, + }) + } +} + +#[async_trait] +impl AgentSessionModelPort for FakeOwner { + async fn update_session_model_selection( + &self, + request: AgentSessionModelSelectionUpdateRequest, + ) -> PortResult<()> { + self.updated_models + .lock() + .unwrap() + .push((request.session_id, request.selection.model_id)); + if self.fail_model_update { + return Err(PortError::new( + PortErrorKind::Backend, + "model update failed", + )); + } + Ok(()) + } +} + #[derive(Default)] struct PermissionStore { audit: Mutex>, @@ -531,6 +764,28 @@ fn blocking_permission_manager() -> Arc { )) } +fn permission_request_fixture(request_id: &str, order: u32, session_id: &str) -> PermissionRequest { + PermissionRequest { + request_id: request_id.to_string(), + round_id: "round-fixture".to_string(), + order, + tool_call_id: Some("tool-fixture".to_string()), + project_path: Some("D:/workspace/project".to_string()), + project_id: "project-fixture".to_string(), + session_id: session_id.to_string(), + agent_id: "agentic".to_string(), + action: "edit".to_string(), + resources: vec!["src/lib.rs".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "edit".to_string(), + }, + delegation: None, + display_metadata: serde_json::Map::new(), + } +} + async fn host_with_query_limit( max_active_queries: usize, ) -> ( @@ -561,6 +816,7 @@ async fn host_with_query_limit( max_active_queries, ..SdkHostConfig::default() }, + fake_installer(), ), owner, receiver, @@ -617,14 +873,8 @@ struct BlockingSessionCreateOutput { #[async_trait] impl HostOutput for BlockingSessionCreateOutput { async fn send(&self, value: serde_json::Value) -> Result<(), ()> { - let is_session_create = value - .get("result") - .and_then(|result| result.get("sessionId")) - .is_some() - && value - .get("result") - .and_then(|result| result.get("queryId")) - .is_none(); + let is_session_create = + value.get("id").and_then(serde_json::Value::as_str) == Some("visible-create"); self.output.send(value).await.map_err(|_| ())?; if is_session_create { self.response_visible.notify_one(); @@ -653,6 +903,14 @@ impl AgentSessionClosePort for FakeOwner { } Ok(true) } + + async fn unload_persisted_session( + &self, + request: AgentTransientSessionDiscardRequest, + ) -> PortResult { + self.unload_requests.lock().unwrap().push(request.clone()); + self.discard_transient_session(request).await + } } fn request(value: serde_json::Value) -> JsonRpcRequest { @@ -663,9 +921,53 @@ async fn host() -> ( SdkHostConnection, Arc, mpsc::Receiver, +) { + host_with_temporary_model_installer(fake_installer()).await +} + +async fn host_with_temporary_model_installer( + installer: Arc, +) -> ( + SdkHostConnection, + Arc, + mpsc::Receiver, ) { let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let owner = Arc::new(FakeOwner::with_queue(queue.clone())); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(owner.clone()) + .with_dialog_turn_port(owner.clone()) + .with_cancellation_port(owner.clone()) + .with_turn_settlement_port(owner.clone()) + .with_session_management_port(owner.clone()) + .with_session_close_port(owner.clone()) + .with_session_model_port(owner.clone()) + .with_session_restore_port(owner.clone()) + .with_permission_request_manager(permission_manager()) + .with_event_source(AgentEventSource::new(queue)) + .build() + .unwrap(); + let (output, receiver) = mpsc::channel(32); + ( + SdkHostConnection::new( + runtime, + "D:/workspace/project", + output, + SdkHostConfig::default(), + installer, + ), + owner, + receiver, + ) +} + +async fn host_with_tool_events() -> ( + SdkHostConnection, + Arc, + mpsc::Receiver, +) { + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let owner = Arc::new(FakeOwner::with_tool_events(queue.clone())); let runtime = AgentRuntimeBuilder::new() .with_submission_port(owner.clone()) .with_dialog_turn_port(owner.clone()) @@ -684,6 +986,7 @@ async fn host() -> ( "D:/workspace/project", output, SdkHostConfig::default(), + fake_installer(), ), owner, receiver, @@ -698,13 +1001,202 @@ async fn initialize(host: &SdkHostConnection, output: &mut mpsc::Receiver JsonRpcRequest { + request(serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "clientInfo": { "name": "fixture", "version": "0.1.0" }, + "capabilities": { + "serverNotifications": true, + "permissionResponses": true + }, + "model": { + "provider": "openai", + "model": "fixture-model", + "apiKey": "fixture-secret" + } + } + })) +} + +#[tokio::test] +async fn temporary_model_is_connection_scoped_and_cannot_be_overridden() { + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let owner = Arc::new(FakeOwner::with_queue(queue.clone())); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(owner.clone()) + .with_dialog_turn_port(owner.clone()) + .with_cancellation_port(owner.clone()) + .with_turn_settlement_port(owner.clone()) + .with_session_management_port(owner.clone()) + .with_session_close_port(owner.clone()) + .with_permission_request_manager(permission_manager()) + .with_event_source(AgentEventSource::new(queue)) + .build() + .unwrap(); + let installer = Arc::new(FakeTemporaryModelInstaller::default()); + let (sender, mut output) = mpsc::channel(16); + let host = SdkHostConnection::new( + runtime, + "D:/workspace/project", + sender, + SdkHostConfig::default(), + installer.clone(), + ); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "initialize-model", + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "clientInfo": { "name": "fixture", "version": "0.1.0" }, + "capabilities": { + "serverNotifications": true, + "permissionResponses": true + }, + "model": { + "provider": "openai", + "model": "fixture-model", + "apiKey": "fixture-secret" + } + } + }))) + .await; + let initialized = output.recv().await.unwrap(); + assert_eq!(initialized["result"]["modelId"], "sdk:openai:resolved"); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "create-with-override", + "method": "session/create", + "params": { "model": "attempted-override" } + }))) + .await; + assert_eq!(output.recv().await.unwrap()["id"], "create-with-override"); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "query-with-override", + "method": "query/start", + "params": { + "prompt": "hello", + "model": "attempted-query-override" + } + }))) + .await; + assert_eq!(output.recv().await.unwrap()["id"], "query-with-override"); + assert_eq!(installer.installed.lock().unwrap().len(), 1); + assert_eq!( + owner.created_model_ids.lock().unwrap().as_slice(), + &[ + Some("sdk:openai:resolved".to_string()), + Some("sdk:openai:resolved".to_string()), + ] + ); + + host.shutdown_connection().await; + host.shutdown_connection().await; + assert_eq!( + installer.removed.lock().unwrap().as_slice(), + &["sdk:openai:resolved".to_string()] + ); +} + +#[tokio::test] +async fn concurrent_initialize_installs_temporary_model_once() { + let installer = Arc::new(FakeTemporaryModelInstaller::blocking_first_install()); + let (host, _owner, mut output) = host_with_temporary_model_installer(installer.clone()).await; + let first_host = host.clone(); + let first = tokio::spawn(async move { + first_host + .handle_request(temporary_model_initialize_request("initialize-first")) + .await + }); + installer.install_started.notified().await; + + host.handle_request(temporary_model_initialize_request("initialize-second")) + .await; + let second_response = output.recv().await.unwrap(); + installer.release_install.notify_waiters(); + first.await.unwrap(); + let first_response = output.recv().await.unwrap(); + + assert_eq!(second_response["id"], "initialize-second"); + assert_eq!( + second_response["error"]["data"]["code"], + "already_initialized" + ); + assert_eq!(first_response["id"], "initialize-first"); + assert_eq!(first_response["result"]["modelId"], "sdk:openai:resolved"); + assert_eq!(installer.installed.lock().unwrap().len(), 1); + host.shutdown_connection().await; +} + +#[tokio::test] +async fn initialize_finishing_after_shutdown_removes_the_installed_model() { + let installer = Arc::new(FakeTemporaryModelInstaller::blocking_first_install()); + let (host, _owner, mut output) = host_with_temporary_model_installer(installer.clone()).await; + let initialize_host = host.clone(); + let initialize = tokio::spawn(async move { + initialize_host + .handle_request(temporary_model_initialize_request( + "initialize-during-shutdown", + )) + .await + }); + installer.install_started.notified().await; + + host.shutdown_connection().await; + installer.release_install.notify_waiters(); + initialize.await.unwrap(); + let response = output.recv().await.unwrap(); + + assert_eq!(response["id"], "initialize-during-shutdown"); + assert_eq!(response["error"]["data"]["code"], "cancelled"); + assert!(!host.is_initialized().await); + assert_eq!( + installer.removed.lock().unwrap().as_slice(), + &["sdk:openai:resolved".to_string()] + ); +} + +#[tokio::test] +async fn failed_temporary_model_install_rolls_back_for_retry() { + let installer = Arc::new(FakeTemporaryModelInstaller::failing_first_install()); + let (host, _owner, mut output) = host_with_temporary_model_installer(installer.clone()).await; + + host.handle_request(temporary_model_initialize_request("initialize-invalid")) + .await; + let rejected = output.recv().await.unwrap(); + assert_eq!(rejected["error"]["data"]["code"], "invalid_request"); + + host.handle_request(temporary_model_initialize_request("initialize-retry")) + .await; + let initialized = output.recv().await.unwrap(); + assert_eq!(initialized["result"]["modelId"], "sdk:openai:resolved"); + assert_eq!(installer.installed.lock().unwrap().len(), 2); + host.shutdown_connection().await; +} + #[tokio::test] async fn resource_lifecycle_notifications_do_not_create_unaddressable_sessions() { let (host, owner, mut output) = host().await; @@ -731,7 +1223,7 @@ async fn resource_lifecycle_notifications_do_not_create_unaddressable_sessions() .await; let created = output.recv().await.unwrap(); assert_eq!(created["id"], "create-after-notifications"); - assert_eq!(created["result"]["lifetime"], "connection"); + assert_eq!(created["result"]["lifetime"], "durable"); host.shutdown_connection().await; } @@ -756,7 +1248,15 @@ async fn initialize_is_required_and_version_mismatch_fails_closed() { "params": { "protocolVersion": 99, "clientInfo": { "name": "fixture", "version": "0.1.0" }, - "capabilities": { "serverNotifications": true } + "capabilities": { + "serverNotifications": true, + "permissionResponses": true + }, + "model": { + "provider": "openai", + "model": "fixture-model", + "apiKey": "fixture-secret" + } } }))) .await; @@ -797,12 +1297,159 @@ async fn query_streams_existing_events_and_one_terminal_result() { assert_eq!(event["params"]["event"]["text"], "fixture result"); let result = output.recv().await.unwrap(); - assert_eq!(result["method"], "query/result"); - assert_eq!(result["params"]["queryId"], query_id); - assert_eq!(result["params"]["operationId"], operation_id); + assert_eq!(result["method"], "query/result"); + assert_eq!(result["params"]["queryId"], query_id); + assert_eq!(result["params"]["operationId"], operation_id); + assert_eq!(result["params"]["status"], "completed"); + assert_eq!(result["params"]["output"]["text"], "fixture result"); + assert!(output.try_recv().is_err(), "terminal result must be unique"); +} + +#[tokio::test] +async fn query_maps_local_image_paths_to_existing_runtime_attachments() { + let (host, owner, mut output) = host().await; + initialize(&host, &mut output).await; + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "query-image", + "method": "query/start", + "params": { + "prompt": "", + "images": ["screenshots/fixture.jpg"] + } + }))) + .await; + + let accepted = output.recv().await.unwrap(); + assert_eq!(accepted["result"]["accepted"], true); + let requests = owner.dialog_requests.lock().unwrap(); + let request = requests.last().expect("submitted dialog request"); + assert!(request.message.is_empty()); + let attachment = &request.attachments[0]; + assert_eq!(attachment.kind, "remote_image"); + assert_eq!(attachment.metadata["mimeType"], "image/jpeg"); + assert_eq!( + PathBuf::from( + attachment.metadata["imagePath"] + .as_str() + .expect("local image path") + ), + PathBuf::from("D:/workspace/project").join("screenshots/fixture.jpg") + ); +} + +#[tokio::test] +async fn query_result_aggregates_usage_for_its_turn() { + let (host, owner, mut output) = host_with_query_limit(1).await; + initialize(&host, &mut output).await; + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "query-usage", + "method": "query/start", + "params": { "prompt": "hello" } + }))) + .await; + + let accepted = output.recv().await.unwrap(); + assert_eq!(accepted["result"]["turnId"], "turn-fixture"); + assert_eq!(output.recv().await.unwrap()["method"], "query/event"); + let session_id = accepted["result"]["sessionId"].as_str().unwrap(); + let queue = owner.queue.lock().unwrap().clone().unwrap(); + for event in [ + usage_event(session_id, "turn-fixture", 100, Some(25), 125, Some(40)), + usage_event(session_id, "another-turn", 900, Some(90), 990, Some(80)), + usage_event(session_id, "turn-fixture", 200, Some(50), 250, Some(80)), + AgenticEvent::DialogTurnCompleted { + session_id: session_id.to_string(), + turn_id: "turn-fixture".to_string(), + total_rounds: 2, + total_tools: 0, + duration_ms: 1, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("stop".to_string()), + has_final_response: Some(true), + }, + ] { + queue.enqueue(event, None).await.unwrap(); + } + + let result = output.recv().await.unwrap(); + assert_eq!(result["method"], "query/result"); + assert_eq!(result["params"]["usage"]["inputTokens"], 300); + assert_eq!(result["params"]["usage"]["outputTokens"], 75); + assert_eq!(result["params"]["usage"]["totalTokens"], 375); + assert_eq!(result["params"]["usage"]["cachedTokens"], 120); +} + +fn usage_event( + session_id: &str, + turn_id: &str, + input_tokens: usize, + output_tokens: Option, + total_tokens: usize, + cached_tokens: Option, +) -> AgenticEvent { + AgenticEvent::TokenUsageUpdated { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), + input_tokens, + output_tokens, + total_tokens, + max_context_tokens: Some(200_000), + is_subagent: false, + cached_tokens, + token_details: None, + } +} + +#[tokio::test] +async fn query_projects_safe_tool_activity_without_raw_inputs_or_results() { + let (host, _, mut output) = host_with_tool_events().await; + initialize(&host, &mut output).await; + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "query-tools", + "method": "query/start", + "params": { "prompt": "read a file" } + }))) + .await; + + let accepted = output.recv().await.unwrap(); + let started = output.recv().await.unwrap(); + let progress = output.recv().await.unwrap(); + let text = output.recv().await.unwrap(); + let completed = output.recv().await.unwrap(); + let result = output.recv().await.unwrap(); + + assert_eq!(started["params"]["sequence"], 1); + assert_eq!(started["params"]["event"]["type"], "tool_event"); + assert_eq!(started["params"]["event"]["toolCallId"], "tool-fixture"); + assert_eq!(started["params"]["event"]["toolName"], "Read"); + assert_eq!(started["params"]["event"]["status"], "started"); + assert!(started["params"]["event"].get("params").is_none()); + + assert_eq!(progress["params"]["sequence"], 2); + assert_eq!(progress["params"]["event"]["status"], "progress"); + assert_eq!(progress["params"]["event"]["progress"], 50.0); + assert!(!serde_json::to_string(&progress) + .unwrap() + .contains("must-not-leak-progress")); + assert_eq!(text["params"]["sequence"], 3); + assert_eq!(text["params"]["event"]["type"], "assistant_text_delta"); + assert_eq!(completed["params"]["sequence"], 4); + assert_eq!(completed["params"]["event"]["status"], "completed"); + assert_eq!(completed["params"]["event"]["durationMs"], 12); + assert!(completed["params"]["event"].get("result").is_none()); + assert_eq!(result["params"]["status"], "completed"); + assert_eq!(result["params"]["queryId"], accepted["result"]["queryId"]); assert_eq!(result["params"]["output"]["text"], "fixture result"); - assert!(output.try_recv().is_err(), "terminal result must be unique"); } #[tokio::test] @@ -829,6 +1476,7 @@ async fn escaped_query_output_fails_before_exceeding_the_wire_budget() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -912,6 +1560,7 @@ async fn cancellation_timeout_reports_unknown_outcome_for_the_exact_operation() "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -969,7 +1618,7 @@ async fn cancellation_timeout_reports_unknown_outcome_for_the_exact_operation() } #[tokio::test] -async fn query_on_created_transient_session_preserves_connection_lifetime() { +async fn query_on_created_session_preserves_durable_lifetime() { let (host, _, mut output) = host().await; initialize(&host, &mut output).await; @@ -981,7 +1630,7 @@ async fn query_on_created_transient_session_preserves_connection_lifetime() { }))) .await; let created = output.recv().await.unwrap(); - assert_eq!(created["result"]["lifetime"], "connection"); + assert_eq!(created["result"]["lifetime"], "durable"); let session_id = created["result"]["sessionId"].as_str().unwrap(); host.handle_request(request(serde_json::json!({ @@ -998,9 +1647,160 @@ async fn query_on_created_transient_session_preserves_connection_lifetime() { let accepted = output.recv().await.unwrap(); assert_eq!(accepted["id"], "query-1"); assert_eq!(accepted["result"]["createdSession"], false); - assert_eq!(accepted["result"]["sessionLifetime"], "connection"); + assert_eq!(accepted["result"]["sessionLifetime"], "durable"); + + host.shutdown_connection().await; +} + +#[tokio::test] +async fn persisted_session_resume_rebinds_the_connection_model_and_unloads_on_close() { + let (host, owner, mut output) = host().await; + initialize(&host, &mut output).await; + let session_id = "00000000-0000-4000-8000-000000000001"; + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "resume-1", + "method": "session/resume", + "params": { "sessionId": session_id } + }))) + .await; + let resumed = output.recv().await.unwrap(); + assert_eq!(resumed["result"]["sessionId"], session_id); + assert_eq!(resumed["result"]["lifetime"], "durable"); + assert_eq!( + owner.updated_models.lock().unwrap().as_slice(), + &[(session_id.to_string(), "sdk:openai:resolved".to_string())] + ); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "close-resumed", + "method": "session/close", + "params": { "sessionId": session_id } + }))) + .await; + assert_eq!(output.recv().await.unwrap()["result"]["unloaded"], true); + assert_eq!(owner.unload_requests.lock().unwrap().len(), 1); + + host.shutdown_connection().await; +} + +#[tokio::test] +async fn concurrent_resume_of_the_same_session_is_rejected_while_attach_is_in_flight() { + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let owner = Arc::new(FakeOwner::blocking_first_session_restore(queue.clone())); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(owner.clone()) + .with_dialog_turn_port(owner.clone()) + .with_cancellation_port(owner.clone()) + .with_turn_settlement_port(owner.clone()) + .with_session_management_port(owner.clone()) + .with_session_close_port(owner.clone()) + .with_session_model_port(owner.clone()) + .with_session_restore_port(owner.clone()) + .with_permission_request_manager(permission_manager()) + .with_event_source(AgentEventSource::new(queue)) + .build() + .unwrap(); + let (sender, mut output) = mpsc::channel(16); + let host = SdkHostConnection::new( + runtime, + "D:/workspace/project", + sender, + SdkHostConfig::default(), + fake_installer(), + ); + initialize(&host, &mut output).await; + let session_id = "00000000-0000-4000-8000-000000000002"; + + let first_host = host.clone(); + let first = tokio::spawn(async move { + first_host + .handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "resume-first", + "method": "session/resume", + "params": { "sessionId": session_id } + }))) + .await + }); + owner.session_restore_started.notified().await; + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "resume-second", + "method": "session/resume", + "params": { "sessionId": session_id } + }))) + .await; + let rejected = output.recv().await.unwrap(); + assert_eq!(rejected["id"], "resume-second"); + assert_eq!(rejected["error"]["data"]["code"], "invalid_request"); + + owner.release_session_restore.notify_one(); + assert_eq!(first.await.unwrap(), ConnectionControl::Continue); + let resumed = output.recv().await.unwrap(); + assert_eq!(resumed["id"], "resume-first"); + assert_eq!(resumed["result"]["sessionId"], session_id); + assert_eq!(owner.restored_session_ids.lock().unwrap().len(), 1); + + host.shutdown_connection().await; +} + +#[tokio::test] +async fn failed_resume_compensation_blocks_new_work_and_retries_unload_on_shutdown() { + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let owner = Arc::new(FakeOwner::failing_resume_preparation_and_unload( + queue.clone(), + )); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(owner.clone()) + .with_dialog_turn_port(owner.clone()) + .with_cancellation_port(owner.clone()) + .with_turn_settlement_port(owner.clone()) + .with_session_management_port(owner.clone()) + .with_session_close_port(owner.clone()) + .with_session_model_port(owner.clone()) + .with_session_restore_port(owner.clone()) + .with_permission_request_manager(permission_manager()) + .with_event_source(AgentEventSource::new(queue)) + .build() + .unwrap(); + let (sender, mut output) = mpsc::channel(16); + let host = SdkHostConnection::new( + runtime, + "D:/workspace/project", + sender, + SdkHostConfig::default(), + fake_installer(), + ); + initialize(&host, &mut output).await; + let session_id = "00000000-0000-4000-8000-000000000003"; + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "resume-failed-cleanup", + "method": "session/resume", + "params": { "sessionId": session_id } + }))) + .await; + let resume_error = output.recv().await.unwrap(); + assert_eq!(resume_error["error"]["data"]["code"], "cleanup_required"); + assert_eq!(owner.unload_requests.lock().unwrap().len(), 1); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "blocked-after-failed-cleanup", + "method": "session/create", + "params": {} + }))) + .await; + let blocked = output.recv().await.unwrap(); + assert_eq!(blocked["error"]["data"]["code"], "cleanup_required"); host.shutdown_connection().await; + assert_eq!(owner.unload_requests.lock().unwrap().len(), 2); } #[tokio::test] @@ -1024,6 +1824,7 @@ async fn dialog_session_identity_mismatch_releases_the_requested_session_reserva "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1143,6 +1944,7 @@ async fn uncertain_session_close_cleanup_requires_host_restart() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; host.handle_request(request(serde_json::json!({ @@ -1237,6 +2039,7 @@ async fn cancellation_remains_available_when_data_request_capacity_is_exhausted( max_in_flight_control_requests: 1, ..SdkHostConfig::default() }, + fake_installer(), ); initialize(&host, &mut output).await; @@ -1277,7 +2080,7 @@ async fn cancellation_remains_available_when_data_request_capacity_is_exhausted( } #[tokio::test] -async fn connection_loss_discards_owned_transient_sessions_through_core_port() { +async fn connection_loss_unloads_owned_durable_sessions_through_core_port() { let (host, owner, mut output) = host().await; initialize(&host, &mut output).await; host.handle_request(request(serde_json::json!({ @@ -1289,18 +2092,18 @@ async fn connection_loss_discards_owned_transient_sessions_through_core_port() { .await; let created = output.recv().await.unwrap(); assert_eq!(created["id"], "create-1"); - assert_eq!(created["result"]["lifetime"], "connection"); + assert_eq!(created["result"]["lifetime"], "durable"); let session_id = created["result"]["sessionId"].as_str().unwrap(); host.shutdown_connection().await; - let requests = owner.discard_requests.lock().unwrap(); + let requests = owner.unload_requests.lock().unwrap(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].session_id, session_id); } #[tokio::test] -async fn existing_durable_session_is_not_adopted_without_cross_process_fencing() { +async fn query_start_does_not_implicitly_resume_a_durable_session() { let (host, owner, mut output) = host().await; initialize(&host, &mut output).await; host.handle_request(request(serde_json::json!({ @@ -1324,7 +2127,7 @@ async fn existing_durable_session_is_not_adopted_without_cross_process_fencing() } #[tokio::test] -async fn visible_session_create_response_is_exposed_before_shutdown_cleanup() { +async fn visible_session_create_response_commits_before_immediate_close() { let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let owner = Arc::new(FakeOwner::with_queue(queue.clone())); let runtime = AgentRuntimeBuilder::new() @@ -1350,6 +2153,7 @@ async fn visible_session_create_response_is_exposed_before_shutdown_cleanup() { release_response: release_response.clone(), }), SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1365,21 +2169,37 @@ async fn visible_session_create_response_is_exposed_before_shutdown_cleanup() { .await }); response_visible.notified().await; - assert_eq!(output.recv().await.unwrap()["id"], "visible-create"); + let visible = output.recv().await.unwrap(); + assert_eq!(visible["id"], "visible-create"); + let session_id = visible["result"]["sessionId"].as_str().unwrap().to_string(); - let shutdown_host = host.clone(); - let mut shutdown = tokio::spawn(async move { shutdown_host.shutdown_connection().await }); + let close_host = host.clone(); + let close = tokio::spawn(async move { + close_host + .handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "close-before-create-delivery-commits", + "method": "session/close", + "params": { "sessionId": session_id } + }))) + .await + }); assert!( - tokio::time::timeout(Duration::from_millis(50), &mut shutdown) + tokio::time::timeout(Duration::from_millis(50), output.recv()) .await .is_err(), - "shutdown must wait until response visibility is committed" + "close must wait until response visibility is committed" ); release_response.notify_one(); assert_eq!(create.await.unwrap(), ConnectionControl::Continue); - shutdown.await.unwrap(); - assert_eq!(owner.discard_requests.lock().unwrap().len(), 1); + assert_eq!(close.await.unwrap(), ConnectionControl::Continue); + let closed = output.recv().await.unwrap(); + assert_eq!(closed["result"]["unloaded"], true); + assert_eq!(owner.unload_requests.lock().unwrap().len(), 1); + assert!(owner.delete_requests.lock().unwrap().is_empty()); + + host.shutdown_connection().await; } #[tokio::test] @@ -1403,6 +2223,7 @@ async fn shutdown_waits_for_in_flight_session_creation_then_cleans_it() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1431,7 +2252,7 @@ async fn shutdown_waits_for_in_flight_session_creation_then_cleans_it() { assert_eq!(create.await.unwrap(), ConnectionControl::Continue); shutdown.await.unwrap(); - let deleted = owner.discard_requests.lock().unwrap(); + let deleted = owner.delete_requests.lock().unwrap(); assert_eq!(deleted.len(), 1); assert_eq!(deleted[0].session_id, owner.last_created_session_id()); } @@ -1457,6 +2278,7 @@ async fn shutdown_compensates_a_session_creation_task_that_panics_after_creation "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1473,9 +2295,9 @@ async fn shutdown_compensates_a_session_creation_task_that_panics_after_creation host.shutdown_connection_bounded(Duration::from_secs(1)) .await ); - let discarded = owner.discard_requests.lock().unwrap(); - assert_eq!(discarded.len(), 1); - assert_eq!(discarded[0].session_id, owner.last_created_session_id()); + let deleted = owner.delete_requests.lock().unwrap(); + assert_eq!(deleted.len(), 1); + assert_eq!(deleted[0].session_id, owner.last_created_session_id()); } #[tokio::test] @@ -1499,6 +2321,7 @@ async fn shutdown_reports_failure_when_post_panic_session_compensation_fails() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1516,7 +2339,7 @@ async fn shutdown_reports_failure_when_post_panic_session_compensation_fails() { .shutdown_connection_bounded(Duration::from_secs(1)) .await ); - assert_eq!(owner.discard_requests.lock().unwrap().len(), 1); + assert_eq!(owner.delete_requests.lock().unwrap().len(), 1); } #[tokio::test] @@ -1540,6 +2363,7 @@ async fn a_later_request_registers_panicked_session_cleanup_for_shutdown() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1565,15 +2389,15 @@ async fn a_later_request_registers_panicked_session_cleanup_for_shutdown() { cleanup_required["error"]["data"]["code"], "cleanup_required" ); - assert!(owner.discard_requests.lock().unwrap().is_empty()); + assert!(owner.delete_requests.lock().unwrap().is_empty()); assert!( host.shutdown_connection_bounded(Duration::from_secs(1)) .await ); - let discarded = owner.discard_requests.lock().unwrap(); - assert_eq!(discarded.len(), 1); - assert_eq!(discarded[0].session_id, owner.last_created_session_id()); + let deleted = owner.delete_requests.lock().unwrap(); + assert_eq!(deleted.len(), 1); + assert_eq!(deleted[0].session_id, owner.last_created_session_id()); } #[tokio::test] @@ -1597,6 +2421,7 @@ async fn shutdown_does_not_forget_cleanup_registered_by_a_later_request() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1627,9 +2452,9 @@ async fn shutdown_does_not_forget_cleanup_registered_by_a_later_request() { .shutdown_connection_bounded(Duration::from_secs(1)) .await ); - let discarded = owner.discard_requests.lock().unwrap(); - assert_eq!(discarded.len(), 1); - assert_eq!(discarded[0].session_id, owner.last_created_session_id()); + let deleted = owner.delete_requests.lock().unwrap(); + assert_eq!(deleted.len(), 1); + assert_eq!(deleted[0].session_id, owner.last_created_session_id()); } #[tokio::test] @@ -1675,7 +2500,7 @@ async fn existing_transient_session_cannot_be_adopted_as_durable() { assert_eq!(error["error"]["data"]["code"], "capability_unavailable"); assert!(error["error"]["message"] .as_str() - .is_some_and(|message| message.contains("same SDK Host connection"))); + .is_some_and(|message| message.contains("created or resumed"))); } #[tokio::test] @@ -1699,6 +2524,7 @@ async fn failed_implicit_query_submission_deletes_the_unexposed_session() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1738,6 +2564,7 @@ async fn shutdown_takes_over_failed_query_start_cleanup_within_its_total_budget( "D:/workspace/project", Arc::new(FailQueryStartOutput { output: sender }), SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1790,6 +2617,7 @@ async fn failed_unexposed_session_cleanup_poison_connection_and_allows_shutdown( "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -1900,6 +2728,7 @@ async fn uncertain_turn_settlement_fails_the_connection_without_a_result() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); let connection_failed = host.connection_failed_token(); initialize(&host, &mut output).await; @@ -2024,6 +2853,7 @@ async fn queued_query_is_accepted_and_tracked_by_its_exact_turn() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; @@ -2035,7 +2865,7 @@ async fn queued_query_is_accepted_and_tracked_by_its_exact_turn() { }))) .await; let created = output.recv().await.unwrap(); - assert_eq!(created["result"]["lifetime"], "connection"); + assert_eq!(created["result"]["lifetime"], "durable"); let session_id = created["result"]["sessionId"] .as_str() .expect("created Session id") @@ -2123,6 +2953,7 @@ async fn session_close_rejects_while_query_start_is_in_flight() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; host.handle_request(request(serde_json::json!({ @@ -2192,6 +3023,7 @@ async fn query_start_rejects_if_session_close_finishes_before_reservation() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; host.handle_request(request(serde_json::json!({ @@ -2241,7 +3073,7 @@ async fn query_start_rejects_if_session_close_finishes_before_reservation() { } #[tokio::test] -async fn permission_without_callback_is_rejected_and_finishes_action_required() { +async fn permission_request_is_streamed_and_can_be_allowed_once() { let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let owner = Arc::new(FakeOwner::without_terminal(queue.clone())); let permissions = permission_manager(); @@ -2261,7 +3093,11 @@ async fn permission_without_callback_is_rejected_and_finishes_action_required() runtime, "D:/workspace/project", sender, - SdkHostConfig::default(), + SdkHostConfig { + permission_response_timeout: Duration::from_millis(250), + ..SdkHostConfig::default() + }, + fake_installer(), ); initialize(&host, &mut output).await; host.handle_request(request(serde_json::json!({ @@ -2279,25 +3115,7 @@ async fn permission_without_callback_is_rejected_and_finishes_action_required() .to_string(); assert_eq!(output.recv().await.unwrap()["method"], "query/event"); - let permission_request = PermissionRequest { - request_id: "permission-fixture".to_string(), - round_id: "round-fixture".to_string(), - order: 0, - tool_call_id: Some("tool-fixture".to_string()), - project_path: Some("D:/workspace/project".to_string()), - project_id: "project-fixture".to_string(), - session_id, - agent_id: "agentic".to_string(), - action: "edit".to_string(), - resources: vec!["src/lib.rs".to_string()], - save_resources: Vec::new(), - source: PermissionRequestSource { - kind: PermissionRequestSourceKind::ToolCall, - identity: "edit".to_string(), - }, - delegation: None, - display_metadata: serde_json::Map::new(), - }; + let permission_request = permission_request_fixture("permission-fixture", 0, &session_id); let unrelated = permissions .register_batch_for_turn( vec![PermissionRequest { @@ -2325,31 +3143,174 @@ async fn permission_without_callback_is_rejected_and_finishes_action_required() )); let pending = permissions - .register_batch_for_turn(vec![permission_request], "turn-fixture") + .register_batch_for_turn(vec![permission_request.clone()], "turn-fixture") .await .unwrap() .pop() .unwrap(); - let result = loop { - let value = output.recv().await.unwrap(); - if value["method"] == "query/result" { - break value; + let permission = output.recv().await.unwrap(); + assert_eq!(permission["method"], "query/event"); + assert_eq!(permission["params"]["event"]["type"], "permission_request"); + assert_eq!( + permission["params"]["event"]["requestId"], + "permission-fixture" + ); + assert_eq!(permission["params"]["event"]["action"], "edit"); + assert_eq!( + permission["params"]["event"]["resources"], + serde_json::json!(["src/lib.rs"]) + ); + assert_eq!(permission["params"]["event"]["source"]["kind"], "tool_call"); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "permission-response", + "method": "permission/respond", + "params": { + "queryId": accepted["result"]["queryId"], + "sessionId": accepted["result"]["sessionId"], + "turnId": accepted["result"]["turnId"], + "operationId": accepted["result"]["operationId"], + "requestId": "permission-fixture", + "decision": "allow_once" } - }; - assert_eq!(result["params"]["status"], "failed"); - assert_eq!(result["params"]["error"]["data"]["code"], "action_required"); + }))) + .await; + let response = output.recv().await.unwrap(); + assert_eq!(response["result"]["accepted"], true); + assert_eq!(response["result"]["requestId"], "permission-fixture"); + let resolution = pending.wait().await; assert!(matches!( resolution, bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( - bitfun_agent_runtime::sdk::PermissionReply::Reject { .. } + bitfun_agent_runtime::sdk::PermissionReply::Once + ) + )); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "duplicate-permission-response", + "method": "permission/respond", + "params": { + "queryId": accepted["result"]["queryId"], + "sessionId": accepted["result"]["sessionId"], + "turnId": accepted["result"]["turnId"], + "operationId": accepted["result"]["operationId"], + "requestId": "permission-fixture", + "decision": "allow_once" + } + }))) + .await; + assert_eq!( + output.recv().await.unwrap()["error"]["data"]["code"], + "not_found" + ); + + let rejected = permissions + .register_batch_for_turn( + vec![permission_request_fixture( + "permission-rejected", + 1, + accepted["result"]["sessionId"].as_str().unwrap(), + )], + "turn-fixture", + ) + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!( + output.recv().await.unwrap()["params"]["event"]["requestId"], + "permission-rejected" + ); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "wrong-query-identity", + "method": "permission/respond", + "params": { + "queryId": accepted["result"]["queryId"], + "sessionId": accepted["result"]["sessionId"], + "turnId": accepted["result"]["turnId"], + "operationId": "another-operation", + "requestId": "permission-rejected", + "decision": "reject", + "feedback": "not needed" + } + }))) + .await; + assert_eq!( + output.recv().await.unwrap()["error"]["data"]["code"], + "invalid_request" + ); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "reject-permission", + "method": "permission/respond", + "params": { + "queryId": accepted["result"]["queryId"], + "sessionId": accepted["result"]["sessionId"], + "turnId": accepted["result"]["turnId"], + "operationId": accepted["result"]["operationId"], + "requestId": "permission-rejected", + "decision": "reject", + "feedback": "not needed" + } + }))) + .await; + assert_eq!(output.recv().await.unwrap()["result"]["accepted"], true); + assert!(matches!( + rejected.wait().await, + bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( + bitfun_agent_runtime::sdk::PermissionReply::Reject { feedback } + ) if feedback.as_deref() == Some("not needed") + )); + + let expired = permissions + .register_batch_for_turn( + vec![permission_request_fixture( + "permission-expired", + 2, + accepted["result"]["sessionId"].as_str().unwrap(), + )], + "turn-fixture", ) + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!( + output.recv().await.unwrap()["params"]["event"]["requestId"], + "permission-expired" + ); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), expired.wait()) + .await + .expect("permission timeout must settle through the Runtime owner"), + bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( + bitfun_agent_runtime::sdk::PermissionReply::Reject { feedback } + ) if feedback.as_deref() == Some("SDK permission response timed out") )); + + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "cancel-after-permission", + "method": "query/cancel", + "params": { + "queryId": accepted["result"]["queryId"], + "sessionId": accepted["result"]["sessionId"], + "turnId": accepted["result"]["turnId"], + "operationId": accepted["result"]["operationId"] + } + }))) + .await; } #[tokio::test] -async fn stalled_permission_rejection_is_bounded_and_cancels_the_exact_turn() { +async fn stalled_user_permission_response_is_bounded_and_cancels_the_exact_turn() { let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let owner = Arc::new(FakeOwner::without_terminal(queue.clone())); let permissions = blocking_permission_manager(); @@ -2370,6 +3331,7 @@ async fn stalled_permission_rejection_is_bounded_and_cancels_the_exact_turn() { "D:/workspace/project", sender, SdkHostConfig::default(), + fake_installer(), ); initialize(&host, &mut output).await; host.handle_request(request(serde_json::json!({ @@ -2385,28 +3347,15 @@ async fn stalled_permission_rejection_is_bounded_and_cancels_the_exact_turn() { .as_str() .unwrap() .to_string(); + assert_eq!(output.recv().await.unwrap()["method"], "query/event"); let _pending = permissions .register_batch_for_turn( - vec![PermissionRequest { - request_id: "permission-stalled".to_string(), - round_id: "round-fixture".to_string(), - order: 0, - tool_call_id: Some("tool-fixture".to_string()), - project_path: Some("D:/workspace/project".to_string()), - project_id: "project-fixture".to_string(), - session_id, - agent_id: "agentic".to_string(), - action: "edit".to_string(), - resources: vec!["src/lib.rs".to_string()], - save_resources: Vec::new(), - source: PermissionRequestSource { - kind: PermissionRequestSourceKind::ToolCall, - identity: "edit".to_string(), - }, - delegation: None, - display_metadata: serde_json::Map::new(), - }], + vec![permission_request_fixture( + "permission-stalled", + 0, + &session_id, + )], "turn-fixture", ) .await @@ -2414,17 +3363,46 @@ async fn stalled_permission_rejection_is_bounded_and_cancels_the_exact_turn() { .pop() .unwrap(); - let result = tokio::time::timeout(Duration::from_secs(5), async { + let permission = output.recv().await.unwrap(); + assert_eq!( + permission["params"]["event"]["requestId"], + "permission-stalled" + ); + host.handle_request(request(serde_json::json!({ + "jsonrpc": "2.0", + "id": "stalled-permission-response", + "method": "permission/respond", + "params": { + "queryId": accepted["result"]["queryId"], + "sessionId": accepted["result"]["sessionId"], + "turnId": accepted["result"]["turnId"], + "operationId": accepted["result"]["operationId"], + "requestId": "permission-stalled", + "decision": "allow_once" + } + }))) + .await; + + let (result, response) = tokio::time::timeout(Duration::from_secs(5), async { + let mut result = None; + let mut response = None; loop { let value = output.recv().await.unwrap(); if value["method"] == "query/result" { - break value; + result = Some(value); + } else if value["id"] == "stalled-permission-response" { + response = Some(value); + } + if result.is_some() && response.is_some() { + break (result.unwrap(), response.unwrap()); } } }) .await .expect("permission rejection must remain bounded"); assert_eq!(result["params"]["error"]["data"]["code"], "timeout"); + assert_eq!(response["error"]["data"]["code"], "timeout"); + assert_eq!(response["error"]["data"]["retryable"], false); assert_eq!( owner.cancel_requests.lock().unwrap()[0].turn_id.as_deref(), Some("turn-fixture") diff --git a/src/crates/interfaces/sdk-host/tests/protocol_contracts.rs b/src/crates/interfaces/sdk-host/tests/protocol_contracts.rs index 93eb25860e..2506e24109 100644 --- a/src/crates/interfaces/sdk-host/tests/protocol_contracts.rs +++ b/src/crates/interfaces/sdk-host/tests/protocol_contracts.rs @@ -1,50 +1,98 @@ use bitfun_sdk_host::protocol::{ ErrorCode, ErrorData, ErrorStage, HostCapabilities, InitializeParams, InitializeResult, - JsonRpcErrorResponse, JsonRpcRequest, JsonRpcSuccessResponse, OutcomeCertainty, QueryEvent, - QueryOutput, QueryResultError, QueryResultParams, QueryTerminalStatus, RecoveryAction, - RequestId, SessionLifetime, Stability, PROTOCOL_VERSION, + JsonRpcErrorResponse, JsonRpcRequest, JsonRpcSuccessResponse, OutcomeCertainty, + PermissionDecision, PermissionRespondParams, PermissionSource, PermissionSourceKind, + QueryEvent, QueryOutput, QueryResultError, QueryResultParams, QueryStartParams, + QueryTerminalStatus, QueryUsage, RecoveryAction, RequestId, SessionLifetime, + SessionResumeParams, Stability, TemporaryModelConfig, TemporaryModelProvider, ToolEventStatus, + PROTOCOL_VERSION, }; #[test] -fn initialize_contract_is_versioned_and_uses_familiar_capability_names() { +fn initialize_contract_is_versioned_and_binds_one_temporary_model() { let request: JsonRpcRequest = serde_json::from_value(serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { - "protocolVersion": 1, + "protocolVersion": 5, "clientInfo": { "name": "fixture", "version": "0.1.0" }, - "capabilities": { "serverNotifications": true } + "capabilities": { + "serverNotifications": true, + "permissionResponses": true + }, + "model": { + "provider": "openai", + "model": "fixture-model", + "apiKey": "fixture-secret", + "baseUrl": "http://127.0.0.1:43123/v1" + } } })) .unwrap(); let params: InitializeParams = request.params_as().unwrap(); assert_eq!(request.id, Some(RequestId::Number(1))); + assert_eq!(PROTOCOL_VERSION, 5); assert_eq!(params.protocol_version, PROTOCOL_VERSION); assert!(params.capabilities.server_notifications); + assert!(params.capabilities.permission_responses); + assert_eq!(params.model.provider, TemporaryModelProvider::Openai); + assert_eq!(params.model.model, "fixture-model"); + assert_eq!(params.model.api_key, "fixture-secret"); + assert_eq!( + params.model.base_url.as_deref(), + Some("http://127.0.0.1:43123/v1") + ); - let result = InitializeResult::current("0.2.13"); + for provider in ["openai", "responses", "anthropic", "gemini"] { + let model: TemporaryModelConfig = serde_json::from_value(serde_json::json!({ + "provider": provider, + "model": "fixture-model", + "apiKey": "fixture-secret" + })) + .unwrap(); + assert_eq!( + serde_json::to_value(model.provider).unwrap(), + serde_json::json!(provider) + ); + } + assert!( + serde_json::from_value::(serde_json::json!({ + "provider": "unknown", + "model": "fixture-model", + "apiKey": "fixture-secret" + })) + .is_err() + ); + + let result = InitializeResult::current("0.2.13", "sdk:openai:fixture"); assert_eq!(result.protocol_version, PROTOCOL_VERSION); assert_eq!(result.stability, Stability::NotDelivered); assert_eq!( result.capabilities, HostCapabilities { session_create: true, - session_create_lifetime: SessionLifetime::Connection, + session_create_lifetime: SessionLifetime::Durable, + session_resume: true, query: true, query_cancel: true, session_close: true, event_stream: true, + tool_events: true, + image_input: true, structured_output: false, - usage: false, + usage: true, custom_tools: false, - permission_callbacks: false, + permission_responses: true, hooks: false, mcp_configuration: false, prestarted_transport: false, } ); + let result_json = serde_json::to_string(&result).unwrap(); + assert!(result_json.contains("\"modelId\":\"sdk:openai:fixture\"")); + assert!(!result_json.contains("fixture-secret")); } #[test] @@ -56,20 +104,59 @@ fn current_host_capabilities_are_a_deliberate_subset_of_the_headless_cli_target( assert!(capabilities.query_cancel); assert!(capabilities.session_close); assert!(capabilities.event_stream); + assert!(capabilities.tool_events); + assert!(capabilities.image_input); assert_eq!( capabilities.session_create_lifetime, - SessionLifetime::Connection + SessionLifetime::Durable ); assert!(!capabilities.structured_output); - assert!(!capabilities.usage); + assert!(capabilities.usage); assert!(!capabilities.custom_tools); - assert!(!capabilities.permission_callbacks); + assert!(capabilities.permission_responses); assert!(!capabilities.hooks); assert!(!capabilities.mcp_configuration); assert!(!capabilities.prestarted_transport); } +#[test] +fn query_input_carries_only_text_and_local_image_paths() { + let params: QueryStartParams = serde_json::from_value(serde_json::json!({ + "prompt": "describe these images", + "images": ["screenshots/one.png", "D:/captures/two.jpg"] + })) + .unwrap(); + + assert_eq!(params.prompt, "describe these images"); + assert_eq!( + params.images, + vec!["screenshots/one.png", "D:/captures/two.jpg"] + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "prompt": "unsupported payload", + "imageUrl": "https://example.com/image.png" + })) + .is_err() + ); +} + +#[test] +fn durable_sessions_have_one_minimal_resume_contract() { + let params: SessionResumeParams = serde_json::from_value(serde_json::json!({ + "sessionId": "session-1" + })) + .unwrap(); + + assert_eq!(params.session_id, "session-1"); + assert_eq!( + serde_json::to_value(SessionLifetime::Durable).unwrap(), + serde_json::json!("durable") + ); + assert!(HostCapabilities::current().session_resume); +} + #[test] fn query_events_and_terminal_errors_are_closed_protocol_values() { let event = serde_json::to_value(QueryEvent::AssistantTextDelta { @@ -81,6 +168,55 @@ fn query_events_and_terminal_errors_are_closed_protocol_values() { serde_json::json!({ "type": "assistant_text_delta", "text": "hello" }) ); + let tool_event = serde_json::to_value(QueryEvent::ToolEvent { + tool_call_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + status: ToolEventStatus::Started, + progress: None, + duration_ms: None, + }) + .unwrap(); + assert_eq!( + tool_event, + serde_json::json!({ + "type": "tool_event", + "toolCallId": "tool-1", + "toolName": "Read", + "status": "started" + }) + ); + assert!(tool_event.get("params").is_none()); + assert!(tool_event.get("result").is_none()); + + let permission_event = serde_json::to_value(QueryEvent::PermissionRequest { + request_id: "permission-1".to_string(), + action: "edit".to_string(), + resources: vec!["src/lib.rs".to_string()], + source: PermissionSource { + kind: PermissionSourceKind::ToolCall, + identity: "edit".to_string(), + }, + tool_call_id: Some("tool-1".to_string()), + response_timeout_ms: 120_000, + }) + .unwrap(); + assert_eq!(permission_event["type"], "permission_request"); + assert_eq!(permission_event["requestId"], "permission-1"); + assert_eq!(permission_event["source"]["kind"], "tool_call"); + assert_eq!(permission_event["responseTimeoutMs"], 120_000); + + let respond: PermissionRespondParams = serde_json::from_value(serde_json::json!({ + "queryId": "query-1", + "sessionId": "session-1", + "turnId": "turn-1", + "operationId": "operation-1", + "requestId": "permission-1", + "decision": "allow_once" + })) + .unwrap(); + assert_eq!(respond.decision, PermissionDecision::AllowOnce); + assert!(respond.feedback.is_none()); + let result = serde_json::to_value(QueryResultParams { query_id: "query-1".to_string(), session_id: "session-1".to_string(), @@ -90,6 +226,12 @@ fn query_events_and_terminal_errors_are_closed_protocol_values() { output: QueryOutput { text: "partial response".to_string(), }, + usage: Some(QueryUsage { + input_tokens: 100, + output_tokens: Some(25), + total_tokens: 125, + cached_tokens: Some(40), + }), error: Some(QueryResultError { message: "Permission approval is required".to_string(), data: ErrorData { @@ -109,6 +251,8 @@ fn query_events_and_terminal_errors_are_closed_protocol_values() { assert_eq!(result["error"]["data"]["stage"], "query"); assert_eq!(result["operationId"], "operation-1"); assert_eq!(result["output"]["text"], "partial response"); + assert_eq!(result["usage"]["inputTokens"], 100); + assert_eq!(result["usage"]["cachedTokens"], 40); assert_eq!(result["error"]["data"]["outcomeCertainty"], "committed"); assert_eq!( result["error"]["message"], @@ -192,3 +336,30 @@ fn request_correlation_ids_preserve_json_rpc_id_type() { "request:string:1" ); } + +#[test] +fn json_rpc_request_debug_redacts_temporary_model_secret() { + let request: JsonRpcRequest = serde_json::from_value(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 3, + "clientInfo": { "name": "fixture", "version": "0.1.0" }, + "capabilities": { + "serverNotifications": true, + "permissionResponses": true + }, + "model": { + "provider": "openai", + "model": "fixture-model", + "apiKey": "bitfun-sdk-debug-secret-31d4" + } + } + })) + .unwrap(); + + let debug = format!("{request:?}"); + assert!(debug.contains("initialize")); + assert!(!debug.contains("bitfun-sdk-debug-secret-31d4")); +} diff --git a/src/crates/services/miniapp-market-service/Cargo.toml b/src/crates/services/miniapp-market-service/Cargo.toml index c221641e79..eb939c9c47 100644 --- a/src/crates/services/miniapp-market-service/Cargo.toml +++ b/src/crates/services/miniapp-market-service/Cargo.toml @@ -15,7 +15,7 @@ base64 = { workspace = true } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["miniapp"] } chrono = { workspace = true } hex = { workspace = true } -image = { workspace = true } +image = { workspace = true, features = ["jpeg", "png", "webp"] } rand = { workspace = true } reqwest = { workspace = true, features = ["form", "http2", "json", "rustls"] } serde = { workspace = true } diff --git a/src/crates/services/miniapp-market-service/src/package.rs b/src/crates/services/miniapp-market-service/src/package.rs index 2be4f7eec9..0e7d62dde8 100644 --- a/src/crates/services/miniapp-market-service/src/package.rs +++ b/src/crates/services/miniapp-market-service/src/package.rs @@ -596,12 +596,10 @@ mod tests { #[test] fn screenshot_rejects_formats_outside_png_jpeg_and_webp() { - let mut output = Cursor::new(Vec::new()); - image::DynamicImage::new_rgb8(2, 2) - .write_to(&mut output, image::ImageFormat::Bmp) - .unwrap(); assert_eq!( - validate_screenshot(&output.into_inner()).unwrap_err().code, + validate_screenshot(b"BM\0\0\0\0\0\0\0\0\0\0") + .unwrap_err() + .code, "unsupported_screenshot_format" ); } diff --git a/src/crates/services/page-function-runtime/Cargo.toml b/src/crates/services/page-function-runtime/Cargo.toml index 4a3d62aad2..c116350878 100644 --- a/src/crates/services/page-function-runtime/Cargo.toml +++ b/src/crates/services/page-function-runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-page-function-runtime" -version = "0.2.18" +version = "0.2.19" authors = ["BitFun Team"] edition = "2021" description = "Embedded JS Page Function runtime for BitFun Pages (rquickjs)" diff --git a/src/crates/services/relay-service/Cargo.toml b/src/crates/services/relay-service/Cargo.toml index 5a0d96a8cd..8ed5038ff6 100644 --- a/src/crates/services/relay-service/Cargo.toml +++ b/src/crates/services/relay-service/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-relay-service" -version = "0.2.18" +version = "0.2.19" authors = ["BitFun Team"] edition = "2021" description = "Reusable relay runtime for BitFun Remote Connect" diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index ba6f95a9e9..d313b55bcc 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -41,10 +41,6 @@ rusqlite = { version = "0.32", features = ["bundled"], optional = true } win32job = { workspace = true, optional = true } windows = { workspace = true, optional = true } -# Keep libgit2 self-contained on Unix, matching the product assembly dependency. -[target.'cfg(not(windows))'.dependencies] -git2 = { workspace = true, features = ["vendored-openssl"], optional = true } - [target.'cfg(unix)'.dependencies] libc = { workspace = true, optional = true } diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index c21101b14f..b3cf31adc5 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } log = { workspace = true } ts-rs = { workspace = true, optional = true } bitfun-agent-runtime = { path = "../../execution/agent-runtime", optional = true } +bitfun-agent-workflows = { path = "../../execution/agent-workflows", optional = true } bitfun-agent-tools = { path = "../../execution/tool-contracts", optional = true } bitfun-events = { path = "../../contracts/events" } bitfun-core-types = { path = "../../contracts/core-types", optional = true } @@ -76,9 +77,6 @@ ssh_config = { workspace = true, optional = true } terminal-core = { path = "../terminal", optional = true } x25519-dalek = { workspace = true, optional = true } -[target.'cfg(not(windows))'.dependencies] -git2 = { workspace = true, features = ["vendored-openssl"], optional = true } - [target.'cfg(target_os = "macos")'.dependencies] apple-native-keyring-store = { workspace = true, optional = true } @@ -88,8 +86,6 @@ windows-native-keyring-store = { workspace = true, optional = true } windows = { workspace = true, optional = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", - "Win32_System_Diagnostics_ToolHelp", - "Win32_System_Threading", ] } [target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))'.dependencies] @@ -124,8 +120,7 @@ canvas-runtime = [ deep-research = [ "anyhow", "async-trait", - "dep:bitfun-agent-runtime", - "bitfun-agent-runtime/deep-research", + "dep:bitfun-agent-workflows", "bitfun-runtime-ports/workspace-ports", ] git = [ @@ -203,6 +198,10 @@ miniapp-market = [ "dep:zbus-secret-service-keyring-store", "hex", "image", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", "miniapp-runtime", "reqwest", "reqwest/json", @@ -263,6 +262,11 @@ remote-connect = [ "hostname", "hex", "image", + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", "local-ip-address", "mac_address", "md5", @@ -284,7 +288,8 @@ remote-connect = [ "tokio/rt", "tokio/sync", "tokio/time", - "tokio-tungstenite", + "dep:tokio-tungstenite", + "tokio-tungstenite?/rustls-tls-native-roots", "urlencoding", "uuid", "which", diff --git a/src/crates/services/services-integrations/src/deep_research.rs b/src/crates/services/services-integrations/src/deep_research.rs index 6a9fbb59a4..9aba30d1f4 100644 --- a/src/crates/services/services-integrations/src/deep_research.rs +++ b/src/crates/services/services-integrations/src/deep_research.rs @@ -1,9 +1,9 @@ //! Citation renumbering hook for finalized DeepResearch reports. //! //! This module owns the best-effort filesystem hook and sidecar persistence. -//! The deterministic report rewrite stays in `bitfun-agent-runtime`. +//! The deterministic report rewrite stays in `bitfun-agent-workflows`. -use bitfun_agent_runtime::deep_research::{ +use bitfun_agent_workflows::deep_research::{ renumber_research_report, ResearchCitationDisplayMapEntry, }; use bitfun_runtime_ports::WorkspaceFileSystem; diff --git a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs index b21779e36f..7bb2cbfcfb 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs @@ -17,6 +17,9 @@ pub fn create_mcp_client_info( .enable_sampling() .enable_elicitation() .build(); - ClientInfo::new(capabilities, Implementation::new(client_name, client_version)) - .with_protocol_version(ProtocolVersion::LATEST) + ClientInfo::new( + capabilities, + Implementation::new(client_name, client_version), + ) + .with_protocol_version(ProtocolVersion::LATEST) } diff --git a/src/crates/services/services-integrations/src/review_platform.rs b/src/crates/services/services-integrations/src/review_platform.rs index dcd87661cb..472f65c963 100644 --- a/src/crates/services/services-integrations/src/review_platform.rs +++ b/src/crates/services/services-integrations/src/review_platform.rs @@ -38,6 +38,12 @@ const MAX_REVIEW_TARGET_PAGES: usize = 10; const MAX_REVIEW_TARGET_LIST_ITEMS: usize = MAX_REVIEW_TARGET_PAGES * 100; const MAX_REVIEW_TARGET_RESPONSE_BYTES: usize = 4 * 1024 * 1024; const MAX_REVIEW_FILE_DIFF_CHARS: usize = 80_000; +// GitCode truncates `GET /pulls/{number}/files` at 3,000 entries without a +// total-count header. The line-count headers are truncated with the body. +const GITCODE_PULL_REQUEST_FILES_RESPONSE_LIMIT: usize = 3_000; +// The whole file list, diffs included, arrives in that one response, so it needs +// a larger budget than a single pull request detail payload. +const GITCODE_PULL_REQUEST_FILES_RESPONSE_BYTES: usize = 16 * 1024 * 1024; const DEFAULT_ISSUE_PAGE: u32 = 1; const DEFAULT_ISSUE_PAGE_SIZE: u32 = 100; const MAX_ISSUE_PAGE_SIZE: u32 = 100; @@ -234,11 +240,19 @@ pub struct ReviewPlatformPullRequest { pub additions: i32, pub deletions: i32, pub changed_files: i32, + /// Whether `changed_files` is safe to present as an actual count. + /// Older payloads predate the unknown state and are treated as known. + #[serde(default = "default_changed_file_count_known")] + pub changed_file_count_known: bool, pub comments: i32, pub review_decision: ReviewDecision, pub checks: ReviewChecks, } +fn default_changed_file_count_known() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformFile { @@ -1833,62 +1847,65 @@ async fn gitcode_review_target_parts( ); let initial_detail = send_bounded_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; - let token = ctx.token.clone(); let files_url = format!("{}/files", base); - let files = fetch_bounded_paginated_array( - |page| { - let page = page.to_string(); - gitcode_request(client.clone(), &files_url, token.as_deref()) - .query(&[("per_page", "100"), ("page", &page)]) - }, - github_next_page, - MAX_REVIEW_TARGET_LIST_ITEMS, - ) + let files_response = send_bounded_gitcode_files_response(gitcode_request( + client.clone(), + &files_url, + ctx.token.as_deref(), + )) .await?; + let files = files_response + .value + .as_array() + .ok_or_else(|| { + ReviewPlatformError::Parse( + "GitCode pull request files response was not an array".to_string(), + ) + })? + .iter() + .take(MAX_REVIEW_TARGET_LIST_ITEMS) + .map(gitcode_file_from_value) + .collect::>(); let confirmed_detail = send_bounded_json(gitcode_request(client, &base, ctx.token.as_deref())).await?; let initial_pull_request = gitcode_pull_request_from_value(&initial_detail); - let confirmed_pull_request = gitcode_pull_request_from_value(&confirmed_detail); + let mut confirmed_pull_request = gitcode_pull_request_from_value(&confirmed_detail); ensure_pull_request_revisions_stable(&initial_pull_request, &confirmed_pull_request)?; - Ok(( - confirmed_pull_request, - array_items(&files) - .iter() - .map(gitcode_file_from_value) - .collect(), - )) + apply_gitcode_pull_request_change_stats(&mut confirmed_pull_request, &files_response); + Ok((confirmed_pull_request, files)) } +// The page hint is unusable here: GitCode answers `/files` with the whole list +// regardless of the page parameters, so the file is searched in that response. async fn gitcode_review_file_parts( ctx: &ProviderContext, pull_request_id: &str, file_path: &str, - file_page_hint: Option, + _file_page_hint: Option, ) -> Result<(ReviewPlatformPullRequest, Vec), ReviewPlatformError> { let client = http_client()?; let base = format!( "{}/repos/{}/{}/pulls/{}", ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id ); - let token = ctx.token.clone(); let files_url = format!("{}/files", base); - let file = fetch_bounded_paginated_file( - |page| { - let page = page.to_string(); - gitcode_request(client.clone(), &files_url, token.as_deref()) - .query(&[("per_page", "100"), ("page", &page)]) - }, - github_next_page, - file_page_hint.unwrap_or(1), - if file_page_hint.is_some() { - 100 - } else { - MAX_REVIEW_TARGET_LIST_ITEMS - }, - file_path, - gitcode_file_from_value, - ) - .await?; + let files = send_bounded_gitcode_files_response(gitcode_request( + client.clone(), + &files_url, + ctx.token.as_deref(), + )) + .await? + .value; + let file = files + .as_array() + .ok_or_else(|| { + ReviewPlatformError::Parse( + "GitCode pull request files response was not an array".to_string(), + ) + })? + .iter() + .map(gitcode_file_from_value) + .find(|file| file.path == file_path || file.old_path.as_deref() == Some(file_path)); let detail = send_bounded_json(gitcode_request(client, &base, ctx.token.as_deref())).await?; Ok(( gitcode_pull_request_from_value(&detail), @@ -2839,27 +2856,37 @@ async fn gitcode_pull_request_detail_page( let mut section_pagination = empty_detail_pagination(section, pagination); match section { - ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Overview => { + if let Ok(response) = send_bounded_gitcode_files_response(gitcode_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + )) + .await + { + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); + } + } ReviewPlatformDetailSection::Ci => { section_pagination = pagination_from_total(pagination, ci.len()); ci = slice_page(ci, pagination); } ReviewPlatformDetailSection::Files => { - if let Ok(response) = fetch_array_page( - gitcode_request( - client.clone(), - &format!("{}/files", base), - ctx.token.as_deref(), - ), - pagination, - ) + if let Ok(response) = send_bounded_gitcode_files_response(gitcode_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + )) .await { - section_pagination = pagination_from_response(&response, pagination); - files = array_items(&response.value) - .iter() - .map(gitcode_file_from_value) - .collect(); + if let Some(values) = response.value.as_array() { + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); + section_pagination = gitcode_files_pagination(pagination, values.len()); + files = slice_page( + values.iter().map(gitcode_file_from_value).collect(), + pagination, + ); + } } } ReviewPlatformDetailSection::Commits => { @@ -2955,7 +2982,7 @@ impl ReviewProvider for GitcodeProvider { .iter() .map(gitcode_pull_request_from_value) .collect::>(); - let pull_requests = enrich_gitcode_pull_request_counts(ctx, pull_requests).await; + let pull_requests = enrich_gitcode_pull_request_change_stats(ctx, pull_requests).await; Ok(ReviewPlatformPullRequestPage { items: pull_requests, @@ -2980,18 +3007,14 @@ impl ReviewProvider for GitcodeProvider { ); let detail = send_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; - let token = ctx.token.clone(); let files_url = format!("{}/files", base); - let files = fetch_paginated_array( - |page| { - let page = page.to_string(); - gitcode_request(client.clone(), &files_url, token.as_deref()) - .query(&[("per_page", "100"), ("page", &page)]) - }, - github_next_page, - ) + let files_response = send_bounded_gitcode_files_response(gitcode_request( + client.clone(), + &files_url, + ctx.token.as_deref(), + )) .await - .unwrap_or(Value::Array(Vec::new())); + .ok(); let token = ctx.token.clone(); let commits_url = format!("{}/commits", base); let commits = fetch_paginated_array( @@ -3019,6 +3042,19 @@ impl ReviewProvider for GitcodeProvider { let ci = gitcode_ci_items(&detail); let mut pull_request = gitcode_pull_request_from_value(&detail); pull_request.checks = summarize_ci_items(&ci); + let files = files_response + .as_ref() + .and_then(|response| response.value.as_array()) + .map(|values| { + values + .iter() + .map(gitcode_file_from_value) + .collect::>() + }) + .unwrap_or_default(); + if let Some(response) = files_response.as_ref() { + apply_gitcode_pull_request_change_stats(&mut pull_request, response); + } Ok(ReviewPlatformPullRequestDetail { body: first_non_empty(&[ @@ -3027,10 +3063,7 @@ impl ReviewProvider for GitcodeProvider { ]), pull_request, ci, - files: array_items(&files) - .iter() - .map(gitcode_file_from_value) - .collect(), + files, commits: array_items(&commits) .iter() .map(gitcode_commit_from_value) @@ -3214,6 +3247,15 @@ fn review_http_error(error: ReviewHttpError) -> ReviewPlatformError { } } +fn gitcode_files_http_error(error: ReviewHttpError) -> ReviewPlatformError { + match error { + ReviewHttpError::ResponseTooLarge { limit_bytes } => ReviewPlatformError::Api(format!( + "GitCode pull request files response exceeded the {limit_bytes}-byte limit" + )), + error => review_http_error(error), + } +} + async fn send_json(request: ReviewHttpRequest) -> Result { send_review_json(request).await.map_err(review_http_error) } @@ -3241,6 +3283,14 @@ async fn send_bounded_json_response( .map_err(review_http_error) } +async fn send_bounded_gitcode_files_response( + request: ReviewHttpRequest, +) -> Result { + send_review_json_response_bounded(request, GITCODE_PULL_REQUEST_FILES_RESPONSE_BYTES) + .await + .map_err(gitcode_files_http_error) +} + async fn send_bounded_text( request: ReviewHttpRequest, max_bytes: usize, @@ -3404,6 +3454,17 @@ fn pagination_from_total( } } +fn gitcode_files_pagination( + pagination: PullRequestPagination, + available_files: usize, +) -> ReviewPlatformPagination { + let mut result = pagination_from_total(pagination, available_files); + if available_files >= GITCODE_PULL_REQUEST_FILES_RESPONSE_LIMIT { + result.total = None; + } + result +} + fn slice_page(items: Vec, pagination: PullRequestPagination) -> Vec { let start = pagination .page @@ -3492,7 +3553,7 @@ async fn enrich_gitlab_pull_request_counts( .await } -async fn enrich_gitcode_pull_request_counts( +async fn enrich_gitcode_pull_request_change_stats( ctx: &ProviderContext, pull_requests: Vec, ) -> Vec { @@ -3502,17 +3563,16 @@ async fn enrich_gitcode_pull_request_counts( let futures = pull_requests.into_iter().map(|mut pull_request| { let client = client.clone(); let url = format!( - "{}/repos/{}/{}/pulls/{}", + "{}/repos/{}/{}/pulls/{}/files", ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request.id ); let token = ctx.token.clone(); async move { - if let Ok(value) = send_json(gitcode_request(client, &url, token.as_deref())).await { - let detail = gitcode_pull_request_from_value(&value); - pull_request.additions = detail.additions; - pull_request.deletions = detail.deletions; - pull_request.changed_files = detail.changed_files; - pull_request.comments = detail.comments; + if let Ok(response) = + send_bounded_gitcode_files_response(gitcode_request(client, &url, token.as_deref())) + .await + { + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); } pull_request } @@ -3523,6 +3583,36 @@ async fn enrich_gitcode_pull_request_counts( .await } +fn apply_gitcode_pull_request_change_stats( + pull_request: &mut ReviewPlatformPullRequest, + response: &JsonResponse, +) { + let Some(values) = response.value.as_array() else { + return; + }; + let file_count = i32::try_from(values.len()).unwrap_or(i32::MAX); + if values.len() >= GITCODE_PULL_REQUEST_FILES_RESPONSE_LIMIT { + if !pull_request.changed_file_count_known || pull_request.changed_files < file_count { + pull_request.changed_files = file_count; + pull_request.changed_file_count_known = false; + } + return; + } + let files = values + .iter() + .map(gitcode_file_from_value) + .collect::>(); + apply_files_stats(pull_request, &files); + pull_request.changed_files = file_count; + pull_request.changed_file_count_known = true; + if let Some(total) = header_u64(&response.headers, "total_added_lines") { + pull_request.additions = i32::try_from(total).unwrap_or(i32::MAX); + } + if let Some(total) = header_u64(&response.headers, "total_removed_lines") { + pull_request.deletions = i32::try_from(total).unwrap_or(i32::MAX); + } +} + fn gitlab_request(client: ReviewHttpClient, url: &str, token: Option<&str>) -> ReviewHttpRequest { let mut request = client .get(url) @@ -4574,6 +4664,7 @@ fn github_pull_request_from_gh_cli_value( additions: value_i64(value, "additions") as i32, deletions: value_i64(value, "deletions") as i32, changed_files: value_i64(value, "changedFiles") as i32, + changed_file_count_known: true, comments: value .get("comments") .and_then(Value::as_array) @@ -6438,6 +6529,7 @@ fn github_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { additions: value_i64(value, "additions") as i32, deletions: value_i64(value, "deletions") as i32, changed_files: value_i64(value, "changed_files") as i32, + changed_file_count_known: true, comments: (value_i64(value, "comments") + value_i64(value, "review_comments")) as i32, review_decision: ReviewDecision::Pending, checks: empty_checks(), @@ -6486,6 +6578,7 @@ fn gitlab_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { additions: 0, deletions: 0, changed_files, + changed_file_count_known: true, comments: value_i64(value, "user_notes_count") as i32, review_decision: ReviewDecision::Pending, checks: empty_checks(), @@ -6499,6 +6592,19 @@ fn gitcode_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { "closed" => ReviewItemState::Closed, _ => ReviewItemState::Open, }; + let changes_count = value_string(value, "changes_count"); + let changes_count = changes_count.trim(); + let changes_count_is_approximate = changes_count.ends_with('+'); + let changed_files_from_changes_count = changes_count.trim_end_matches('+').parse::().ok(); + let changed_files_from_legacy_field = value.get("changed_files").and_then(|count| { + count + .as_i64() + .or_else(|| count.as_str()?.parse::().ok()) + .and_then(|count| i32::try_from(count).ok()) + }); + let changed_files = changed_files_from_changes_count.or(changed_files_from_legacy_field); + let changed_file_count_known = changed_files.is_some() + && !(changed_files_from_changes_count.is_some() && changes_count_is_approximate); ReviewPlatformPullRequest { id: number.to_string(), provider_id: None, @@ -6532,15 +6638,28 @@ fn gitcode_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { value_string(value, "html_url"), value_string(value, "web_url"), ]), - additions: value_i64(value, "additions") as i32, - deletions: value_i64(value, "deletions") as i32, - changed_files: value_i64(value, "changed_files") as i32, + additions: gitcode_pull_request_line_count(value, "added_lines", "additions"), + deletions: gitcode_pull_request_line_count(value, "removed_lines", "deletions"), + changed_files: changed_files.unwrap_or(0), + changed_file_count_known, comments: value_i64(value, "comments") as i32, review_decision: ReviewDecision::Pending, checks: empty_checks(), } } +fn gitcode_pull_request_line_count( + value: &Value, + documented_field: &str, + legacy_field: &str, +) -> i32 { + if value.get(documented_field).is_some() { + value_i64(value, documented_field) as i32 + } else { + value_i64(value, legacy_field) as i32 + } +} + fn github_file_from_value(value: &Value) -> ReviewPlatformFile { ReviewPlatformFile { path: value_string(value, "filename"), @@ -6561,17 +6680,37 @@ fn gitcode_file_from_value(value: &Value) -> ReviewPlatformFile { value_string(value, "filename"), value_string(value, "new_path"), ]), - old_path: value - .get("previous_filename") - .and_then(Value::as_str) - .map(str::to_string), - status: file_status(&value_string(value, "status")), + old_path: optional_string(value, "old_path") + .or_else(|| optional_string(value, "previous_filename")), + status: gitcode_file_status(value), additions: value_i64(value, "additions") as i32, deletions: value_i64(value, "deletions") as i32, - patch: optional_string(value, "patch").or_else(|| optional_string(value, "diff")), + patch: gitcode_patch_from_value(value), } } +fn gitcode_file_status(value: &Value) -> ReviewFileStatus { + if value_bool(value, "new_file") { + ReviewFileStatus::Added + } else if value_bool(value, "deleted_file") { + ReviewFileStatus::Deleted + } else if value_bool(value, "renamed_file") { + ReviewFileStatus::Renamed + } else { + file_status(&value_string(value, "status")) + } +} + +fn gitcode_patch_from_value(value: &Value) -> Option { + optional_string(value, "patch") + .or_else(|| { + value + .get("patch") + .and_then(|patch| optional_string(patch, "diff")) + }) + .or_else(|| optional_string(value, "diff")) +} + fn gitlab_files(value: &Value) -> Vec { value .get("changes") @@ -8111,6 +8250,309 @@ mod tests { ); } + #[test] + fn gitcode_pull_request_maps_documented_change_counts() { + let pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "fix bugs", + "state": "open", + "added_lines": 133, + "removed_lines": 22, + "changes_count": "7", + "additions": 99, + "deletions": 99, + "changed_files": 99 + })); + + assert_eq!(pull_request.additions, 133); + assert_eq!(pull_request.deletions, 22); + assert_eq!(pull_request.changed_files, 7); + assert!(pull_request.changed_file_count_known); + } + + #[test] + fn gitcode_pull_request_marks_approximate_change_count_unknown() { + let pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "large change", + "state": "open", + "changes_count": "3000+" + })); + + assert_eq!(pull_request.changed_files, 3_000); + assert!(!pull_request.changed_file_count_known); + } + + #[test] + fn gitcode_pull_request_marks_missing_file_count_unknown() { + let pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "fix bugs", + "state": "open", + "added_lines": 133, + "removed_lines": 22 + })); + + assert_eq!(pull_request.changed_files, 0); + assert!(!pull_request.changed_file_count_known); + assert_eq!(pull_request.additions, 133); + assert_eq!(pull_request.deletions, 22); + } + + #[test] + fn gitcode_file_maps_nested_patch_and_change_flags() { + let file = gitcode_file_from_value(&json!({ + "filename": "src/new.rs", + "old_path": "src/old.rs", + "status": null, + "new_file": false, + "renamed_file": true, + "deleted_file": false, + "additions": 1, + "deletions": 1, + "patch": { "diff": "@@ -1 +1 @@\n-old\n+new" } + })); + + assert_eq!(file.path, "src/new.rs"); + assert_eq!(file.old_path.as_deref(), Some("src/old.rs")); + assert_eq!(file.status, ReviewFileStatus::Renamed); + assert_eq!(file.patch.as_deref(), Some("@@ -1 +1 @@\n-old\n+new")); + assert!(file_has_complete_patch(&file)); + } + + #[test] + fn gitcode_capped_files_pagination_does_not_claim_exact_total() { + let pagination = gitcode_files_pagination( + PullRequestPagination { + page: 60, + per_page: 50, + }, + GITCODE_PULL_REQUEST_FILES_RESPONSE_LIMIT, + ); + + assert_eq!(pagination.total, None); + assert!(!pagination.has_next); + } + + #[test] + fn gitcode_review_target_reports_the_files_the_budget_left_out() { + let mut pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "large change", + "state": "open", + "added_lines": 9_999, + "removed_lines": 8_888, + "changes_count": "2500" + })); + let response = JsonResponse { + value: Value::Array( + (0..2_500) + .map(|index| { + json!({ + "filename": format!("src/file-{index}.rs"), + "additions": "1", + "deletions": "2" + }) + }) + .collect(), + ), + headers: ReviewHttpHeaders::default(), + }; + let files = array_items(&response.value) + .iter() + .take(MAX_REVIEW_TARGET_LIST_ITEMS) + .map(gitcode_file_from_value) + .collect::>(); + + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); + let target = review_target_from_parts(pull_request, files); + + assert_eq!(target.pull_request.changed_files, 2_500); + assert!(target.pull_request.changed_file_count_known); + assert_eq!(target.pull_request.additions, 2_500); + assert_eq!(target.pull_request.deletions, 5_000); + assert_eq!(target.files.len(), MAX_REVIEW_TARGET_LIST_ITEMS); + assert_eq!(target.omitted_file_count, 1_500); + assert!(target + .limitations + .contains(&"provider_file_list_incomplete".to_string())); + } + + #[test] + fn gitcode_files_response_too_large_reports_explicit_reason() { + let error = gitcode_files_http_error(ReviewHttpError::ResponseTooLarge { + limit_bytes: GITCODE_PULL_REQUEST_FILES_RESPONSE_BYTES, + }); + + assert_eq!( + error.to_string(), + "Provider API failed: GitCode pull request files response exceeded the 16777216-byte limit" + ); + } + + #[test] + fn gitcode_file_response_overrides_incomplete_list_stats() { + let mut pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "fix bugs", + "state": "open", + "added_lines": 133, + "removed_lines": 22 + })); + let response = JsonResponse { + value: json!([ + { "filename": "src/a.rs", "additions": "5", "deletions": "2" }, + { "filename": "src/b.rs", "additions": "8", "deletions": "3" } + ]), + headers: ReviewHttpHeaders::default(), + }; + + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); + + assert_eq!(pull_request.changed_files, 2); + assert!(pull_request.changed_file_count_known); + assert_eq!(pull_request.additions, 13); + assert_eq!(pull_request.deletions, 5); + } + + #[test] + fn gitcode_empty_file_response_confirms_zero_files() { + let mut pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "no changes", + "state": "open" + })); + let response = JsonResponse { + value: json!([]), + headers: ReviewHttpHeaders::default(), + }; + + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); + + assert_eq!(pull_request.changed_files, 0); + assert!(pull_request.changed_file_count_known); + } + + #[test] + fn gitcode_non_array_file_response_does_not_fake_zero_files() { + let mut pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "fix bugs", + "state": "open", + "added_lines": 133, + "removed_lines": 22 + })); + let response = JsonResponse { + value: json!({ "message": "temporarily unavailable" }), + headers: ReviewHttpHeaders::default(), + }; + + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); + + assert_eq!(pull_request.changed_files, 0); + assert!(!pull_request.changed_file_count_known); + assert_eq!(pull_request.additions, 133); + assert_eq!(pull_request.deletions, 22); + } + + #[test] + fn pull_request_payload_without_known_flag_remains_backward_compatible() { + let pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "legacy payload", + "state": "open", + "changes_count": "0" + })); + let mut value = serde_json::to_value(pull_request).expect("serialize pull request"); + value + .as_object_mut() + .expect("pull request object") + .remove("changedFileCountKnown"); + + let decoded: ReviewPlatformPullRequest = + serde_json::from_value(value).expect("deserialize legacy pull request"); + + assert!(decoded.changed_file_count_known); + assert_eq!(decoded.changed_files, 0); + } + + #[test] + fn gitcode_file_response_prefers_total_line_headers() { + let mut pull_request = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "fix bugs", + "state": "open" + })); + let response = JsonResponse { + value: json!([ + { "filename": "src/a.rs", "additions": "5", "deletions": "2" }, + { "filename": "src/b.rs", "additions": "8", "deletions": "3" } + ]), + headers: ReviewHttpHeaders::from_pairs(&[ + ("total_added_lines", "133"), + ("total_removed_lines", "22"), + ]), + }; + + apply_gitcode_pull_request_change_stats(&mut pull_request, &response); + + assert_eq!(pull_request.changed_files, 2); + assert!(pull_request.changed_file_count_known); + assert_eq!(pull_request.additions, 133); + assert_eq!(pull_request.deletions, 22); + } + + #[test] + fn gitcode_capped_file_response_does_not_claim_truncated_stats() { + let response = JsonResponse { + value: Value::Array( + (0..GITCODE_PULL_REQUEST_FILES_RESPONSE_LIMIT) + .map(|index| { + json!({ + "filename": format!("src/{index}.rs"), + "additions": "1", + "deletions": "1" + }) + }) + .collect(), + ), + headers: ReviewHttpHeaders::from_pairs(&[ + ("total_added_lines", "1910"), + ("total_removed_lines", "116799"), + ]), + }; + let mut unknown_count = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "large change", + "state": "open", + "added_lines": 133, + "removed_lines": 22 + })); + + apply_gitcode_pull_request_change_stats(&mut unknown_count, &response); + + assert_eq!(unknown_count.changed_files, 3_000); + assert!(!unknown_count.changed_file_count_known); + assert_eq!(unknown_count.additions, 133); + assert_eq!(unknown_count.deletions, 22); + + let mut provider_count = gitcode_pull_request_from_value(&json!({ + "number": 5, + "title": "large change", + "state": "open", + "added_lines": 401011, + "removed_lines": 5219754, + "changes_count": "32202" + })); + + apply_gitcode_pull_request_change_stats(&mut provider_count, &response); + + assert_eq!(provider_count.changed_files, 32_202); + assert!(provider_count.changed_file_count_known); + assert_eq!(provider_count.additions, 401_011); + assert_eq!(provider_count.deletions, 5_219_754); + } + #[test] fn github_cli_pull_request_maps_open_user_list_fields() { let pull_request = github_pull_request_from_gh_cli_value( diff --git a/src/crates/services/services-integrations/src/review_platform_http.rs b/src/crates/services/services-integrations/src/review_platform_http.rs index c6a78da4dc..1017e74fde 100644 --- a/src/crates/services/services-integrations/src/review_platform_http.rs +++ b/src/crates/services/services-integrations/src/review_platform_http.rs @@ -129,6 +129,16 @@ impl ReviewHttpHeaders { .map(|(_, value)| value.as_str()) } + #[cfg(test)] + pub(crate) fn from_pairs(values: &[(&str, &str)]) -> Self { + Self { + values: values + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect(), + } + } + fn from_header_map(headers: &reqwest::header::HeaderMap) -> Self { let values = headers .iter() diff --git a/src/crates/services/skin-market-service/Cargo.toml b/src/crates/services/skin-market-service/Cargo.toml index f5ed1471f8..de0c5f3e35 100644 --- a/src/crates/services/skin-market-service/Cargo.toml +++ b/src/crates/services/skin-market-service/Cargo.toml @@ -15,7 +15,7 @@ base64 = { workspace = true } bitfun-product-domains = { path = "../../contracts/product-domains", features = ["appearance-market"] } chrono = { workspace = true } hex = { workspace = true } -image = { workspace = true } +image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] } hmac = { workspace = true } reqwest = { workspace = true, features = ["http2", "json", "rustls"] } semver = { workspace = true } diff --git a/src/crates/services/terminal/src/transcript.rs b/src/crates/services/terminal/src/transcript.rs index 29722f4359..ccf6990cfe 100644 --- a/src/crates/services/terminal/src/transcript.rs +++ b/src/crates/services/terminal/src/transcript.rs @@ -225,11 +225,10 @@ impl TranscriptRecorder { &self, operation: impl FnOnce(&mut TranscriptStore) -> io::Result, ) -> io::Result { - let mut store = self.inner.lock().map_err(|_| { - io::Error::other( - "terminal transcript recorder lock is poisoned", - ) - })?; + let mut store = self + .inner + .lock() + .map_err(|_| io::Error::other("terminal transcript recorder lock is poisoned"))?; operation(&mut store) } } @@ -684,9 +683,7 @@ impl TranscriptStore { }); let index = TranscriptIndex { sessions }; let serialized = serde_json::to_vec_pretty(&index).map_err(|error| { - io::Error::other( - format!("serialize terminal transcript index: {error}"), - ) + io::Error::other(format!("serialize terminal transcript index: {error}")) })?; let temporary_path = self.root.join(INDEX_TEMP_FILE_NAME); diff --git a/src/miniapp-market-web/README.md b/src/miniapp-market-web/README.md index 139f1c50b6..11fb3349fc 100644 --- a/src/miniapp-market-web/README.md +++ b/src/miniapp-market-web/README.md @@ -36,6 +36,8 @@ | `src/api.ts` | `/miniapp/api/v1` 客户端、CSRF、登录和下载 URL | | `src/types.ts` | 网页使用的 API DTO | | `src/MiniAppIcon.tsx` | 将 MiniApp 元数据中的 Lucide 图标名安全解析为图标组件 | +| `src/GetBitfunCta.tsx` | 目录页和详情页共用的「下载 BitFun 客户端」引流入口 | +| `src/links.ts` | 官网与下载页的对外链接常量 | | `src/i18n.ts` | `zh-CN`、`zh-TW`、`en-US` 文案与 fallback | | `src/format.ts` | 市场页面的日期和数字格式化 | | `src/styles.css` | 响应式布局与视觉样式 | diff --git a/src/miniapp-market-web/package.json b/src/miniapp-market-web/package.json index 89b9423221..cb17f15b81 100644 --- a/src/miniapp-market-web/package.json +++ b/src/miniapp-market-web/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-miniapp-market-web", - "version": "0.2.18", + "version": "0.2.19", "private": true, "type": "module", "scripts": { diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index 059a80419a..932c09fd41 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -32,7 +32,9 @@ import { } from '@phosphor-icons/react'; import { downloadUrl, loginUrl, marketApi, MarketApiError } from './api'; import { formatCompactNumber, formatMarketDate, formatMarketDateTime } from './format'; +import { GetBitfunCta } from './GetBitfunCta'; import { useLocale, type Locale, type MessageKey } from './i18n'; +import { BITFUN_HOME_URL } from './links'; import { MiniAppIcon } from './MiniAppIcon'; import { marketImageSrcSet, marketImageUrl, retryOriginalMarketImage } from './marketImages'; import { useTheme, type Theme } from './theme'; @@ -191,7 +193,7 @@ function App() { BitFun MiniApp Market {t('footerNote')} - + {t('bitfunHome')} @@ -453,6 +455,7 @@ function CatalogPage({

{t('headline')}

{t('intro')}

+
{t('heroImageAlt')} @@ -775,6 +778,7 @@ function DetailPage({ )}
+
{[1, 2, 3, 4, 5].map((value) => (
- +
diff --git a/src/skin-market-web/src/DetailPage.tsx b/src/skin-market-web/src/DetailPage.tsx index 45be32356b..2204eee24f 100644 --- a/src/skin-market-web/src/DetailPage.tsx +++ b/src/skin-market-web/src/DetailPage.tsx @@ -16,8 +16,8 @@ import { formatPackageSize, shortHash, } from './format'; +import { GetBitfunCta } from './GetBitfunCta'; import type { Locale, Translate } from './i18n'; -import { BITFUN_RELEASES_URL } from './links'; import { PosterImage } from './PosterImage'; import type { AppearanceListingDetail, @@ -184,16 +184,11 @@ export function DetailPage({ catalogSearch, isAdmin, locale, onNavigate, slug, t {t('detailDownload')} ) : null} - +

+

+
diff --git a/src/skin-market-web/src/GetBitfunCta.test.tsx b/src/skin-market-web/src/GetBitfunCta.test.tsx new file mode 100644 index 0000000000..b720fe5567 --- /dev/null +++ b/src/skin-market-web/src/GetBitfunCta.test.tsx @@ -0,0 +1,28 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { GetBitfunCta, type GetBitfunPlacement } from './GetBitfunCta'; +import type { Translate } from './i18n'; +import { BITFUN_DOWNLOAD_URL } from './links'; + +const t = ((key: string) => key) as Translate; + +describe('GetBitfunCta', () => { + it.each(['catalog', 'listing'])( + 'sends %s visitors to the official download page', + (placement) => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain(`href="${BITFUN_DOWNLOAD_URL}"`); + expect(markup).toContain('rel="noreferrer"'); + expect(markup).toContain('getBitfunTitle'); + expect(markup).toContain('getBitfunAction'); + }, + ); + + it('explains the surface the visitor is actually looking at', () => { + expect(renderToStaticMarkup()) + .toContain('getBitfunListingNote'); + expect(renderToStaticMarkup()) + .toContain('getBitfunCatalogNote'); + }); +}); diff --git a/src/skin-market-web/src/GetBitfunCta.tsx b/src/skin-market-web/src/GetBitfunCta.tsx new file mode 100644 index 0000000000..fdaad2246c --- /dev/null +++ b/src/skin-market-web/src/GetBitfunCta.tsx @@ -0,0 +1,38 @@ +import { ArrowRight, DownloadSimple } from '@phosphor-icons/react'; +import type { Translate } from './i18n'; +import { BITFUN_DOWNLOAD_URL } from './links'; + +// An appearance package only applies inside the desktop client, so the catalog +// and every listing carry the same visible route to the official download page. +export type GetBitfunPlacement = 'catalog' | 'listing'; + +export function GetBitfunCta({ + placement, + t, +}: { + placement: GetBitfunPlacement; + t: Translate; +}) { + return ( + + + + + {t('getBitfunTitle')} + + {t(placement === 'listing' ? 'getBitfunListingNote' : 'getBitfunCatalogNote')} + + + {t('getBitfunAction')} + + + + ); +} diff --git a/src/skin-market-web/src/i18n.ts b/src/skin-market-web/src/i18n.ts index 5334553208..75f6db883c 100644 --- a/src/skin-market-web/src/i18n.ts +++ b/src/skin-market-web/src/i18n.ts @@ -27,7 +27,6 @@ const messages = { githubUnavailable: 'GitHub sign-in is being configured. Please try again shortly.', headline: 'A different atmosphere for BitFun.', intro: 'Browse reviewed appearance packages, then install the one that fits your workspace.', - desktopInstallTitle: 'Install with BitFun Desktop', desktopInstallNote: 'Download the package, then open Settings > Appearance in BitFun Desktop to import it.', catalogTitle: 'Reviewed appearances', catalogIntro: 'Each release is immutable, compatibility-labeled, and ready for local import.', @@ -84,6 +83,12 @@ const messages = { backToCatalog: 'Browse appearances', footerNote: 'Reviewed packages. Local installation. Your appearance stays on your device.', bitfunHome: 'BitFun website', + getBitfunTitle: 'New to BitFun?', + getBitfunCatalogNote: + 'Appearances apply inside the BitFun desktop client. Download it once, then install any appearance from this market.', + getBitfunListingNote: + 'This appearance applies inside the BitFun desktop client. Download it first, then import the package.', + getBitfunAction: 'Download BitFun', refresh: 'Refresh', submissionsTitle: 'My submissions', submissionsIntro: 'Track appearance packages submitted from BitFun Agent and respond to review feedback.', @@ -160,7 +165,6 @@ const messages = { githubUnavailable: 'GitHub 登录正在配置,请稍后再试。', headline: '换一种 BitFun 的气质。', intro: '浏览经过审核的外观包,为你的工作空间选择更合适的视觉表达。', - desktopInstallTitle: '使用 BitFun Desktop 安装', desktopInstallNote: '下载外观包后,在 BitFun Desktop 中打开「设置 > 外观」并导入。', catalogTitle: '已审核外观', catalogIntro: '每个版本都锁定内容哈希,并明确标注兼容要求,可安全导入本机。', @@ -217,6 +221,12 @@ const messages = { backToCatalog: '浏览外观', footerNote: '人工审核安装包,本机安装,外观资源只保存在你的设备中。', bitfunHome: 'BitFun 官网', + getBitfunTitle: '没有 BitFun?', + getBitfunCatalogNote: + '外观需要在 BitFun 客户端里生效。下载客户端后,就能安装市场里的任意外观。', + getBitfunListingNote: + '这个外观需要在 BitFun 客户端里生效。先下载客户端,再导入外观包即可。', + getBitfunAction: '下载 BitFun 客户端', refresh: '刷新', submissionsTitle: '我的投稿', submissionsIntro: '查看通过 BitFun Agent 投稿的外观包,并跟进审核意见。', diff --git a/src/skin-market-web/src/links.test.ts b/src/skin-market-web/src/links.test.ts index 159b8c02f4..17702a7fbc 100644 --- a/src/skin-market-web/src/links.test.ts +++ b/src/skin-market-web/src/links.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { BITFUN_HOME_URL, BITFUN_RELEASES_URL } from './links'; +import { BITFUN_DOWNLOAD_URL, BITFUN_HOME_URL } from './links'; describe('Skin Market external links', () => { - it('uses the official BitFun website and GitHub release pages', () => { + it('uses the official BitFun website and download pages', () => { expect(BITFUN_HOME_URL).toBe('https://openbitfun.com/'); - expect(BITFUN_RELEASES_URL).toBe('https://github.com/GCWing/BitFun/releases'); + expect(BITFUN_DOWNLOAD_URL).toBe('https://openbitfun.com/download'); expect(new URL(BITFUN_HOME_URL).protocol).toBe('https:'); - expect(new URL(BITFUN_RELEASES_URL).protocol).toBe('https:'); + expect(new URL(BITFUN_DOWNLOAD_URL).protocol).toBe('https:'); }); }); diff --git a/src/skin-market-web/src/links.ts b/src/skin-market-web/src/links.ts index 2e8e34896f..64c1704f5b 100644 --- a/src/skin-market-web/src/links.ts +++ b/src/skin-market-web/src/links.ts @@ -1,2 +1,5 @@ +// Most visitors arrive from a shared appearance link without BitFun installed. +// Keep the official website and download page in one place so every browse +// surface funnels to the same URLs. export const BITFUN_HOME_URL = 'https://openbitfun.com/'; -export const BITFUN_RELEASES_URL = 'https://github.com/GCWing/BitFun/releases'; +export const BITFUN_DOWNLOAD_URL = 'https://openbitfun.com/download'; diff --git a/src/skin-market-web/src/styles.css b/src/skin-market-web/src/styles.css index f72045e969..d18c26d668 100644 --- a/src/skin-market-web/src/styles.css +++ b/src/skin-market-web/src/styles.css @@ -439,42 +439,70 @@ img { line-height: 1.55; } -.install-note { +/* An appearance package only applies inside the desktop client, so the catalog + and every listing carry the same visible route to the download page. */ +.get-bitfun { display: grid; grid-template-columns: auto 1fr; gap: 14px; - padding: 20px 0 3px; - border-top: 1px solid var(--border-strong); + padding: 19px 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); color: var(--text-soft); + text-decoration: none; + transition: border-color 150ms ease, background-color 150ms ease; } -.install-note > svg { - color: var(--accent); +.get-bitfun:hover { + border-color: var(--border-strong); + background: var(--surface-strong); } -.install-note > a { - color: inherit; - text-decoration: none; +.get-bitfun__icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: var(--control-radius); + background: var(--accent-soft); + color: var(--accent-text); } -.install-note strong { - display: flex; - width: fit-content; - align-items: center; - gap: 6px; - margin-bottom: 7px; +.get-bitfun__copy { + display: grid; + gap: 7px; + min-width: 0; +} + +.get-bitfun__copy strong { color: var(--text); font-size: 15px; + font-weight: 700; } -.install-note > a:hover strong { - color: var(--accent-strong); +.get-bitfun__copy > span { + font-size: 14px; + line-height: 1.55; } -.install-note p { - margin: 0; +.get-bitfun__action { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + margin-top: 2px; + color: var(--accent-text); font-size: 14px; - line-height: 1.55; + font-weight: 720; +} + +.get-bitfun__action svg { + transition: transform 150ms ease; +} + +.get-bitfun:hover .get-bitfun__action svg { + transform: translateX(3px); } .catalog { @@ -1046,41 +1074,23 @@ img { margin-top: 28px; } -.desktop-guidance { +.import-hint { display: grid; grid-template-columns: auto 1fr; + align-items: center; gap: 11px; - margin-top: 23px; - padding-top: 18px; - border-top: 1px solid var(--border); + margin: 20px 0 0; color: var(--text-faint); -} - -.desktop-guidance > svg { - color: var(--accent); -} - -.desktop-guidance a { - display: flex; - flex-direction: column; - gap: 5px; - color: inherit; - font-size: 12px; + font-size: 13px; line-height: 1.5; - text-decoration: none; } -.desktop-guidance strong { - display: flex; - width: fit-content; - align-items: center; - gap: 5px; - color: var(--text); - font-size: 13px; +.import-hint > svg { + color: var(--accent); } -.desktop-guidance a:hover strong { - color: var(--accent-strong); +.get-bitfun--listing { + margin-top: 22px; } .detail-fact-strip { @@ -2091,7 +2101,7 @@ img { line-height: 1; } - .install-note { + .get-bitfun--catalog { max-width: 540px; } diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 01aed2c455..dbe69eb0fd 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@bitfun/web-ui", - "version": "0.2.18", + "version": "0.2.19", "private": true, "description": "BitFun Web UI - 支持 Desktop 和 Server 两种部署方式", "type": "module", diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts index 7a09380213..4eb9bee40f 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.test.ts @@ -4,9 +4,11 @@ import type { ReviewPlatformPullRequestDetail } from '@/infrastructure/api'; import { currentPullRequestReviewStatusText, effectivePullRequestReviewFreshness, + mergeChangedFileCount, mergeRevalidatedPullRequestOverview, pullRequestReviewFreshness, pullRequestReviewLaunchKey, + resolvedChangedFileCount, samePullRequestIdentity, } from './reviewLinking'; @@ -166,4 +168,88 @@ describe('pull request Review linking', () => { expect(merged).toBe(overview); }); + + it('does not replace known change stats when overview enrichment fails', () => { + const current = { + baseRevision, + headRevision, + additions: 133, + deletions: 22, + changedFiles: 13, + changedFileCountKnown: true, + ci: [], + files: [], + commits: [], + threads: [], + } as unknown as ReviewPlatformPullRequestDetail; + const overview = { + baseRevision, + headRevision, + additions: 133, + deletions: 22, + changedFiles: 0, + changedFileCountKnown: false, + ci: [], + files: [], + commits: [], + threads: [], + } as unknown as ReviewPlatformPullRequestDetail; + + const merged = mergeRevalidatedPullRequestOverview(current, overview); + + expect(merged.changedFiles).toBe(13); + expect(merged.changedFileCountKnown).toBe(true); + }); + + it('keeps an authoritative zero when merging file counts', () => { + expect(mergeChangedFileCount({ + changedFiles: 13, + changedFileCountKnown: true, + }, { + changedFiles: 0, + changedFileCountKnown: true, + })).toEqual({ + changedFiles: 0, + changedFileCountKnown: true, + }); + }); + + it('preserves known and legacy file counts when incoming data is incomplete', () => { + expect(mergeChangedFileCount({ + changedFiles: 13, + changedFileCountKnown: true, + }, { + changedFiles: 0, + changedFileCountKnown: false, + })).toEqual({ + changedFiles: 13, + changedFileCountKnown: true, + }); + expect(mergeChangedFileCount({ changedFiles: 7 }, { changedFiles: 0 })).toEqual({ + changedFiles: 7, + changedFileCountKnown: undefined, + }); + }); + + it('distinguishes an unknown file count from a real zero', () => { + expect(resolvedChangedFileCount({ + changedFiles: 0, + changedFileCountKnown: false, + })).toBeNull(); + expect(resolvedChangedFileCount({ + changedFiles: 0, + changedFileCountKnown: true, + })).toBe(0); + }); + + it('falls back to a known count and accepts legacy payloads', () => { + expect(resolvedChangedFileCount({ + changedFiles: 0, + changedFileCountKnown: false, + }, { + changedFiles: 13, + changedFileCountKnown: true, + })).toBe(13); + expect(resolvedChangedFileCount({ changedFiles: 7 })).toBe(7); + }); }); diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx index 259b656659..a6027c58a0 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -49,9 +49,11 @@ import type { PullRequestContext } from '@/shared/types/context'; import { currentPullRequestReviewStatusText, effectivePullRequestReviewFreshness, + mergeChangedFileCount, mergeRevalidatedPullRequestOverview, pullRequestReviewFreshness, pullRequestReviewLaunchKey, + resolvedChangedFileCount, samePullRequestRevisions, samePullRequestIdentity, type PullRequestReviewFreshness, @@ -198,7 +200,7 @@ function mergeDetailPage( ...page, additions: page.additions || base.additions, deletions: page.deletions || base.deletions, - changedFiles: page.changedFiles || base.changedFiles, + ...mergeChangedFileCount(base, page), ci: page.section === 'ci' ? page.ci : base.ci, files: page.section === 'files' ? page.files : base.files, commits: page.section === 'commits' ? page.commits : base.commits, @@ -2026,7 +2028,7 @@ export const ReviewPlatformPanel: React.FC = ({ {decisionLabel(pr.reviewDecision)} - {pr.changedFiles} files + {resolvedChangedFileCount(pr) ?? '—'} files +{pr.additions} -{pr.deletions} @@ -2211,7 +2213,7 @@ export const ReviewPlatformPanel: React.FC = ({ {displayPr?.sourceBranch ?? selectedPr.sourceBranch} {displayPr?.targetBranch ?? selectedPr.targetBranch} - {displayPr?.changedFiles ?? selectedPr.changedFiles} files + {resolvedChangedFileCount(displayPr, selectedPr) ?? '—'} files +{displayPr?.additions ?? selectedPr.additions} -{displayPr?.deletions ?? selectedPr.deletions} @@ -2495,7 +2497,11 @@ export const ReviewPlatformPanel: React.FC = ({ ); })} {!detailLoading && detail && detail.files.length === 0 && ( -
No changed files were returned by this provider.
+
+ {detail.changedFileCountKnown === false + ? 'Changed files are currently unavailable from this provider.' + : 'No changed files were returned by this provider.'} +
)} {renderDetailPagination('Files', changePage, changedFiles.length, setChangePageIndex)} diff --git a/src/web-ui/src/app/components/panels/review-platform/reviewLinking.ts b/src/web-ui/src/app/components/panels/review-platform/reviewLinking.ts index ef3d6462e0..02f88fc824 100644 --- a/src/web-ui/src/app/components/panels/review-platform/reviewLinking.ts +++ b/src/web-ui/src/app/components/panels/review-platform/reviewLinking.ts @@ -15,6 +15,10 @@ interface PullRequestReviewStatusInput { } type PullRequestReviewIdentity = NonNullable['pullRequest']; +type PullRequestChangedFileCount = Pick< + ReviewPlatformPullRequest, + 'changedFiles' | 'changedFileCountKnown' +>; function normalizeProviderHost(value: string): string { return value.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, ''); @@ -120,8 +124,17 @@ export function mergeRevalidatedPullRequestOverview( if (!current || !samePullRequestRevisions(current, overview)) { return overview; } + const preserveKnownLineStats = overview.changedFileCountKnown === false + && current.changedFileCountKnown !== false + ? { + additions: current.additions, + deletions: current.deletions, + } + : {}; return { ...overview, + ...preserveKnownLineStats, + ...mergeChangedFileCount(current, overview), ci: current.ci, files: current.files, commits: current.commits, @@ -129,6 +142,42 @@ export function mergeRevalidatedPullRequestOverview( }; } +export function mergeChangedFileCount( + current: PullRequestChangedFileCount, + incoming: PullRequestChangedFileCount, +): PullRequestChangedFileCount { + if (incoming.changedFileCountKnown === true) { + return { + changedFiles: incoming.changedFiles, + changedFileCountKnown: true, + }; + } + if (incoming.changedFileCountKnown === false) { + const source = current.changedFileCountKnown !== false ? current : incoming; + return { + changedFiles: source.changedFiles, + changedFileCountKnown: source.changedFileCountKnown, + }; + } + return { + changedFiles: incoming.changedFiles || current.changedFiles, + changedFileCountKnown: undefined, + }; +} + +export function resolvedChangedFileCount( + primary?: PullRequestChangedFileCount | null, + fallback?: PullRequestChangedFileCount | null, +): number | null { + if (primary && primary.changedFileCountKnown !== false) { + return primary.changedFiles; + } + if (fallback && fallback.changedFileCountKnown !== false) { + return fallback.changedFiles; + } + return null; +} + export function samePullRequestRevisions( left: Pick, right: Pick, diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx index 61606253d5..3c0216074a 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx @@ -16,7 +16,7 @@ import { IconButton, Textarea } from '@/component-library'; import { ModelSelector } from '@/flow_chat/components/ModelSelector'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; -import { useImeEnterGuard } from '@/flow_chat/hooks/useImeEnterGuard'; +import { useImeOwnedKeyGuard } from '@/flow_chat/hooks/useImeOwnedKeyGuard'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; @@ -39,7 +39,7 @@ const AssistantQuickInput: React.FC = ({ const { setActiveWorkspace } = useWorkspaceContext(); const [value, setValue] = useState(''); const [sending, setSending] = useState(false); - const { isImeEnter, handleCompositionStart, handleCompositionEnd } = useImeEnterGuard(); + const { isImeOwnedKey, handleCompositionStart, handleCompositionEnd } = useImeOwnedKeyGuard(); const handleChange = useCallback((e: React.ChangeEvent) => { setValue(e.target.value); @@ -81,11 +81,11 @@ const AssistantQuickInput: React.FC = ({ const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { - if (isImeEnter(e)) return; + if (isImeOwnedKey(e)) return; e.preventDefault(); void handleSend(); } - }, [handleSend, isImeEnter]); + }, [handleSend, isImeOwnedKey]); const placeholder = assistantName ? t('input.assistantPlaceholder', { name: assistantName }) diff --git a/src/web-ui/src/flow_chat/components/AcpModeSelector.appearance.ts b/src/web-ui/src/flow_chat/components/AcpModeSelector.appearance.ts new file mode 100644 index 0000000000..fc1a252682 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/AcpModeSelector.appearance.ts @@ -0,0 +1,17 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const acpModeSelectorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'acp-mode-selector', + parts: [ + { id: 'root' }, + { id: 'trigger' }, + { id: 'label' }, + { id: 'menu' }, + { id: 'header' }, + { id: 'option' }, + ], + states: [ + { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, + { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/components/AcpModeSelector.scss b/src/web-ui/src/flow_chat/components/AcpModeSelector.scss new file mode 100644 index 0000000000..7475194222 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/AcpModeSelector.scss @@ -0,0 +1,183 @@ +@use '../../component-library/styles/tokens' as *; +@use '../../component-library/styles/overlay-surfaces' as surfaces; + +.bitfun-acp-mode-selector { + position: relative; + display: inline-flex; + align-items: center; + + &__trigger { + display: inline-flex; + align-items: center; + gap: 4px; + height: 20px; + max-width: 132px; + padding: 0 8px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--bf-appearance-token-color-text-secondary); + font-size: var(--bf-appearance-token-flowchat-font-size-2xs); + cursor: pointer; + opacity: 0.72; + outline: none; + + .bitfun-chat-input__box:focus-within &, + &:hover, + &:focus-visible, + &--open { + color: var(--bf-appearance-token-color-text-primary); + opacity: 1; + } + + &:hover, + &--open { + background: var(--bf-appearance-token-element-bg-medium); + } + + &:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 20%, transparent); + } + + &:disabled { + cursor: default; + opacity: 0.4; + } + } + + &__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__menu { + @include surfaces.floating-surface; + + --acp-mode-menu-offset: -4px; + + min-width: clamp(0px, 190px, calc(100vw - 16px)); + max-width: clamp(0px, 240px, calc(100vw - 16px)); + padding: 4px; + overflow: hidden; + z-index: $z-popover; + transform-origin: center top; + transition: + opacity 100ms ease, + transform 120ms cubic-bezier(0.23, 1, 0.32, 1); + + &[data-open='true']:not([data-keyboard-open='true']) { + @starting-style { + opacity: 0; + transform: translateY(var(--acp-mode-menu-offset)) scale(0.985); + } + } + + &[data-placement='top'] { + --acp-mode-menu-offset: 4px; + transform-origin: center bottom; + } + + &[data-open='false'] { + opacity: 0; + transform: translateY(var(--acp-mode-menu-offset)) scale(0.985); + pointer-events: none; + } + + &[data-keyboard-open='true'] { + transition: none; + } + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 7px 7px; + color: var(--bf-appearance-token-color-text-muted); + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + font-weight: 500; + } + + &__header-hint { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 400; + opacity: 0.8; + } + + &__option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-height: 30px; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--bf-appearance-token-color-text-secondary); + text-align: left; + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--bf-appearance-token-element-bg-medium); + color: var(--bf-appearance-token-color-text-primary); + outline: none; + } + + &[aria-checked='true'] { + background: var(--bf-appearance-token-element-bg-subtle); + } + + &:disabled { + cursor: default; + opacity: 0.55; + + &:hover { + background: transparent; + color: var(--bf-appearance-token-color-text-secondary); + } + + &[aria-checked='true']:hover { + background: var(--bf-appearance-token-element-bg-subtle); + } + } + + > span { + display: flex; + flex-direction: column; + min-width: 0; + } + + strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 0; + font-size: var(--bf-appearance-token-flowchat-font-size-xs); + font-weight: 500; + } + + svg { + flex-shrink: 0; + color: var(--bf-appearance-token-color-accent-500); + } + } +} + +@media (prefers-reduced-motion: reduce) { + .bitfun-acp-mode-selector__menu { + transition: opacity 100ms linear; + } +} + +@media (max-width: 520px) { + .bitfun-acp-mode-selector__trigger { + max-width: 92px; + } +} diff --git a/src/web-ui/src/flow_chat/components/AcpModeSelector.tsx b/src/web-ui/src/flow_chat/components/AcpModeSelector.tsx new file mode 100644 index 0000000000..f5198dda1c --- /dev/null +++ b/src/web-ui/src/flow_chat/components/AcpModeSelector.tsx @@ -0,0 +1,280 @@ +/** + * The ACP session mode picker. + * + * ACP's `mode` category is "the picker that is not the model picker": dsh-acp + * publishes its agent presets there, claude-code and codex their permission + * modes. It used to live as a second section inside the model dropdown, which + * made one button stand for two unrelated choices. It is its own trigger now, + * sitting beside the model picker and showing the mode in force. + */ + +import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Check, ChevronDown } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from '@/component-library'; +import { PresenceBoundary } from '@/component-library/components/PresenceBoundary'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import type { AcpModeState } from '../utils/acpSessionConfig'; +import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition'; +import './AcpModeSelector.scss'; + +interface AcpModeSelectorProps { + mode: AcpModeState; + /** Which agent published the mode; shown as the dropdown's right-hand hint. */ + clientId?: string; + disabled?: boolean; + loading?: boolean; + dropdownPlacement?: 'top' | 'bottom'; + /** + * Replaces the trigger tooltip. The caller uses this when this picker is the + * only one on screen and therefore also carries the context-usage readout. + */ + tooltip?: React.ReactNode; + /** Extra trigger content before the chevron — the context-usage badge. */ + trailing?: React.ReactNode; + onSelect: (value: string) => void | Promise; +} + +export const AcpModeSelector: React.FC = ({ + mode, + clientId, + disabled = false, + loading = false, + dropdownPlacement = 'top', + tooltip, + trailing, + onSelect, +}) => { + const { t } = useTranslation('flow-chat'); + const [open, setOpen] = useState(false); + const [keyboardOpen, setKeyboardOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const menuId = useId(); + const [menuStyle, setMenuStyle] = useState({ + position: 'fixed', + visibility: 'hidden', + }); + const [resolvedPlacement, setResolvedPlacement] = useState(dropdownPlacement); + + const candidates = mode.option.options; + + useEffect(() => { + if (candidates.length === 0) setOpen(false); + }, [candidates.length]); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setOpen(false); + setKeyboardOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [open]); + + useEffect(() => { + if (!open || !rootRef.current) return; + const updatePosition = () => { + if (!rootRef.current || !menuRef.current) return; + const layout = getModelSelectorDropdownLayout( + rootRef.current.getBoundingClientRect(), + menuRef.current.getBoundingClientRect(), + dropdownPlacement, + { width: window.innerWidth, height: window.innerHeight }, + ); + setMenuStyle(layout.style); + setResolvedPlacement(layout.placement); + }; + updatePosition(); + const observer = new ResizeObserver(updatePosition); + if (menuRef.current) observer.observe(menuRef.current); + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + observer.disconnect(); + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [dropdownPlacement, open]); + + useEffect(() => { + if (!open || !keyboardOpen) return; + const frame = window.requestAnimationFrame(() => { + const checked = menuRef.current?.querySelector( + 'button[role="menuitemradio"][aria-checked="true"]:not(:disabled)', + ); + const first = menuRef.current?.querySelector( + 'button[role="menuitemradio"]:not(:disabled)', + ); + (checked ?? first)?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [keyboardOpen, open]); + + const select = useCallback((value: string) => { + if (menuRef.current?.contains(document.activeElement)) { + triggerRef.current?.focus(); + } + setOpen(false); + void onSelect(value); + }, [onSelect]); + + const handleMenuKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + triggerRef.current?.focus(); + setOpen(false); + return; + } + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const items = Array.from(event.currentTarget.querySelectorAll( + 'button[role="menuitemradio"]:not(:disabled)', + )); + if (items.length === 0) return; + event.preventDefault(); + const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement); + let nextIndex = activeIndex; + if (event.key === 'Home') nextIndex = 0; + if (event.key === 'End') nextIndex = items.length - 1; + if (event.key === 'ArrowDown') nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % items.length; + if (event.key === 'ArrowUp') nextIndex = activeIndex < 0 ? items.length - 1 : (activeIndex - 1 + items.length) % items.length; + items[nextIndex]?.focus(); + }, []); + + if (candidates.length === 0) return null; + + const currentLabel = candidates.find(candidate => candidate.value === mode.currentValue)?.name + ?? mode.currentValue; + const triggerTooltip = tooltip + ?? mode.option.description + ?? `${mode.option.name}: ${currentLabel}`; + + return ( +
+ + + + + + {createPortal( + , + getAppearanceOverlayHost(), + )} + +
+ ); +}; + +export default AcpModeSelector; diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 77645ec72a..c2fceaa0f0 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -47,6 +47,7 @@ import { } from '../utils/tokenUsageDisplay'; import { createLogger } from '@/shared/utils/logger'; import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition'; +import { AcpModeSelector } from './AcpModeSelector'; import { ReasoningPresetSelector } from './ReasoningPresetSelector'; import { getRecentReasoningPreset, @@ -1397,10 +1398,21 @@ export const ModelSelector: React.FC = ({ ? (acpMode.option.options.find(candidate => candidate.value === acpMode.currentValue)?.name ?? acpMode.currentValue) : ''; - const acpModeOnly = acpAvailableModels.length === 0 && acpMode !== null; - const acpBaseTooltip = acpModeOnly - ? (acpMode?.option.description ?? `${acpMode?.option.name ?? ''}: ${acpModeLabel}`) - : getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); + // The mode has a trigger of its own now. What is left here is the model + // list and the fast-mode switch, so this picker only appears when the agent + // published one of them — a mode-only agent shows the mode picker alone. + const showModelTrigger = acpAvailableModels.length > 0 || acpFastMode !== null; + let acpBaseTooltip: string; + if (acpAvailableModels.length > 0) { + acpBaseTooltip = getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); + } else if (showModelTrigger) { + acpBaseTooltip = t('modelSelector.fastMode'); + } else { + acpBaseTooltip = acpMode?.option.description ?? `${acpMode?.option.name ?? ''}: ${acpModeLabel}`; + } + const acpDropdownTitle = acpAvailableModels.length > 0 + ? 'ACP model' + : t('modelSelector.fastMode'); const acpTooltip = buildContextUsageTooltip({ baseTooltip: acpBaseTooltip, usage: { @@ -1410,12 +1422,24 @@ export const ModelSelector: React.FC = ({ }, t, }); + // Whichever trigger is on screen carries the context readout; with no model + // trigger it rides along on the mode one instead of disappearing. + const contextUsageBadge = tokenPercentage > 0 ? ( + + · {tokenPercentage}% + + ) : null; return (
+ {showModelTrigger && ( + )} + + {acpMode && ( + + )} {acpReasoning ? ( = ({ /> ) : null} + {showModelTrigger && ( {createPortal(
= ({ aria-hidden={!dropdownOpen} {...(!dropdownOpen ? { inert: '' } : {})} role="menu" - aria-label={acpModeOnly ? t('modelSelector.acpMode') : 'ACP model'} + aria-label={acpDropdownTitle} onKeyDown={handleDropdownKeyDown} >
- {acpModeOnly ? t('modelSelector.acpMode') : 'ACP model'} + {acpDropdownTitle} {acpClientId} @@ -1531,64 +1567,11 @@ export const ModelSelector: React.FC = ({
)} - {acpMode && ( + {acpFastMode && ( <> {acpAvailableModels.length > 0 && ( - <> -
-
- {t('modelSelector.acpMode')} -
- +
)} -
- {acpMode.option.options.map(candidate => { - const isSelected = acpMode.currentValue === candidate.value; - // The row stays one line; what a mode does — or why it can no - // longer change — is hover-only. - const hint = acpMode.locked - ? (acpMode.option.description ?? t('modelSelector.acpModeLocked')) - : (candidate.description ?? candidate.name); - - return ( - - - - ); - })} -
- - )} - - {acpFastMode && ( - <> -
@@ -1612,6 +1595,7 @@ export const ModelSelector: React.FC = ({ getAppearanceOverlayHost() )} + )}
); } diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx index 32c43ef664..994a2f241a 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx @@ -2,9 +2,10 @@ * @vitest-environment jsdom * * The composer's mode picker for ACP sessions. An agent publishes its modes as - * a `mode`-category config option, and dsh-acp publishes ONLY that — no models — - * so this covers the case where the mode is the whole picker, and the case where - * the agent has fixed it and left exactly one choice. + * a `mode`-category config option, and it gets a trigger of its own beside the + * model picker — the two are unrelated choices and used to share one dropdown. + * This covers the agent that publishes only a mode, the agent that publishes + * both, and the agent that has fixed the mode and left exactly one choice. */ import { act } from 'react'; @@ -136,9 +137,13 @@ describe('ModelSelector ACP mode picker', () => { }); /** Render the selector against one set of ACP session options. */ - const renderWithOptions = async (configOptions: unknown[]) => { + const renderWithOptions = async ( + configOptions: unknown[], + session: { availableModels?: unknown[]; currentModelId?: string } = {}, + ) => { vi.mocked(ACPClientAPI.getSessionOptions).mockResolvedValue({ - availableModels: [], + availableModels: session.availableModels ?? [], + ...(session.currentModelId ? { currentModelId: session.currentModelId } : {}), configOptions, } as never); await act(async () => { @@ -151,11 +156,13 @@ describe('ModelSelector ACP mode picker', () => { it('renders the mode as the whole picker when the agent offers no models', async () => { await renderWithOptions([MODE_OPTION]); - const trigger = container.querySelector('[data-testid="chat-model-selector-btn"]'); + const trigger = container.querySelector('[data-testid="chat-acp-mode-selector-btn"]'); // Without this the ACP branch used to return null on an empty model list, // leaving a dsh session with no picker at all. expect(trigger, 'the mode picker must render without models').not.toBeNull(); expect(trigger?.textContent).toContain('Standard'); + // Nothing left for the model picker to show, so it stays away entirely. + expect(container.querySelector('[data-testid="chat-model-selector-btn"]')).toBeNull(); await act(async () => { trigger?.click(); }); const options = document.body.querySelectorAll('[data-testid="chat-acp-mode-option"]'); @@ -163,6 +170,32 @@ describe('ModelSelector ACP mode picker', () => { .toEqual(['standard', 'minimal']); }); + it('keeps the mode out of the model dropdown when the agent offers both', async () => { + await renderWithOptions([MODE_OPTION], { + availableModels: [ + { id: 'deepseek-official/deepseek-v4-flash', name: 'DeepSeek V4 Flash' }, + { id: 'deepseek-official/deepseek-v4-pro', name: 'DeepSeek V4 Pro' }, + ], + currentModelId: 'deepseek-official/deepseek-v4-flash', + }); + + const modelTrigger = container.querySelector('[data-testid="chat-model-selector-btn"]'); + const modeTrigger = container.querySelector('[data-testid="chat-acp-mode-selector-btn"]'); + expect(modelTrigger, 'the model picker keeps its own trigger').not.toBeNull(); + expect(modeTrigger?.textContent).toContain('Standard'); + + // The whole point of the split: one button, one kind of choice. + await act(async () => { modelTrigger?.click(); }); + const modelMenu = document.body.querySelector('[data-testid="chat-model-selector-menu"]'); + expect(modelMenu?.querySelectorAll('[data-testid="chat-model-selector-option"]')).toHaveLength(2); + expect(modelMenu?.querySelectorAll('[data-testid="chat-acp-mode-option"]')).toHaveLength(0); + + await act(async () => { modeTrigger?.click(); }); + expect( + document.body.querySelectorAll('[data-testid="chat-acp-mode-selector-menu"] [data-testid="chat-acp-mode-option"]'), + ).toHaveLength(2); + }); + it('sends the chosen mode to the agent', async () => { await renderWithOptions([MODE_OPTION]); vi.mocked(ACPClientAPI.setSessionConfigOption).mockResolvedValue({ @@ -171,7 +204,7 @@ describe('ModelSelector ACP mode picker', () => { } as never); await act(async () => { - container.querySelector('[data-testid="chat-model-selector-btn"]')?.click(); + container.querySelector('[data-testid="chat-acp-mode-selector-btn"]')?.click(); }); await act(async () => { document.body.querySelector( @@ -187,7 +220,7 @@ describe('ModelSelector ACP mode picker', () => { value: { type: 'select', value: 'minimal' }, })); expect( - container.querySelector('[data-testid="chat-model-selector-btn"]')?.textContent, + container.querySelector('[data-testid="chat-acp-mode-selector-btn"]')?.textContent, ).toContain('Minimal'); }); @@ -201,7 +234,7 @@ describe('ModelSelector ACP mode picker', () => { }]); await act(async () => { - container.querySelector('[data-testid="chat-model-selector-btn"]')?.click(); + container.querySelector('[data-testid="chat-acp-mode-selector-btn"]')?.click(); }); const options = document.body.querySelectorAll( '[data-testid="chat-acp-mode-option"]', diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx index b20c1368d3..d19ee1ce4c 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx @@ -1,6 +1,7 @@ import React, { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; +import { Simulate } from 'react-dom/test-utils'; import { JSDOM } from 'jsdom'; import { UserMessageEditComposer } from './UserMessageEditComposer'; import type { ComposerPresentation } from '../../utils/composerPresentation'; @@ -36,6 +37,47 @@ describe('UserMessageEditComposer', () => { let container: HTMLDivElement; let root: Root; + const renderComposer = async (options: { rich?: boolean } = {}) => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + return { onChange, onSubmit, onCancel }; + }; + + const dispatchKey = async ( + target: Element, + key: string, + init: KeyboardEventInit = {}, + ) => { + const event = new dom.window.KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...init, + }); + await act(async () => { + target.dispatchEvent(event); + }); + return event; + }; + beforeEach(() => { dom = new JSDOM('
', { pretendToBeVisual: true, @@ -72,6 +114,92 @@ describe('UserMessageEditComposer', () => { vi.unstubAllGlobals(); }); + it('keeps Enter with the IME during tracked composition and submits afterward', async () => { + const { onSubmit } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + await act(async () => { + Simulate.compositionStart(textarea!); + }); + const imeEnter = await dispatchKey(textarea!, 'Enter'); + + expect(imeEnter.defaultPrevented).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + + await act(async () => { + Simulate.compositionEnd(textarea!); + }); + const submitEnter = await dispatchKey(textarea!, 'Enter'); + + expect(submitEnter.defaultPrevented).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['native isComposing', { isComposing: true }], + ['native keyCode 229', { keyCode: 229 }], + ] as const)('keeps Enter with the IME for %s', async (_label, init) => { + const { onSubmit } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + const event = await dispatchKey(textarea!, 'Enter', init); + + expect(event.defaultPrevented).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('keeps Escape with the IME during tracked composition and cancels afterward', async () => { + const { onCancel } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + await act(async () => { + Simulate.compositionStart(textarea!); + }); + const imeEscape = await dispatchKey(textarea!, 'Escape'); + + expect(imeEscape.defaultPrevented).toBe(false); + expect(onCancel).not.toHaveBeenCalled(); + + await act(async () => { + Simulate.compositionEnd(textarea!); + }); + const cancelEscape = await dispatchKey(textarea!, 'Escape'); + + expect(cancelEscape.defaultPrevented).toBe(true); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['native isComposing', { isComposing: true }], + ['native keyCode 229', { keyCode: 229 }], + ] as const)('keeps Escape with the IME for %s', async (_label, init) => { + const { onCancel } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + const event = await dispatchKey(textarea!, 'Escape', init); + + expect(event.defaultPrevented).toBe(false); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('keeps rich editor Enter and Escape handling inside its IME boundary', async () => { + const { onSubmit, onCancel } = await renderComposer({ rich: true }); + const editor = container.querySelector('.rich-text-input'); + expect(editor).toBeTruthy(); + + const enter = await dispatchKey(editor!, 'Enter', { keyCode: 229 }); + const escape = await dispatchKey(editor!, 'Escape', { keyCode: 229 }); + + expect(enter.defaultPrevented).toBe(false); + expect(escape.defaultPrevented).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + it('restores and removes reference capsules atomically', async () => { const onChange = vi.fn(); const onSubmit = vi.fn(); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx index 4230bc2d4b..92edda87be 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Check, Loader2, X } from 'lucide-react'; import { Textarea } from '@/component-library'; +import { useImeOwnedKeyGuard } from '@/flow_chat/hooks/useImeOwnedKeyGuard'; import type { ContextItem } from '@/shared/types/context'; import { FileMentionPicker } from '../FileMentionPicker'; import { @@ -190,6 +191,7 @@ export const UserMessageEditComposer: React.FC = ( excludeSessionId, }) => { const textareaRef = useRef(null); + const { isImeOwnedKey, handleCompositionStart, handleCompositionEnd } = useImeOwnedKeyGuard(); const trimmedValue = value.trim(); const canSubmit = trimmedValue.length > 0 && !isSubmitting; @@ -207,6 +209,10 @@ export const UserMessageEditComposer: React.FC = ( }, [canSubmit, onSubmit]); const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + if ((event.key === 'Enter' || event.key === 'Escape') && isImeOwnedKey(event)) { + return; + } + if (event.key === 'Escape') { event.preventDefault(); onCancel(); @@ -217,7 +223,7 @@ export const UserMessageEditComposer: React.FC = ( event.preventDefault(); handleSubmit(); } - }, [handleSubmit, onCancel]); + }, [handleSubmit, isImeOwnedKey, onCancel]); if (presentation) { return ( @@ -248,6 +254,8 @@ export const UserMessageEditComposer: React.FC = ( value={value} onChange={(event) => onChange(event.target.value)} onKeyDown={handleKeyDown} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} placeholder={placeholder} autoResize disabled={isSubmitting} diff --git a/src/web-ui/src/flow_chat/hooks/index.ts b/src/web-ui/src/flow_chat/hooks/index.ts index dbc7c9d001..da2d288bdd 100644 --- a/src/web-ui/src/flow_chat/hooks/index.ts +++ b/src/web-ui/src/flow_chat/hooks/index.ts @@ -2,6 +2,5 @@ export { useFlowChat } from './useFlowChat'; export { useActiveSessionState } from './useActiveSessionState'; export { useAutoScroll } from './useAutoScroll'; export { useTypewriter } from './useTypewriter'; -export { useImeEnterGuard } from './useImeEnterGuard'; -export type { ImeEnterGuard } from './useImeEnterGuard'; - +export { useImeOwnedKeyGuard } from './useImeOwnedKeyGuard'; +export type { ImeOwnedKeyGuard } from './useImeOwnedKeyGuard'; diff --git a/src/web-ui/src/flow_chat/hooks/useImeEnterGuard.ts b/src/web-ui/src/flow_chat/hooks/useImeEnterGuard.ts deleted file mode 100644 index eb60540211..0000000000 --- a/src/web-ui/src/flow_chat/hooks/useImeEnterGuard.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * useImeEnterGuard — IME-safe Enter detection for chat-style inputs. - * - * Problem: with Chinese / Japanese / Korean IMEs, the Enter key that - * confirms a candidate must NOT be treated as "send message", but the - * Enter key that actually submits the input MUST trigger send — and - * fast typists may chain "confirm candidate → send" within a few - * milliseconds. - * - * Strategy (no time-based heuristics): - * 1. Track our own "is composing" flag via composition events. This - * handles browsers/IMEs where `KeyboardEvent.isComposing` is - * occasionally unreliable (notably some Safari / Linux paths). - * 2. Treat any Enter `keydown` whose `keyCode === 229` as IME-owned. - * `keyCode 229` is the W3C-defined "composition keyCode" that - * every major browser still emits while an IME is processing the - * key, even when `isComposing` has already flipped back to false. - * - * The combination removes the need for a fragile time window guard - * (which would otherwise swallow legitimate fast Enter presses) while - * still rejecting the IME-confirmation Enter on every platform we - * tested. - * - * Reference behaviour mirrors how Slack / Discord / Lark handle the - * same race condition. - */ - -import { useCallback, useRef } from 'react'; - -export interface ImeEnterGuard { - isImeEnter: (e: React.KeyboardEvent) => boolean; - handleCompositionStart: () => void; - handleCompositionEnd: () => void; -} - -export function useImeEnterGuard(): ImeEnterGuard { - const isImeComposingRef = useRef(false); - - const handleCompositionStart = useCallback(() => { - isImeComposingRef.current = true; - }, []); - - const handleCompositionEnd = useCallback(() => { - isImeComposingRef.current = false; - }, []); - - const isImeEnter = useCallback((e: React.KeyboardEvent) => { - const native = e.nativeEvent as KeyboardEvent | undefined; - if (isImeComposingRef.current) return true; - if (native?.isComposing) return true; - // `keyCode === 229` is the canonical IME "in-flight" signal and is - // still emitted by every evergreen browser even though the field is - // marked legacy. It catches the race where the IME swallows Enter - // to confirm a candidate but `isComposing` has already cleared. - if (native?.keyCode === 229) return true; - return false; - }, []); - - return { isImeEnter, handleCompositionStart, handleCompositionEnd }; -} diff --git a/src/web-ui/src/flow_chat/hooks/useImeOwnedKeyGuard.ts b/src/web-ui/src/flow_chat/hooks/useImeOwnedKeyGuard.ts new file mode 100644 index 0000000000..de12eb0ce1 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useImeOwnedKeyGuard.ts @@ -0,0 +1,36 @@ +/** + * IME ownership detection for keyboard shortcuts on text inputs. + * + * Composition lifecycle tracking covers browsers where the native keyboard + * event is incomplete. The native signals cover event-ordering races where + * composition has ended locally but the IME still owns the key. + */ + +import { useCallback, useRef } from 'react'; + +export interface ImeOwnedKeyGuard { + isImeOwnedKey: (event: React.KeyboardEvent) => boolean; + handleCompositionStart: () => void; + handleCompositionEnd: () => void; +} + +export function useImeOwnedKeyGuard(): ImeOwnedKeyGuard { + const isImeComposingRef = useRef(false); + + const handleCompositionStart = useCallback(() => { + isImeComposingRef.current = true; + }, []); + + const handleCompositionEnd = useCallback(() => { + isImeComposingRef.current = false; + }, []); + + const isImeOwnedKey = useCallback((event: React.KeyboardEvent) => { + const nativeEvent = event.nativeEvent as KeyboardEvent | undefined; + return isImeComposingRef.current + || nativeEvent?.isComposing === true + || nativeEvent?.keyCode === 229; + }, []); + + return { isImeOwnedKey, handleCompositionStart, handleCompositionEnd }; +} diff --git a/src/web-ui/src/flow_chat/services/SessionRollbackService.test.ts b/src/web-ui/src/flow_chat/services/SessionRollbackService.test.ts index 812ee8571f..6cf6d85c15 100644 --- a/src/web-ui/src/flow_chat/services/SessionRollbackService.test.ts +++ b/src/web-ui/src/flow_chat/services/SessionRollbackService.test.ts @@ -39,6 +39,8 @@ describe('SessionRollbackService', () => { sessions.set('session-1', { sessionId: 'session-1', workspacePath: 'E:/workspace', + workspaceId: 'local_workspace-1', + workspaceHostname: 'localhost', config: {}, isPartial: true, dialogTurns: [{ id: 'turn-7', storageTurnIndex: 7 }], @@ -66,6 +68,8 @@ describe('SessionRollbackService', () => { expect(agentApiMock.rollbackSessionToTurn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-1', + workspaceId: 'local_workspace-1', + workspaceHostname: 'localhost', targetTurnId: 'turn-7', expectedStorageTurnIndex: 7, expectedCatalogRevision: 'catalog-3', diff --git a/src/web-ui/src/flow_chat/services/SessionRollbackService.ts b/src/web-ui/src/flow_chat/services/SessionRollbackService.ts index ced54d6683..f143b65441 100644 --- a/src/web-ui/src/flow_chat/services/SessionRollbackService.ts +++ b/src/web-ui/src/flow_chat/services/SessionRollbackService.ts @@ -116,6 +116,8 @@ export async function rollbackSessionToTurn( const outcome = await agentAPI.rollbackSessionToTurn({ workspacePath, + workspaceId: session.workspaceId ?? session.config.workspaceId, + workspaceHostname: session.workspaceHostname, sessionId: request.sessionId, targetTurnId: request.targetTurnId, expectedStorageTurnIndex, diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 4ed8a675c6..f180bf4de6 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -357,6 +357,8 @@ export type LoadSessionTurnWindowResponse = export interface RollbackSessionToTurnRequest { workspacePath: string; + workspaceId?: string; + workspaceHostname?: string; sessionId: string; targetTurnId: string; expectedStorageTurnIndex?: number; diff --git a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts index 8f3dbda4c4..5e54cd27d2 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ReviewPlatformAPI.ts @@ -101,6 +101,8 @@ export interface ReviewPlatformPullRequest { additions: number; deletions: number; changedFiles: number; + /** Missing on older backends; only an explicit false means the count is unknown. */ + changedFileCountKnown?: boolean; comments: number; reviewDecision: ReviewDecision; checks: ReviewChecks; diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 5f1e3b8a2e..c54c53ae52 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -40,6 +40,7 @@ import { modelRoundItemAppearanceDescriptor } from '@/flow_chat/components/moder import { deepReviewActionBarAppearanceDescriptor } from '@/flow_chat/deep-review/action-bar/appearance'; import { modelSelectorAppearanceDescriptor } from '@/flow_chat/components/ModelSelector.appearance'; import { reasoningPresetSelectorAppearanceDescriptor } from '@/flow_chat/components/ReasoningPresetSelector.appearance'; +import { acpModeSelectorAppearanceDescriptor } from '@/flow_chat/components/AcpModeSelector.appearance'; import { flowChatHeaderAppearanceDescriptor } from '@/flow_chat/components/modern/FlowChatHeader.appearance'; import { flowChatTurnRailAppearanceDescriptor } from '@/flow_chat/components/modern/FlowChatTurnRail.appearance'; import { sessionFilesBadgeAppearanceDescriptor } from '@/flow_chat/components/modern/SessionFilesBadge.appearance'; @@ -333,6 +334,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(deepReviewActionBarAppearanceDescriptor) .registerComponent(modelSelectorAppearanceDescriptor) .registerComponent(reasoningPresetSelectorAppearanceDescriptor) + .registerComponent(acpModeSelectorAppearanceDescriptor) .registerComponent(flowChatHeaderAppearanceDescriptor) .registerComponent(flowChatTurnRailAppearanceDescriptor) .registerComponent(sessionFilesBadgeAppearanceDescriptor) diff --git a/tests/e2e/config/wdio.conf_l1.ts b/tests/e2e/config/wdio.conf_l1.ts index c4fa678bce..12578cb0c2 100644 --- a/tests/e2e/config/wdio.conf_l1.ts +++ b/tests/e2e/config/wdio.conf_l1.ts @@ -17,6 +17,7 @@ export const config = createEmbeddedConfig( '../specs/l1-dialog.spec.ts', '../specs/l1-chat.spec.ts', '../specs/l1-chat-scroll-whitespace.spec.ts', + '../specs/l1-local-rollback-path-collision.spec.ts', ], 'L1' ); diff --git a/tests/e2e/specs/l1-local-rollback-path-collision.spec.ts b/tests/e2e/specs/l1-local-rollback-path-collision.spec.ts new file mode 100644 index 0000000000..b471bdadc1 --- /dev/null +++ b/tests/e2e/specs/l1-local-rollback-path-collision.spec.ts @@ -0,0 +1,166 @@ +/** + * L1 regression coverage for local rollback when an SSH workspace has the + * same absolute path. + */ + +import { browser, expect } from '@wdio/globals'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +type WorkspaceInfo = { + id: string; + rootPath: string; +}; + +type InvokeResult = + | { ok: true; value: T } + | { ok: false; error: string }; + +async function invoke(command: string, args: unknown): Promise> { + return browser.execute(async (targetCommand: string, targetArgs: unknown) => { + const tauri = (window as typeof window & { + __TAURI__?: { + core?: { + invoke?: (command: string, args?: unknown) => Promise; + }; + }; + }).__TAURI__; + const tauriInvoke = tauri?.core?.invoke; + if (typeof tauriInvoke !== 'function') { + return { ok: false as const, error: 'Tauri invoke is unavailable' }; + } + + try { + return { + ok: true as const, + value: await tauriInvoke(targetCommand, targetArgs) as T, + }; + } catch (error) { + return { ok: false as const, error: String(error) }; + } + }, command, args); +} + +async function invokeExpectingFailure(command: string, args: unknown): Promise { + const driverPort = Number(process.env.BITFUN_E2E_WEBDRIVER_PORT || 4445); + const response = await fetch( + `http://127.0.0.1:${driverPort}/session/${browser.sessionId}/execute/sync`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + script: `return (async (command, args) => { + return window.__TAURI__.core.invoke(command, args); + }).apply(null, arguments);`, + args: [command, args], + }), + }, + ); + const payload = await response.json() as { + value?: { message?: string } | string; + }; + expect(response.ok).toBe(false); + return typeof payload.value === 'string' + ? payload.value + : payload.value?.message || JSON.stringify(payload.value); +} + +describe('L1 Local rollback workspace identity', () => { + const connectionId = 'e2e-rollback-path-collision'; + let fixtureRoot = ''; + let localWorkspaceId: string | null = null; + let remoteWorkspaceId: string | null = null; + + before(() => { + fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-rollback-collision-e2e-')), + ); + }); + + it('uses explicit local identity when a registered SSH workspace has the same path', async () => { + const remoteOpen = await invoke('open_remote_workspace', { + request: { + remotePath: fixtureRoot, + connectionId, + connectionName: 'E2E rollback collision', + sshHost: 'rollback-collision.example', + }, + }); + expect(remoteOpen.ok).toBe(true); + if (!remoteOpen.ok) { + throw new Error(remoteOpen.error); + } + remoteWorkspaceId = remoteOpen.value.id; + expect(remoteWorkspaceId.startsWith('remote_')).toBe(true); + + const localOpen = await invoke('open_workspace', { + request: { path: fixtureRoot }, + }); + expect(localOpen.ok).toBe(true); + if (!localOpen.ok) { + throw new Error(localOpen.error); + } + localWorkspaceId = localOpen.value.id; + expect(localWorkspaceId.startsWith('local_')).toBe(true); + expect(localWorkspaceId).not.toBe(remoteWorkspaceId); + + const baseRequest = { + workspacePath: fixtureRoot, + sessionId: 'e2e-rollback-collision-session', + targetTurnId: 'e2e-rollback-collision-turn', + }; + + const legacyError = await invokeExpectingFailure('rollback_session_to_turn', { + request: baseRequest, + }); + expect(legacyError).toContain('not supported for remote workspaces'); + + const localIdentityError = await invokeExpectingFailure('rollback_session_to_turn', { + request: { + ...baseRequest, + workspaceId: localWorkspaceId, + workspaceHostname: 'localhost', + }, + }); + expect(localIdentityError).not.toContain('not supported for remote workspaces'); + expect(localIdentityError).not.toContain('unavailable for remote workspaces'); + + const conflictingRemoteError = await invokeExpectingFailure('rollback_session_to_turn', { + request: { + ...baseRequest, + workspaceId: localWorkspaceId, + workspaceHostname: 'localhost', + remoteConnectionId: connectionId, + }, + }); + expect(conflictingRemoteError).toContain('not supported for remote workspaces'); + }); + + after(async () => { + if (remoteWorkspaceId) { + await invoke('close_workspace', { + request: { workspaceId: remoteWorkspaceId }, + }); + } else if (fixtureRoot) { + await invoke('remote_remove_workspace', { + connectionId, + remotePath: fixtureRoot, + }); + } + + if (localWorkspaceId) { + await invoke('close_workspace', { + request: { workspaceId: localWorkspaceId }, + }); + } + + if ( + fixtureRoot + && path.basename(fixtureRoot).startsWith('bitfun-rollback-collision-e2e-') + && fs.existsSync(fixtureRoot) + ) { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +});