diff --git a/.dockerignore b/.dockerignore index 3ef58448fd..7e46665a23 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,7 @@ !src/crates/services/relay-service/Cargo.toml !src/crates/services/relay-service/src/ !src/crates/services/relay-service/src/** +!src/crates/services/page-function-runtime/ +!src/crates/services/page-function-runtime/Cargo.toml +!src/crates/services/page-function-runtime/src/ +!src/crates/services/page-function-runtime/src/** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 962649f855..b06162d606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: cli-test: name: CLI Tests (ubuntu-latest) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v5 @@ -52,8 +53,8 @@ jobs: cache-bin: false save-if: ${{ github.event_name != 'pull_request' }} - - name: Run CLI, ACP, and agent runtime tests - run: cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime + - name: Run CLI, ACP, SDK Host, and agent runtime tests + run: cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-sdk-host -p bitfun-sdk-host-app -p bitfun-agent-runtime # ── Rust: build check ───────────────────────────────────────────── rust-build-check: @@ -136,13 +137,38 @@ jobs: - name: Check compilation run: cargo check --locked --workspace + - name: Run SDK Host terminal cleanup regressions + # This PR changes only the PTY/session shutdown path required by + # connection-scoped Session cleanup. Keep the gate scoped to those + # regressions instead of adopting unrelated terminal command tests. + run: | + cargo test --locked -p terminal-core shutdown_returns_only_after_process_exit_is_confirmed -- --test-threads=1 + cargo test --locked -p terminal-core shutdown_evicts_a_process_whose_controller_already_confirmed_exit -- --test-threads=1 + cargo test --locked -p terminal-core background_only_binding_is_owned_by_the_session -- --test-threads=1 + + - name: Run standalone SDK Host process smokes + run: cargo test --locked -p bitfun-sdk-host-app --test process_initialization --test stdio_process + - name: Run Windows ConPTY CLI smoke test if: runner.os == 'Windows' - run: cargo test --locked -p bitfun-cli --test tui_terminal_process + run: cargo test --locked -p bitfun-cli --test terminal_process_contracts - name: Run core and desktop Rust tests run: cargo test --locked -p bitfun-core -p bitfun-desktop + # These crates own platform-sensitive behavior that is not exercised by + # testing bitfun-core/bitfun-desktop alone. Keep their focused contract + # suites in the OS matrix so Linux success cannot hide Windows/macOS + # regressions in worker interruption, relay storage, or OAuth handling. + - name: Run Page Functions runtime tests + run: cargo test --locked -p bitfun-page-function-runtime + + - name: Run Relay service tests + run: cargo test --locked -p bitfun-relay-service + + - name: Run subscription authentication tests + run: cargo test --locked -p bitfun-ai-adapters --features subscription-auth subscription_auth + # ── Frontend: build ──────────────────────────────────────────────── frontend-build: name: Frontend Build diff --git a/.github/workflows/cli-package-manual.yml b/.github/workflows/cli-package-manual.yml index e083bd59b5..41451a6aa0 100644 --- a/.github/workflows/cli-package-manual.yml +++ b/.github/workflows/cli-package-manual.yml @@ -13,9 +13,10 @@ on: - linux-x64 - macos-arm64 - macos-x64 + - windows-x64 - all ubuntu_version: - description: "Ubuntu runner baseline for Linux builds. Ignored for macOS." + description: "Ubuntu runner baseline for Linux builds. Ignored for macOS and Windows." required: true default: "22.04" type: choice @@ -83,19 +84,22 @@ jobs: case "${INPUT_PLATFORM}" in linux-arm64) - MATRIX="{\"platform\":[{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + MATRIX="{\"platform\":[{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\"}]}" ;; linux-x64) - MATRIX="{\"platform\":[{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + MATRIX="{\"platform\":[{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\"}]}" ;; macos-arm64) - MATRIX='{"platform":[{"os":"macos-15","name":"macos-arm64","target":"aarch64-apple-darwin","can_smoke_test":true}]}' + MATRIX='{"platform":[{"os":"macos-15","name":"macos-arm64","target":"aarch64-apple-darwin"}]}' ;; macos-x64) - MATRIX='{"platform":[{"os":"macos-15-intel","name":"macos-x64","target":"x86_64-apple-darwin","can_smoke_test":true}]}' + MATRIX='{"platform":[{"os":"macos-15-intel","name":"macos-x64","target":"x86_64-apple-darwin"}]}' + ;; + windows-x64) + MATRIX='{"platform":[{"os":"windows-latest","name":"windows-x64","target":"x86_64-pc-windows-msvc"}]}' ;; all) - MATRIX="{\"platform\":[{\"os\":\"macos-15\",\"name\":\"macos-arm64\",\"target\":\"aarch64-apple-darwin\",\"can_smoke_test\":true},{\"os\":\"macos-15-intel\",\"name\":\"macos-x64\",\"target\":\"x86_64-apple-darwin\",\"can_smoke_test\":true},{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\",\"can_smoke_test\":true},{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + MATRIX="{\"platform\":[{\"os\":\"macos-15\",\"name\":\"macos-arm64\",\"target\":\"aarch64-apple-darwin\"},{\"os\":\"macos-15-intel\",\"name\":\"macos-x64\",\"target\":\"x86_64-apple-darwin\"},{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\"},{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\"},{\"os\":\"windows-latest\",\"name\":\"windows-x64\",\"target\":\"x86_64-pc-windows-msvc\"}]}" ;; *) echo "Unsupported platform: ${INPUT_PLATFORM}" >&2 @@ -148,7 +152,8 @@ jobs: shared-key: "cli-manual-v1-${{ matrix.platform.name }}" cache-bin: false - - name: Build bitfun-cli + - name: Build CLI (Unix) + if: runner.os != 'Windows' shell: bash run: | set -euo pipefail @@ -156,16 +161,30 @@ jobs: --target ${{ matrix.platform.target }} \ -p bitfun-cli - - name: Smoke test (--version) - if: matrix.platform.can_smoke_test == true + - name: Build CLI (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + RUSTFLAGS: -C target-feature=+crt-static + run: cargo build --release --target ${{ matrix.platform.target }} -p bitfun-cli + + - name: Test installer (Unix) + if: runner.os != 'Windows' shell: bash - run: | - set -euo pipefail - BIN="target/${{ matrix.platform.target }}/release/bitfun-cli" - "$BIN" --version - "$BIN" --help > /dev/null + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + run: bash scripts/cli/test-install-unix.sh "${{ matrix.platform.target }}" - - name: Stage tarball + - name: Test installer (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + RUSTFLAGS: -C target-feature=+crt-static + run: ./scripts/cli/test-install-windows.ps1 -Target $env:CARGO_BUILD_TARGET + + - name: Package and smoke test (Unix) + if: runner.os != 'Windows' id: stage shell: bash env: @@ -173,56 +192,17 @@ jobs: TARGET: ${{ matrix.platform.target }} run: | set -euo pipefail + bash scripts/cli/package-unix.sh "$VERSION" "$TARGET" - STAGE_DIR="$(pwd)/dist-cli/bitfun-cli-${VERSION}-${TARGET}" - mkdir -p "$STAGE_DIR" - - cp "target/${TARGET}/release/bitfun-cli" "$STAGE_DIR/" - cp LICENSE "$STAGE_DIR/" || true - cp README.md "$STAGE_DIR/" || true - - if [[ -d "src/apps/cli/themes" ]]; then - cp -R "src/apps/cli/themes" "$STAGE_DIR/themes" - fi - if [[ -d "src/apps/cli/prompts" ]]; then - cp -R "src/apps/cli/prompts" "$STAGE_DIR/prompts" - fi - - ARCHIVE="bitfun-cli-${VERSION}-${TARGET}.tar.gz" - tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256" - else - shasum -a 256 "$ARCHIVE" > "${ARCHIVE}.sha256" - fi - - echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" - echo "checksum=${ARCHIVE}.sha256" >> "$GITHUB_OUTPUT" - - - name: Smoke test packaged archive - shell: bash + - name: Package and smoke test (Windows) + if: runner.os == 'Windows' + id: stage-windows + shell: pwsh env: - ARCHIVE: ${{ steps.stage.outputs.archive }} - CHECKSUM: ${{ steps.stage.outputs.checksum }} + VERSION: ${{ needs.prepare.outputs.version }} + TARGET: ${{ matrix.platform.target }} run: | - set -euo pipefail - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -c "$CHECKSUM" - else - shasum -a 256 -c "$CHECKSUM" - fi - - EXTRACT_DIR="$(mktemp -d)" - trap 'rm -rf "$EXTRACT_DIR"' EXIT - tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" - - shopt -s nullglob - BIN_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) - [[ "${#BIN_CANDIDATES[@]}" -eq 1 ]] - "${BIN_CANDIDATES[0]}" --version - "${BIN_CANDIDATES[0]}" --help > /dev/null + ./scripts/cli/package-windows.ps1 -Version $env:VERSION -Target $env:TARGET - name: Upload artifact uses: actions/upload-artifact@v6 @@ -230,5 +210,5 @@ jobs: name: bitfun-cli-${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.short_sha }}-${{ matrix.platform.name }} if-no-files-found: error path: | - ${{ steps.stage.outputs.archive }} - ${{ steps.stage.outputs.checksum }} + ${{ steps.stage.outputs.archive || steps.stage-windows.outputs.archive }} + ${{ steps.stage.outputs.checksum || steps.stage-windows.outputs.checksum }} diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml index e10ff55e39..7bd0350796 100644 --- a/.github/workflows/cli-package.yml +++ b/.github/workflows/cli-package.yml @@ -17,7 +17,7 @@ on: type: boolean permissions: - contents: write + contents: read concurrency: group: cli-package-${{ github.event.release.tag_name || inputs.tag_name || github.sha }} @@ -87,19 +87,18 @@ jobs: - os: macos-15 name: macos-arm64 target: aarch64-apple-darwin - can_smoke_test: true - os: macos-15-intel name: macos-x64 target: x86_64-apple-darwin - can_smoke_test: true - os: ubuntu-latest name: linux-x64 target: x86_64-unknown-linux-gnu - can_smoke_test: true - os: ubuntu-24.04-arm name: linux-arm64 target: aarch64-unknown-linux-gnu - can_smoke_test: true + - os: windows-latest + name: windows-x64 + target: x86_64-pc-windows-msvc steps: - name: Checkout @@ -132,7 +131,8 @@ jobs: shared-key: "cli-v1-${{ matrix.platform.name }}" cache-bin: false - - name: Build bitfun-cli + - name: Build CLI (Unix) + if: runner.os != 'Windows' shell: bash run: | set -euo pipefail @@ -140,16 +140,30 @@ jobs: --target ${{ matrix.platform.target }} \ -p bitfun-cli - - name: Smoke test (--version) - if: matrix.platform.can_smoke_test == true + - name: Build CLI (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + RUSTFLAGS: -C target-feature=+crt-static + run: cargo build --release --target ${{ matrix.platform.target }} -p bitfun-cli + + - name: Test installer (Unix) + if: runner.os != 'Windows' shell: bash - run: | - set -euo pipefail - BIN="target/${{ matrix.platform.target }}/release/bitfun-cli" - "$BIN" --version - "$BIN" --help > /dev/null + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + run: bash scripts/cli/test-install-unix.sh "${{ matrix.platform.target }}" + + - name: Test installer (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + RUSTFLAGS: -C target-feature=+crt-static + run: ./scripts/cli/test-install-windows.ps1 -Target $env:CARGO_BUILD_TARGET - - name: Stage tarball + - name: Package and smoke test (Unix) + if: runner.os != 'Windows' id: stage shell: bash env: @@ -157,58 +171,17 @@ jobs: TARGET: ${{ matrix.platform.target }} run: | set -euo pipefail + bash scripts/cli/package-unix.sh "$VERSION" "$TARGET" - STAGE_DIR="$(pwd)/dist-cli/bitfun-cli-${VERSION}-${TARGET}" - mkdir -p "$STAGE_DIR" - - cp "target/${TARGET}/release/bitfun-cli" "$STAGE_DIR/" - cp LICENSE "$STAGE_DIR/" || true - cp README.md "$STAGE_DIR/" || true - - # Optional: bundle prompts and themes shipped alongside the CLI source - if [[ -d "src/apps/cli/themes" ]]; then - cp -R "src/apps/cli/themes" "$STAGE_DIR/themes" - fi - if [[ -d "src/apps/cli/prompts" ]]; then - cp -R "src/apps/cli/prompts" "$STAGE_DIR/prompts" - fi - - ARCHIVE="bitfun-cli-${VERSION}-${TARGET}.tar.gz" - tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" - - # SHA256 (portable: shasum on macOS, sha256sum on Linux) - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256" - else - shasum -a 256 "$ARCHIVE" > "${ARCHIVE}.sha256" - fi - - echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" - echo "checksum=${ARCHIVE}.sha256" >> "$GITHUB_OUTPUT" - - - name: Smoke test packaged archive - shell: bash + - name: Package and smoke test (Windows) + if: runner.os == 'Windows' + id: stage-windows + shell: pwsh env: - ARCHIVE: ${{ steps.stage.outputs.archive }} - CHECKSUM: ${{ steps.stage.outputs.checksum }} + VERSION: ${{ needs.prepare.outputs.version }} + TARGET: ${{ matrix.platform.target }} run: | - set -euo pipefail - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -c "$CHECKSUM" - else - shasum -a 256 -c "$CHECKSUM" - fi - - EXTRACT_DIR="$(mktemp -d)" - trap 'rm -rf "$EXTRACT_DIR"' EXIT - tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" - - shopt -s nullglob - BIN_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) - [[ "${#BIN_CANDIDATES[@]}" -eq 1 ]] - "${BIN_CANDIDATES[0]}" --version - "${BIN_CANDIDATES[0]}" --help > /dev/null + ./scripts/cli/package-windows.ps1 -Version $env:VERSION -Target $env:TARGET - name: Upload artifact uses: actions/upload-artifact@v6 @@ -216,8 +189,8 @@ jobs: name: bitfun-cli-${{ needs.prepare.outputs.release_tag }}-${{ matrix.platform.name }} if-no-files-found: error path: | - ${{ steps.stage.outputs.archive }} - ${{ steps.stage.outputs.checksum }} + ${{ steps.stage.outputs.archive || steps.stage-windows.outputs.archive }} + ${{ steps.stage.outputs.checksum || steps.stage-windows.outputs.checksum }} # ── Aggregate and upload to GitHub Release ───────────────────────── upload-release-assets: @@ -225,6 +198,8 @@ jobs: needs: [prepare, build] if: needs.prepare.outputs.upload_to_release == 'true' runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Download CLI artifacts @@ -243,16 +218,14 @@ jobs: working-directory: cli-release-assets run: | set -euo pipefail - # Sort for reproducibility; only the tarballs go into the combined file - ( - for f in $(ls bitfun-cli-*.tar.gz | sort); do - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$f" - else - shasum -a 256 "$f" - fi - done - ) > SHA256SUMS + # Sort for reproducibility; include Unix tarballs and the Windows zip. + mapfile -t archives < <( + find . -maxdepth 1 -type f \ + \( -name 'bitfun-cli-*.tar.gz' -o -name 'bitfun-cli-*.zip' \) \ + -printf '%f\n' | sort + ) + [[ "${#archives[@]}" -gt 0 ]] + sha256sum "${archives[@]}" > SHA256SUMS echo "---- SHA256SUMS ----" cat SHA256SUMS @@ -264,28 +237,43 @@ jobs: files: | cli-release-assets/bitfun-cli-*.tar.gz cli-release-assets/bitfun-cli-*.tar.gz.sha256 + cli-release-assets/bitfun-cli-*.zip + cli-release-assets/bitfun-cli-*.zip.sha256 cli-release-assets/SHA256SUMS fail_on_unmatched_files: true - # Tell GCWing/homebrew-tap to bump the bitfun-cli formula now that - # the tarballs are live on the release. Safe no-op if the secret - # has not been configured (e.g. on forks or before the tap exists). - - name: Notify homebrew-tap + # This post-publication dispatch only proves that GitHub accepted the + # notification. The external tap owns formula rollout and readback. + - name: Notify homebrew-tap (post-publication) env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_DISPATCH_TOKEN }} RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + OFFICIAL_REPOSITORY: ${{ github.repository == 'GCWing/BitFun' }} shell: bash run: | set -euo pipefail if [[ -z "${GH_TOKEN}" ]]; then - echo "HOMEBREW_TAP_DISPATCH_TOKEN not set; skipping tap dispatch." + if [[ "${OFFICIAL_REPOSITORY}" == "true" ]]; then + echo "::error::HOMEBREW_TAP_DISPATCH_TOKEN is required for the official post-publication notification." + exit 1 + fi + echo "HOMEBREW_TAP_DISPATCH_TOKEN not set on fork; skipping tap dispatch." exit 0 fi echo "Dispatching bitfun-release-published for ${RELEASE_TAG} to GCWing/homebrew-tap" + PAYLOAD="$(jq -cn --arg tag_name "${RELEASE_TAG}" '{ + event_type: "bitfun-release-published", + client_payload: { + tag_name: $tag_name, + primary_binary: "bitfun", + deprecated_binary: "bitfun-cli", + deprecated: true + } + }')" curl -fsSL -X POST \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ https://api.github.com/repos/GCWing/homebrew-tap/dispatches \ - -d "{\"event_type\":\"bitfun-release-published\",\"client_payload\":{\"tag_name\":\"${RELEASE_TAG}\"}}" + -d "$PAYLOAD" diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 20ad8c9247..a5ea37c25f 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -26,7 +26,7 @@ Stable Contracts and Security Control Plane 的边界以 | 1 | 接口与入口层 | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | 产品宿主、命令、UI 入口、协议接口和跨形态测试 | desktop、CLI、server、relay、Web UI、mobile web、installer、E2E、`acp` | 最近的本地 `AGENTS.md`;[interfaces](src/crates/interfaces/AGENTS.md) | | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线和 adapter/service 注册 | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver/OpenCode 协议 adapter 和外部 provider 转换 | `ai-adapters`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | -| 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、LSP plugin registry、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `relay-service`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、LSP plugin registry、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | 执行原语层 | `src/crates/execution` | 可移植 agent、harness、stream、DeepReview policy/report、plugin host 边界、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-host`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、LSP protocol/plugin DTO、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | @@ -55,7 +55,7 @@ pnpm run desktop:dev # 完整热更新:Vite HMR + Rust 自动重 pnpm run desktop:preview:debug # 复用预构建二进制 + Vite HMR;无 Rust 自动重编译 pnpm run dev:web # 纯浏览器前端 pnpm run cli:dev # CLI 运行时 -pnpm run cli:install # release 编译并安装 bitfun-cli 到 ~/.local/bin(写入 bashrc/zshrc PATH) +pnpm run cli:install # release 编译并安装 bitfun(Windows/macOS/Linux;含废弃兼容入口 bitfun-cli) # 检查 pnpm run fmt:rs # 只格式化已改动 / 已暂存的 Rust 文件 diff --git a/AGENTS.md b/AGENTS.md index b3067d7779..200c8e69ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Keep crate dependencies inside each layer to the smallest set needed. | 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, adapter/service registration, and ecosystem-neutral source coordination | `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver/OpenCode protocol adapters and external-provider translation | `ai-adapters`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | -| 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `relay-service`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | Execution primitives | `src/crates/execution` | Portable agent, harness, stream, DeepReview policy/report, plugin host boundary, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-host`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, LSP protocol/plugin DTOs, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | @@ -58,7 +58,7 @@ pnpm run desktop:dev # full hot-reload: Vite HMR + Rust auto-rebui pnpm run desktop:preview:debug # reuse pre-built binary + Vite HMR; no Rust auto-rebuild pnpm run dev:web # browser-only frontend pnpm run cli:dev # CLI runtime -pnpm run cli:install # build release + install bitfun-cli to ~/.local/bin (bashrc/zshrc PATH) +pnpm run cli:install # build release + install bitfun (Windows/macOS/Linux; deprecated bitfun-cli included) # Check pnpm run fmt:rs # format only changed / staged Rust files diff --git a/BitFun-Installer/src/i18n/generatedLocaleContract.ts b/BitFun-Installer/src/i18n/generatedLocaleContract.ts index f87af2a6be..2f90776e19 100644 --- a/BitFun-Installer/src/i18n/generatedLocaleContract.ts +++ b/BitFun-Installer/src/i18n/generatedLocaleContract.ts @@ -61,8 +61,7 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { "codeAgent": "Code Agent", "deepReview": "Review: Strict", "settings": "Settings", - "workspace": "Workspace", - "accountLogin": "Account Login" + "workspace": "Workspace" }, "modes": { "agentic": "Agentic Mode", @@ -112,8 +111,7 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { "codeAgent": "代码助手", "deepReview": "严格审查", "settings": "设置", - "workspace": "工作区", - "accountLogin": "账户登录" + "workspace": "工作区" }, "modes": { "agentic": "代理模式", @@ -163,8 +161,7 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { "codeAgent": "程式碼助手", "deepReview": "嚴格審查", "settings": "設定", - "workspace": "工作區", - "accountLogin": "帳號登入" + "workspace": "工作區" }, "modes": { "agentic": "代理模式", diff --git a/Cargo.toml b/Cargo.toml index da2702a9ce..7085972944 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,31 @@ [workspace] members = [ "src/apps/cli", + "src/apps/sdk-host", "src/apps/desktop", "src/apps/server", "src/apps/relay-server", "src/crates/interfaces/acp", + "src/crates/interfaces/sdk-host", "src/crates/assembly/core", "src/crates/assembly/external-sources", "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", + "src/crates/adapters/claude-code-adapter", + "src/crates/adapters/codex-adapter", + "src/crates/adapters/static-hook-support", "src/crates/adapters/webdriver", "src/crates/adapters/transport", "src/crates/services/services-core", "src/crates/services/services-integrations", "src/crates/services/relay-service", + "src/crates/services/page-function-runtime", "src/crates/services/terminal", "src/crates/assembly/product-capabilities", "src/crates/contracts/product-domains", "src/crates/execution/agent-runtime", "src/crates/execution/agent-stream", + "src/crates/execution/tool-call-jsonrepair", "src/crates/execution/tool-contracts", "src/crates/execution/harness", "src/crates/execution/plugin-runtime-host", @@ -121,6 +128,10 @@ glob = "0.3" ignore = "0.4" notify = "8.2" dirs = "6.0" +keyring-core = "1.0.0" +apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] } +windows-native-keyring-store = "1.1.0" +zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] } dark-light = "1.1" dunce = "1" filetime = "0.2" @@ -163,7 +174,9 @@ sse-stream = "0.2.3" which = "8.0" similar = "2.5" urlencoding = "2.1" -oxc = { version = "0.137.0", default-features = false, features = ["ast_visit", "codegen", "semantic", "transformer"] } +oxc = { version = "0.138.0", default-features = false, features = ["ast_visit", "codegen", "semantic", "transformer"] } +oxc-parse = { package = "oxc", version = "0.138.0", default-features = false } +rquickjs = { version = "0.9", default-features = false, features = ["classes", "properties", "macro"] } # Tauri (desktop only) tauri = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } diff --git a/README.md b/README.md index 43f9b255bc..e1370ee920 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,17 @@ --- -## Local AI Workbench Built Around the Code Agent +## A New-Generation Foundation for Agentic Applications -BitFun is a local AI workbench built around a Code Agent designed for long-horizon tasks, engineering execution, and token economy. +BitFun puts the engineering capabilities of the Code Agent at its core and brings together Co-work and Claw (Assistant Agent) for coding, office work, and more real-world scenarios—creating a local-first, new-generation foundation for agentic applications, powered by a Rust-built Agent Runtime and a polished desktop app experience. -It can understand complex context, call tools, wait for results, correct deviations, and keep long-horizon tasks moving until they reach a deliverable state. Coding, research, office work, documents, desktop operations, and extensible workflows all happen in the same local desktop environment. +- **Lights-Out Factory** (In Progress): Design during the day, let tasks flow to the server and run through the night, then review the results in the morning. +- **Infinite Radius** (In Progress): Extending from desktop and browser to mobile, wearables, and more devices, so work stays accessible and continuous. +- **App Evolution**: Build tailored workflows with custom Agents, MCP, Skills, Mini Apps, or source-level customization. **Community contributors have already created specialized versions for short-form drama, media, and more**. +- **Better, Faster, Cheaper**: Pursue greater efficiency, better results, and lower cost. +- **Ultimate Desktop**: Continuously refine an easier-to-use, more capable, and more beautiful desktop experience. -Core goal: move AI from iterative Agent Loop execution into a productivity system that can autonomously complete long-horizon work. - -![readme_hero](./png/readme_hero_CN.png) +![readme_hero](./png/readme_hero.png) --- @@ -48,13 +50,13 @@ Benchmark references: [SWE-Bench-Pro](https://labs.scale.com/leaderboard/swe_ben Agent economy needs to be evaluated across end-to-end token consumption, execution time, and KV Cache reuse. The current snapshot first covers KV Cache behavior from the same SWE-Bench-Pro round: BitFun's average KV Cache hit rate was **98.67%**. The follow-up full benchmark report will add the broader cost and latency metrics. -![KV Cache hit rate distribution](./png/kv_cache_hit_rate.svg) +![KV Cache hit rate distribution](./png/kv_cache_hit_rate.png) ### 3. Other Experience Metrics Beyond cost, Agent experience also depends on how quickly it can retrieve context in very large engineering projects. For tens-of-millions-line repositories such as Chromium, BitFun uses **flashgrep** to reduce search time by up to about **94.6%**, with an average speedup of about **36.1x**. -![flashgrep search speed](./png/flashgrep_search_speed.svg) +![flashgrep search speed](./png/flashgrep_search_speed.png) --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 4a7a029227..d6ad883ca0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,13 +18,15 @@ --- -## 以 Code Agent 为核心的本地 AI 工作台 +## 融合多种 Agent 形态的新一代 AI 应用底座 -BitFun 基于一个面向长程任务、强调工程执行与 Token 经济性的 Code Agent 打造本地 AI 工作台。 +BitFun 以 Code Agent 的工程能力为核心,融合 Co-work 与 Claw(助手式 Agent),面向编码、办公和更多真实工作场景,打造本地优先的新一代 Agent 应用底座(含Rust构建的Agent Runtime 和完善的桌面端应用体验)。 -它能理解复杂上下文、调用工具、等待结果、修正偏差,把长程任务持续推进到可交付状态;编码、调研、办公、文档、桌面操作和可扩展工作流,都在同一个本地桌面环境里展开。 - -核心目标:让 AI 从“Agent Loop 的迭代执行”进化成“可自主完成长期工作”的生产力系统。 +- **黑灯工厂**(正在构建中):白天设计,夜间任务流转到服务器持续执行,早上直接验收成果。 +- **无限半径**(正在构建中):从桌面、浏览器持续延伸到移动端、可穿戴等更多设备,让工作随时接入、连续协作。 +- **应用进化**:支持自定义 Agent、MCP、Skills、Mini App 乃至源码级改造,组合专属工作流;**社区伙伴已拓展出短剧、媒体等丰富版本**。 +- **多快好省**:追求更高效率、更优效果与更低成本。 +- **极致桌面**:持续打磨更易用、更好用、更漂亮的桌面体验。 ![readme_hero_CN](./png/readme_hero_CN.png) @@ -48,13 +50,13 @@ BitFun 在 **SWE-Bench-Pro** 和 **SWE-Bench-Verified** 上均领先 Open Code Agent 执行是否经济,需要综合评估端到端 Token 消耗、执行耗时和 KV Cache 复用。当前先展示同一轮 SWE-Bench-Pro 中的 KV Cache 观察:BitFun 的平均 KV Cache 命中率为 **98.67%**。后续完整评测会继续补充更完整的成本与耗时指标。 -![KV Cache hit rate distribution](./png/kv_cache_hit_rate.svg) +![KV Cache hit rate distribution](./png/kv_cache_hit_rate.png) ### 3. 其他体验指标 成本之外,Agent 体验还取决于它能否在超大工程里快速找回上下文。面对 Chromium 这类千万行级代码仓库,BitFun 通过 **flashgrep** 最高降低约 **94.6%** 搜索耗时,平均加速约 **36.1x**。 -![flashgrep search speed](./png/flashgrep_search_speed.svg) +![flashgrep search speed](./png/flashgrep_search_speed.png) --- diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index bb40b06759..0afeae2323 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -7,22 +7,27 @@ crate 内部结构和行为保护要求。本文件记录设计约束,不记 结果见 [`product-customization-blueprint.md`](product-customization-blueprint.md);CLI 入口、配置兼容和 CLI Agent 体验边界见 [`cli-product-line-design.md`](cli-product-line-design.md);能力 Provider 如何装配、对外能力门面与 多宿主 adapter 的状态、权限、并发和兼容边界见 -[`capability-runtime-integration-design.md`](extensions/capability-runtime-integration-design.md)。 +[`capability-runtime-integration-design.md`](extensions/capability-runtime-integration-design.md);公开 BitFun Agent SDK 的 +用户心智、SDK Host、Headless CLI/ACP/Server 关系和 Claude Agent SDK 等价门槛见 +[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md)。 本文中的接口片段只说明依赖方向和职责,不自动构成当前 API 或实施承诺。当前接口名称、字段和消费方以代码为准; -新增公共类型前必须有真实生产调用方、版本边界和验证路径。现有 Agent Runtime SDK 仍是 v1 preview,CLI、ACP、 +新增公共类型前必须有真实生产调用方、版本边界和验证路径。现有 `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 主交互只消费由现有 Core owner 构造的窄口径 SDK 门面,尚未组装完整 Desktop profile。这些接入都不等于 -协调器、调度器、持久化或工具执行 owner 已迁移;ACP 的完整持久化历史、模式、模型目录/提供方配置、MCP、客户端路径与 -Desktop 的其余入口仍保留明确的兼容边界,活动会话的模型写入已通过 SDK 回到 Core owner。 +Desktop 主交互只消费由现有 Core owner 构造的窄口径应用门面,尚未组装完整 Desktop profile。这些接入都不等于 +协调器、调度器、持久化或工具执行 owner 已迁移;ACP 的完整持久化历史、模型/模式目录与提供方配置、MCP、客户端路径与 +Desktop 的其余入口仍保留明确的兼容边界,活动会话的模型/模式写入已通过 Agent Runtime API 回到 Core owner。 -阅读路径:第 1 节确认 SDK、内核、产品特性、扩展接口和 crate 边界;第 2-3 节说明稳定接口、 +阅读路径:先从公开 SDK 产品文档区分公开语言 SDK 与内部应用门面;再由第 1 节确认内核、产品特性、 +扩展接口和 crate 边界;第 2-3 节说明稳定接口、 运行时服务、内核、工具和工作流;第 4 节说明产品组装与扩展注册;第 5 节作为质量保护和 目标态判定标准。 ## 1. 设计目标与边界 -- 智能体内核可被 Desktop、CLI、Server、Remote、ACP、Web 和独立 SDK 形态嵌入。 +- 智能体内核可被 Desktop、CLI、Server、Remote、ACP、Web 和公开 SDK adapter 调用;这些入口共享应用用例, + 不共享 UI、协议或公开语言包。 - 智能体内核对外提供稳定、窄口径的 Rust 运行时接口,而不是暴露 `bitfun-core`、产品命令路径或具体管理器。 - 产品特性把内核能力组装为用户侧能力,可能同时触达 Rust 和 UI,但不拥有内核状态机或平台实现。 - 运行时内部接口、能力服务接口、扩展接口和主机内部 ABI 分层表达;OpenCode / ACP / 插件适配器仅承担映射和注册。 @@ -31,39 +36,40 @@ Desktop 的其余入口仍保留明确的兼容边界,活动会话的模型写 - 具体服务、界面宿主、插件运行时主机绑定和适配器清单集合由上层产品组装注入。 - 每个 crate 只依赖最小稳定集合,依赖方向可检查。 -### 1.1 SDK 发布边界 +### 1.1 Rust Runtime SDK(当前 preview)与公开 Agent SDK -Agent Runtime SDK 的发布边界以调用方能力为准,而不是以物理 crate 命名为准。达到目标状态时,外部调用方 -应能在不依赖 `bitfun-core`、app crate、Tauri 或产品内部 manager 的情况下完成以下动作: +当前 `agent-runtime::sdk` 是低层 **Rust Runtime SDK**,成熟度为 preview:它通过 Rust 类型化端口服务 Desktop、CLI、 +ACP 等现有入口,也可用于受控 Rust 嵌入。它不是 Python/TypeScript **BitFun Agent SDK**。两者调用同一个 +Agent Runtime,不形成两套 Agent loop。 -- 构建运行时:注入模型提供方、`RuntimeServices`、工具提供方、工作流提供方、智能体定义、 - 钩子和运行时配置。 -- 发起执行:创建或恢复会话,提交轮次,取消轮次,消费提供方无关事件流。 -- 执行工具:通过稳定工具清单、权限请求、工具结果、产物引用和取消语义 - 管理工具调用。 -- 扩展能力:通过注册表注册子智能体、提示模块、skill、MCP 工具、接口工具、工作流和轮次后处理器。 -- 处理运维语义:接收类型化错误、用量/成本/缓存事实、遥测事件、检查点/恢复事实和不支持能力。 +| 入口 | 面向谁 | 主要接口 | 不暴露 | +|---|---|---|---| +| Rust Runtime SDK | BitFun 内部入口、低层 Rust 嵌入 | Query/Session/Turn/事件等类型化 Rust 用例;builder 和 registry 仅用于内部装配 | Tauri/React、具体 manager、生态原始对象 | +| BitFun Agent SDK | Python/TypeScript 应用开发者 | `query()`、client/session、async Message/Event/Result、typed callback | builder、port、registry、Product Assembly、SDK Host protocol | -因此,SDK 可用性准备的最低标准是: +公开 Agent SDK 必须覆盖以下用户用例,而不是要求调用方自行装配 Runtime: -- 公共门面只暴露 builder、runner、请求/响应 DTO、事件流、类型化错误和注册表接口。 -- 所有 DTO 可序列化,所有运行时句柄通过类型化端口注入,不进入传输 schema。 -- `bitfun-agent-runtime`、工具原语、运行时服务和工作流能通过测试替身提供方独立测试。 -- 内部 SDK 最小特性不牵引 Desktop、Tauri、Git 提供方、MCP 客户端、AI HTTP 客户端、remote SSH 或产品 UI。 -- 完整产品能力只能通过产品组装或兼容 `bitfun-core/product-full` 组装,不反向污染 SDK 接口。 - -Agent Runtime SDK 和“对外能力门面”不是同一发布包必须同时暴露的接口。前者服务于嵌入式 Agent Runtime,后者 -只为外部宿主暴露当前场景需要的 Memory 查询、Workflow 调用、状态或事件等窄用例。若外部产品只调用一项 BitFun -能力,不得迫使其依赖完整 Runtime builder、产品组装、插件 Host ABI 或内部注册表。只有同一语义被真实嵌入方 -复用后,才允许两者共享稳定 DTO 或版本边界。 - -SDK 公共接口以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v1 preview: +| 用户用例 | 公开能力 | +|---|---| +| 运行 Agent | `query()`、异步流、结构化 Result、取消和执行上限 | +| 管理上下文 | Session create/resume/fork/close、Turn start/steer/cancel | +| 使用能力 | 内置 Tool、既有 MCP、Subagent、Skill 和明确的来源状态 | +| 控制副作用 | Permission 与 Hook callback;最终策略仍由 BitFun owner 决定 | +| 扩展应用 | Python/TypeScript 函数 Tool 和用户输入 callback | +| 运维 | 类型化错误、用量/成本/缓存、trace、checkpoint/恢复事实和 capability 状态 | + +Python/TypeScript SDK 通过匹配版本的本地 SDK Host 调用共享 Agent Runtime API;GUI、TUI、Headless CLI、 +ACP 和 Server 使用各自 adapter,不反向依赖公开语言 package。所有 wire DTO 可序列化,运行时句柄不进入 +schema;SDK Host 不拥有 Session、Tool、MCP、Permission、Hook 或 Event 状态。 + +Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v1 preview: 小版本更新允许增加可选 builder hook、有默认实现的端口方法或注册表查询能力,但不得向外部可用 Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段,也不得改变既有端口语义、错误分类、session / turn 标识含义或 默认 feature 依赖。任何需要调用方改写现有嵌入代码的变更,必须提升接口版本并提供兼容迁移路径。 只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有具体服务管理器、读取产品命令 -注册表或依赖全局可变状态,SDK 发布边界就不成立。 +注册表、理解 ACP/内部端口或依赖全局可变状态,公开 SDK 发布边界就不成立。公开 SDK 的完整 +术语、能力等价和版本要求以 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准。 ### 1.2 内核与特性的分界 @@ -79,7 +85,7 @@ Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段, 判断标准: -- 在 Desktop、CLI、Web、ACP 和 SDK 中都可复用,且不依赖 UI 或平台具体实现的能力,优先归智能体内核。 +- 在 Desktop、CLI、Web、ACP 和公开 SDK adapter 中都可复用,且不依赖 UI 或平台具体实现的能力,优先归智能体内核。 - 会改变用户入口、命令、设置、入口视图、默认策略或产品文案的能力,归产品特性。 - 会接触 OS、网络、终端、文件系统、远端主机、MCP server 或 AI 提供方具体实现的能力,归跨平台适配器或协议适配器。 - 来自外部插件、OpenCode、ACP 外部智能体/工具桥接、外部 skill 或第三方包的能力,先进入 @@ -95,7 +101,7 @@ Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段, | 前后端能力服务切面 | 智能体内核如何产出会话、事件、权限和诊断事实 | 宿主协议 DTO、插件状态视图字段、产品形态状态词 | | BitFun 与插件切面 | 插件贡献如何进入内核、执行层和安全控制面 | 具体生态接口、未预算界面贡献字段、OpenCode 原始 payload | | 插件通用运行时切面 | `PluginRuntimeBinding` 如何注入 Agent Runtime 内部 builder | `PluginRuntimeClient`、dispatch/read schema、隔离字段;这些由插件主机文档和 `runtime-ports` 代码定义 | -| 外部生态兼容适配切面 | 不进入 Agent Runtime SDK;各生态 adapter 只作为来源或宿主边界的反腐层 | OpenCode client/server facade、Claude/Codex/Trae Hook 细节、配置导入细节、跨生态稳定 payload | +| 外部生态兼容适配切面 | 不进入 Agent Runtime API 或公开 SDK;各生态 adapter 只作为来源或宿主边界的反腐层 | OpenCode client/server facade、Claude/Codex/Trae Hook 细节、配置导入细节、跨生态稳定 payload | OpenCode 适配器、ACP 桥接和未来插件运行时必须先映射到主架构定义的切面,再由产品组装注册。它们不能直接写智能体内核权威状态;通过 Compatibility Facade、Tool Runtime 或界面宿主调用的 BitFun 能力必须经过相应权限与审计路径。插件脚本直接使用 Bun 文件、网络或进程接口产生的副作用不在这项保证内:没有可执行的操作系统隔离时,严格策略必须禁用相应插件或明确报告 `policy-limited`,不能宣称已被沙箱拦截。 @@ -335,7 +341,7 @@ pub trait SecurityDecisionPort: Send + Sync { ## 3. 内核、工具与工作流 -### 3.1 Agent Kernel / Runtime SDK +### 3.1 Agent Runtime / Agent Runtime API 目标归属 crate:`bitfun-agent-runtime`。 @@ -357,7 +363,7 @@ pub trait SecurityDecisionPort: Send + Sync { - 运行时事件。 - 轮次后处理器。 -公共门面: +当前 Rust Runtime SDK 的装配与调用形态: ```rust pub struct AgentRuntimeBuilder { @@ -401,11 +407,12 @@ impl AgentRuntime { } ``` -该门面是目标接口形态。它必须只接收已组装的类型化部件,不负责创建 +该 Rust 接口是内部产品入口复用的当前形态,不是公开 Python/TypeScript SDK 的目标 API。它必须只接收 +已组装的类型化部件,不负责创建 文件系统、终端、MCP、AI 客户端、Remote 提供方或产品命令。 当前 v1 preview 接口以 message / attachment / metadata 作为最小输入形态;若把 model-round cancellation token、结构化 AgentInput 或更复杂的事件游标纳入公开 SDK, -必须提升 SDK 接口版本并保留旧路径兼容。 +必须分别评审 Rust Runtime SDK、SDK Host protocol 和公开 SDK API 的版本,并保留旧路径兼容。 产品特性边界: @@ -422,7 +429,7 @@ model-round cancellation token、结构化 AgentInput 或更复杂的事件游 handler、具体提示组装、workspace / remote / config IO、自定义子智能体文件 IO 和平台适配器 在行为等价未证明前不得下沉到运行时内核。 - 产品特性命令、UI 状态、settings 持久化、插件 UI 渲染和交付形态默认策略不得下沉到运行时内核。 -- prompt、event、thread goal、scheduler 或 subagent 的纯事实如果进入 Agent Runtime SDK,旧归属模块只能保留兼容入口; +- prompt、event、thread goal、scheduler 或 subagent 的纯事实如果进入 Agent Runtime API,旧归属模块只能保留兼容入口; 行为等价需要有接口等价测试和边界保护证明。 建议内部模块: @@ -615,7 +622,7 @@ pub struct ToolExecutionContext { - 工具原语只消费 `ToolExecutionServices` 这样的窄服务视图,不依赖完整 `RuntimeServices` bundle。 - path policy、runtime artifact ref、remote POSIX containment 由 `tool-contracts` 承载。 -- MCP 工具作为外部工具提供方注入,不内置在 Agent Runtime SDK。 +- MCP 工具由既有 MCP lifecycle owner 管理,并以类型化 Tool provider/catalog 注入 Tool owner;Agent Runtime API 不创建或持有独立 MCP client/registry。 - `GetToolSpec` 是工具目录能力,不是产品 UI。 必须保护: @@ -638,7 +645,7 @@ pub struct ToolExecutionContext { - 把 SDD、DeepReview、DeepResearch、MiniApp、function-agent 等工作流从运行时内核中分离。 - 定义工作流描述符、路由计划、提供方注册表、工作流计划、步骤、策略、产物、 review gate 和 post-processor。 -- 通过 Agent Runtime SDK、工具运行时和服务端口编排。 +- 通过 Agent Runtime API、工具运行时和服务端口编排。 建议内部模块: @@ -691,7 +698,7 @@ pub struct HarnessExecutionContext { - 工作流允许编排运行时/工具,但不拥有会话管理器内部结构。 - 工作流不直接访问具体文件系统 / Git / 终端。 - 产品命令只映射到工作流能力,不把命令展示逻辑下沉。 -- 新工作流通过提供方注册,不改 Agent Runtime SDK 内核。 +- 新工作流通过提供方注册,不改 Agent Runtime 或 Agent Runtime API 的稳定用例。 - 描述符专用 / 旧门面提供方只能表达路由计划;不得被描述为已经拥有具体工作流执行。 执行语义移动必须单独证明行为等价。 @@ -704,27 +711,36 @@ pub struct HarnessExecutionContext { `src/crates/assembly/core` 仍承担 `bitfun-core` 兼容组装。现有 `ProductAssembler` 是具体结构体, 通过 `assemble(ProductAssemblyInput)` 产生 `ProductRuntimeParts`,本文件不再为它定义第二套目标接口。 -当前 CLI 与 CLI 托管的 ACP server 已使用类型化 `RuntimeServices`,分别以 `DeliveryProfile::Cli` 和 -`DeliveryProfile::Acp` 构造 `ProductRuntimeParts`。Desktop 主交互直接从现有协调器和调度器端口构造窄口径 -Agent Runtime SDK 门面,不注册未实现的 `RuntimeServices` 能力,也不宣称完整 Desktop profile 可用。CLI 通过 -一个调用级上下文把 Agent Runtime SDK、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 -交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除/恢复、会话模型更新、类型化转录读取、轮次提交/取消,以及 CLI/TUI -的工具确认、拒绝和用户问题回答;固定 ID 创建使用独立的 `create_session_with_id` 方法,普通创建 DTO 保持 v1 -字段集合。未实现该能力的提供方返回类型化不支持错误;实现成功时 Runtime 必须校验返回 ID 与请求完全一致,不能 -替换为自动生成的 ID。`SessionSelector::Create` 仍保持自动生成。Peer Host 通过同一 SDK 处理对话提交、精确取消、 -工具确认、拒绝和会话模型更新。SDK v1 尚未覆盖的分支、用量、快照和持久化维护等操作仍由 -`assembly/core` 的单一兼容门面转发。 +当前 CLI、CLI 托管的 ACP server 与独立 SDK Host 已使用类型化 `RuntimeServices`,分别以 +`DeliveryProfile::Cli`、`DeliveryProfile::Acp` 和 `DeliveryProfile::Sdk` 构造 `ProductRuntimeParts`。 +SDK profile 当前从共享产品事实获得与 Headless CLI 相同的能力集合,但保持独立产品身份和 +`AgentSubmissionSource::SdkHost`;这不建立 CLI crate/协议依赖。Desktop 主交互直接从现有协调器和调度器端口构造窄口径 +Rust Runtime SDK,不注册未实现的 `RuntimeServices` 能力,也不宣称完整 Desktop profile 可用。CLI 通过 +一个调用级上下文把该 Rust 接口、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 +交互模式下的 Peer Host。Rust Runtime SDK 已承接会话创建/列举/删除/基础恢复、重命名/归档、会话模型更新、thread-goal 查询、类型化转录读取、本地分支、用量生成、 +轮次提交/取消与精确结算,以及 CLI/TUI 的工具确认、拒绝和用户问题回答;固定 ID 创建使用独立的 +`create_session_with_id` 方法,普通创建 DTO 只增加可选工作区 ID 与模型 ID 事实,不承载调用方指定的会话 ID。 +未实现该能力的提供方返回类型化不支持错误;实现成功时 Runtime 必须校验返回 ID 与请求完全一致,不能 +替换为自动生成的 ID。`SessionSelector::Create` 仍保持自动生成。Peer Host 通过同一 Rust Runtime SDK 处理对话提交、精确取消、 +工具确认/拒绝、会话创建/基础恢复/重命名/归档、thread-goal 查询和会话模型更新。TUI 用量卡片以固定的、模型上下文不可见的 +本地命令轮次契约回到 Core owner。CLI 上下文还单独持有不属于 Agent Runtime API 或 `RuntimeServices` capability 的 +本地工作区快照 owner port;Peer Host 只用它完成本地工作区准备、会话文件清单、类型化统计和工作区文件回滚。 +账号同步、富历史读取及 Peer Host/ACP 的其余维护等产品操作仍由 `assembly/core` 的单一兼容门面转发。 `doctor` 与 `health` 校验真实组装结果及必需注册完整性; Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊断不等于对这些外部服务做实时探活。 该切换仍是 `product-full` 兼容组装,不是 owner 迁移。协调器、调度器、持久化、工具管线和 Agentic Event Queue -仍由 Core 唯一持有;CLI 与 ACP 不复制这些状态。ACP 服务端通过 SDK 处理会话创建/列举、轮次、取消、交互响应和事件订阅, -但完整持久化历史回放、模式、模型目录/配置和 MCP 仍走单一 Core 兼容门面;会话模型写入通过 SDK 回到同一 Core owner。ACP stdio、连接和协议投影仍在 -`interfaces/acp`。Desktop 复用同一 Core owner 构造一个窄口径 SDK 门面,主界面的轮次提交/取消、工具确认/拒绝和 -用户问题回答与会话模型更新已通过该门面;会话 CRUD/恢复视图、MCP、MiniApp、Cron、远程连接、Tauri 窗口与平台资源 +仍由 Core 唯一持有;CLI 与 ACP 不复制这些状态。ACP 服务端通过 Rust Runtime SDK 处理会话创建/列举、轮次、取消、交互响应和事件订阅, +但完整持久化历史回放、模型/模式目录与提供方配置和 MCP 仍走单一 Core 兼容门面;会话模型/模式写入通过 Agent Runtime API 回到同一 Core owner。ACP stdio、连接和协议投影仍在 +`interfaces/acp`。Desktop 复用同一 Core owner 构造一个窄口径 Rust Runtime SDK,主界面的轮次提交/取消、工具确认/拒绝和 +用户问题回答与会话模型更新已通过 Rust Runtime SDK;会话 CRUD/恢复视图、MCP、MiniApp、Cron、远程连接、Tauri 窗口与平台资源 仍保留在 Desktop/Core 兼容入口。Server 仅提供健康检查、信息与 ping 路由。未接入入口的 profile、枚举分支和 单元测试仍不能证明对应产品形态可用。 +Desktop 与 CLI Peer Host 还各自注入同一个 Core-backed `LocalWorkspaceSnapshotPort` 契约。它是两个本地宿主之间的内部 owner 边界, +不是公开 Agent SDK、Agent Runtime API 的通用能力、完整 Desktop profile、跨宿主远程能力或通用 checkpoint/rewind API。Core 继续持有 `SnapshotManager`、工具拦截、 +持久化和事件;宿主继续持有远程检测/结果投影,以及回滚时的会话取消、维护、历史顺序和部分失败语义。 + 职责: - 接收入口唯一选择的 `DeliveryProfile` 与具体 `RuntimeServices`,生成静态能力计划并校验必需服务。 @@ -736,8 +752,8 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊 | 阶段 | 约束 | |---|---| -| 当前 | CLI、Peer Host 与 CLI 托管的 ACP server 消费真实 Runtime Parts / SDK,Core 兼容门面只承接已列明的 SDK v1 缺口;不扩张字段或再造描述符 | -| 迁移 | 迁移执行 owner、ACP 剩余兼容路径或 Desktop 入口前,必须分别证明行为等价;relay 的 Cargo 反向边已删除,room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已下沉,但 embedded TCP bind、静态 fallback 和任务生命周期仍是 assembly 兼容债务 | +| 当前 | CLI、Peer Host 与 CLI 托管的 ACP server 消费真实 Runtime Parts / Rust Runtime SDK,Core 兼容门面只承接已列明的 preview 缺口;不扩张字段或再造描述符 | +| 迁移 | 迁移执行 owner、ACP 剩余兼容路径或 Desktop 入口前,必须分别证明行为等价;relay 的 Cargo 反向边已删除,room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已下沉,embedded TCP bind、静态 fallback 和任务生命周期也已由窄宿主端口迁至 Desktop | | 完成 | 每个声称支持的 profile 都由生产入口消费组装结果,并有最小入口验证;无消费方的 profile 不对外宣称可用 | 产品定义、品牌资源和界面布局的长期边界以 @@ -749,7 +765,9 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊 - 具体运行时服务通过 `RuntimeServicesBuilder` / provider registry 构造。 - CLI 只选择 `DeliveryProfile::Cli` 一次;必需服务缺失时组装失败,不回退到静态计划或另一 profile。 -- CLI 的 ACP stdio 入口只选择 `DeliveryProfile::Acp` 一次;组装或 SDK runtime 构造失败时在接受 stdio 请求前退出。 +- CLI 的 ACP stdio 入口只选择 `DeliveryProfile::Acp` 一次;组装或 Rust Runtime SDK 构造失败时在接受 stdio 请求前退出。 +- 独立 `bitfun-sdk-host` 只选择 `DeliveryProfile::Sdk` 一次;stdio framing 与进程 bootstrap 留在 app, + `interfaces/sdk-host` 只保留版本化协议和连接用例。Host 不通过 CLI 启动,也不使用 CLI submission source。 - CLI 的 `json` 输出为单结果文档,`stream-json` 直接复用现有 `AgenticEventEnvelope`;协议层不新增 `schema_version`、`sequence` 或平行事件 taxonomy。 - 能力计划选择工具提供方组计划和 Harness 描述符;当前不存在供任意模块注册所有对象的通用组装注册表。 @@ -772,7 +790,7 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊 传输/接口适配器;运行时部件只接收类型化服务端口、DTO、事件事实和能力可用性。 - 宿主通信的抽取门槛、Tauri 薄适配职责和逐能力迁移顺序以 [`product-architecture.md`](product-architecture.md#22-宿主通信契约与-tauri-薄适配) 为准;不得用通用 API 转发层 - 包装所有 Runtime SDK 方法。 + 包装所有 Agent Runtime API 方法。 - 插件运行时客户端只能作为内核可调用的类型化边界注入;智能体内核、工具运行时和工作流不直接加载 OpenCode 插件代码。 - feature group 是构建时能力边界;能力计划和能力可用性是产品运行时能力边界;两者必须在 @@ -790,22 +808,42 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊 |---|---|---| | Desktop | Tauri 窗口、桌面接口、本地权限界面 | 运行时事件、权限事实、产物引用、桌面服务提供方、能力服务读模型 | | CLI | TUI、命令输入、终端展示、包工作流 | 命令提供方、智能体/会话/工具接口、CLI 安全服务提供方、能力服务读模型 | -| Server / SDK | HTTP/WebSocket 路由、server 工作区策略、外部 SDK 嵌入 | 传输 DTO、运行时请求/响应、工作区身份、稳定 Rust 内核接口 | +| Server | HTTP/WebSocket 路由、server 工作区策略 | 传输 DTO、运行时请求/响应、工作区身份、能力服务读模型 | +| Public Agent SDK | Python/TypeScript `query()`、client/session、异步消息流、callback | 版本化 SDK Host、能力协商、稳定/实验 schema、背压与进程生命周期 | | Remote / mobile | 远端工作区、relay/bot、文件/终端视图 | 远端状态、逻辑路径、权限/事件事实、远端能力事实 | | ACP | ACP 协议、客户端生命周期、远端探测 | 外部智能体/工具能力、环境事实、权限桥接 | | Web UI / mobile web | UI 状态、hydration、配对、会话展示、插件状态视图 | 接口/传输 DTO、运行时事件事实、能力服务读模型 | -当前 Runtime SDK 已提供会话创建、列出、删除、恢复、模型更新和类型化转录读取。模型更新只接受会话 ID 与模型 ID, -不承载模型目录、模式、提供方配置或宿主 UI 选择语义。`AgentSessionRestoreRequest/Result` 与 -`AgentSessionRestorePort` 归 Agent Runtime SDK,以继续复用 Runtime owner 的完整 `SessionState`;类型化 +当前 Rust Runtime SDK 已提供会话创建、列出、删除、恢复、模型/模式更新、类型化转录读取、本地分支、用量生成,以及 +精确轮次结算。模型与模式更新只接受会话 ID 和被选择的稳定 ID, +不承载目录、提供方配置、选择策略或宿主 UI 语义。模式 ID 由 Core 对当前有效目录校验;同值更新不刷新活动时间, +有效变更先按会话串行化,再通过与轮次元数据保存、删除共用的规范化物理路径锁持久化,成功后才提交到活动会话。 +该锁只保证单进程内同一会话元数据的读改写顺序,不宣称跨进程事务,也不把 metadata/state 等多文件更新声明为崩溃原子提交。 +恢复标准主会话时,如果持久化模式已从当前目录移除, +Core 会选择仍可执行的内置回退模式并同步修正元数据;内部子会话的专用模式不走这条迁移规则。 +Desktop 的通用会话元数据命令必须显式声明 UI 字段集合,并只在 owner 锁内更新这些字段;Review 状态与未读、关注、标题等 +独立写入意图不得通过完整旧快照互相覆盖。 +Relay 导入的会话元数据以私有 `pending/complete` 标记区分仅有摘要与完整历史;打开本地会话时先检查并补齐该导入, +再让 Core 恢复模型上下文。普通本地会话只做一次元数据读取且不访问账号或网络;部分导入失败保持可重试并停止本次打开, +不能让 UI 与 Core 分别发布不同的截断历史。 +`AgentSessionRestoreRequest/Result` 与 +`AgentSessionRestorePort` 归 Agent Runtime API,以继续复用 Runtime owner 的完整 `SessionState`;类型化 `SessionTranscript` 归 `runtime-ports`。两者都由 `assembly/core` 注入真实持久化 owner,当前由 CLI/TUI -消费;ACP 为保证模型配置与完整历史来自同一次恢复,继续通过 Core 兼容门面读取协议回放所需的完整轮次,避免为单一协议 -扩张通用 transcript。CLI/TUI 的工具确认、拒绝和用户问题回答,以及 ACP 服务端 / Peer Host 的工具确认与拒绝,通过类型化 +消费;TUI 与 ACP 的活动会话模式更新也通过窄端口回到同一 Core owner。ACP 为保证模型配置与完整历史来自同一次恢复,继续通过 +Core 兼容门面读取协议回放所需的完整轮次,避免为单一协议扩张通用 transcript,也不绕过附件内容所需的独立授权能力。 +会话分支请求显式携带可选远程身份;当前本地 provider 对远程身份返回 `NotAvailable`,不据本地路径 +推断远程语义。CLI/TUI 的工具确认、拒绝和用户问题回答,以及 ACP 服务端 / Peer Host 的工具确认与拒绝,通过类型化 `AgentInteractionResponsePort` 回到 Core 的工具管线或用户输入 owner,不改变审批策略或交互所有权。 +本地工作区快照准备、会话文件清单、类型化统计和工作区文件回滚由 `LocalWorkspaceSnapshotPort` 直接调用现有 Core 快照 owner; +该端口没有远程身份字段,不由 Agent Runtime API re-export,也不注册成完整产品 capability。Desktop 保留既有远程空结果兼容; +Peer Host 对显式远程身份或远程路径返回清晰的不支持错误;二者都不会把远程请求转入本地端口。 +Peer Host 的历史截断、维护锁、后代会话清理和事件投影不进入端口。 `CoreAgentRuntimeCompatibility` 仍承载未迁移的 -持久化、分支、用量和快照等操作;不能据此把整个兼容门面一次性删除,也不能把这些 -操作提前声明为跨宿主稳定接口。旧固定 ID 创建方法仅作为标记废弃的源码迁移转发保留一个兼容周期,生产 CLI -调用已经迁移到 Runtime SDK;该转发不得重新成为新调用入口。 +账号同步、富历史读取及 Peer Host/ACP 其余维护等操作;不能据此把整个兼容门面一次性删除,也不能把这些 +操作提前声明为跨宿主稳定接口。固定 ID 创建的兼容周期已经结束;会话创建、分支和用量生成均由 Rust Runtime SDK +承接;会话重命名/归档、基础恢复、thread-goal 查询和完成态本地命令轮次也通过显式窄端口回到 Core owner, +不形成通用会话 mutation 或 transcript writer。Core provider 直接调用现有协调器、持久化和用量 owner,不再反向经过兼容门面。账号、登录态、 +用户身份和同步策略仍是可选产品能力,不进入 Agent Runtime 或该 Core provider 的稳定接口。 ### 4.3 Product Capability 设计 @@ -842,7 +880,7 @@ Provider 装配同样按需增加,不提前为 Memory、Context、Workflow、S 插件运行时主机的权威设计见 [`plugin-runtime-host-design.md`](extensions/plugin-runtime-host-design.md)。本文件只约束 Agent Runtime 与插件主机的关系: - Agent Runtime 只接收 `PluginRuntimeBinding`,不创建插件主机、不发现插件来源、不加载 OpenCode 适配器。 -- `PluginRuntimeClient` 是 Agent Runtime 内部可调用边界,不进入 SDK 门面、能力服务接口或产品入口 DTO。 +- `PluginRuntimeClient` 是 Agent Runtime 内部可调用边界,不进入 Agent Runtime API、公开 SDK、能力服务接口或产品入口 DTO。 - OpenCode 适配层只存在于插件主机内部;Agent Runtime 不依赖 `bitfun-opencode-adapter`,也不按具体生态类型分支。 - 插件贡献进入 Agent Runtime 前必须已经转换成 BitFun 类型化工具、Hook 输入/输出、诊断或明确不支持; OpenCode 原始对象不能进入业务状态。 @@ -866,12 +904,26 @@ Provider 装配同样按需增加,不提前为 Memory、Context、Workflow、S `bitfun-acp` 保持集成归属。 -CLI 托管的 ACP 服务端使用 `DeliveryProfile::Acp` 组装一个 Agent Runtime,通过 SDK 处理会话创建/列举、轮次提交/取消、 +CLI 托管的 ACP 服务端使用 `DeliveryProfile::Acp` 组装一个 Agent Runtime,通过 Rust Runtime SDK 处理会话创建/列举、轮次提交/取消、 交互响应和只读 Agent 事件订阅。ACP 只把共享运行时事实映射成协议更新;标准输入输出、连接、权限 RPC 与通知生命周期 -不进入 SDK。完整持久化历史恢复、模式、模型目录/提供方配置、MCP 和 ACP 客户端路径仍是明确的 Core 兼容范围; -活动会话的模型写入已经通过 SDK 回到 Core owner, +不进入 Agent Runtime API。完整持久化历史恢复、模型/模式目录与提供方配置读取、MCP 和 ACP 客户端路径仍是明确的 Core 兼容范围; +活动会话的模型/模式写入已经通过 Agent Runtime API 回到 Core owner, 不据此扩张通用 runtime DTO。 +`session/load` 在恢复前占用会话 ID,先完成纯参数校验和临时 MCP 建立,再恢复 Core;只有完整历史通知发送成功后才发布 +活动 ACP 状态并返回成功。同一 ID 的重叠打开或关闭在产生回放和 MCP 副作用前以可重试临时状态拒绝;恢复后的任一步失败 +都会卸载本请求加载的 Core 内存状态并回收临时 MCP,但不删除既有历史。`session/new` 先生成稳定 ID,完成目录校验和 +临时 MCP 建立后再以同一 ID 创建 Core 会话;建立过程失败时尝试回收临时 MCP 和本请求创建的 Core 会话。首次落盘若因 +回滚失败留下目录,会以类型化残余资源结果进入同一补偿路径,不报告“未创建 Core 会话”。补偿失败会携带会话 ID、 +残余资源种类、Core 是否由本请求创建及恢复动作,不伪装成普通输入错误,也不建议 `session/load` 删除既有历史。 +成功的 `session/close` 先阻止新轮次,清空已接收队列、取消后台子会话与活动轮次并确认调度排空,再卸载 Core 临时状态、 +回收临时 MCP 和连接映射;持久化历史及其存储绑定保留,可由后续 `session/load` 重新打开。 +任一步未完成时保留 ACP 会话所有权和持久化历史,返回 `session_close_incomplete`、失败阶段与可重试动作;只有临时 MCP +未回收时才标记具体残余资源,不能沿用 `session/new` 的“本请求创建 Core 会话”语义。 +无效请求与会话不存在分别保持协议可识别的参数错误和资源不存在错误,其他后端故障不泄漏为可重试的客户端输入错误。 +活动会话占用范围是一个 ACP stdio 进程;当前不宣称同一持久化会话可由多个 ACP 进程并发写入。跨进程共享需要先定义 +执行域、权限、冲突和崩溃恢复契约,不在本切片中用临时文件锁提前固化。 + 继续拥有: - ACP protocol。 @@ -894,18 +946,18 @@ pub trait ExternalToolProvider: Send + Sync { } ``` -Agent Runtime SDK 只能看到 external agent/tool capability,不感知 ACP protocol、进程管理、 +Agent Runtime API 只能看到 external agent/tool capability,不感知 ACP protocol、进程管理、 remote probing 或 startup timeout。 ### 4.6 Skills / Prompt / Subagent 建议归属: -- prompt module:Agent Runtime SDK 的 prompt assembly contract。 +- prompt module:Agent Runtime 的 prompt assembly contract。 - skill:prompt / resource / instruction 扩展,作为 agent definition 或 harness input 的一部分。 - subagent definition:现有 `RuntimeAgentRegistry` 与智能体定义 owner。 -- subagent execution:Agent Runtime SDK。 -- Task tool:Tool Runtime entrypoint,调用 Agent Runtime SDK。 +- subagent execution:Agent Runtime。 +- Task tool:Tool Runtime entrypoint,经 Agent Runtime API 调用 Agent Runtime。 约束: @@ -976,7 +1028,7 @@ pub trait BeforeToolExecution: Send + Sync { 错误: - contracts crate 使用可移植错误事实。 -- Agent Runtime SDK / Runtime Services 负责错误分类和事件上报边界。 +- Agent Runtime / Runtime Services 负责错误分类和事件上报边界;Agent Runtime API 只做类型化用例映射。 - Product Surface 只负责展示逻辑。 - unsupported capability 必须明确,不允许泛化为 unknown failure。 @@ -1051,42 +1103,45 @@ Product 测试: - MCP dynamic tool catalog。 - MiniApp 与 review workflow。 - 插件状态视图和 host fallback。 -- 内部 SDK 最小特性 / no-default-features 嵌入验证。 +- Rust Runtime SDK 最小特性 / no-default-features 嵌入验证。 - OpenCode / plugin adapter 的 capability/effect 声明与安全决策测试。 ### 5.4 当前结果与剩余完成条件 已经成立: -- `bitfun-agent-runtime` 不依赖 `bitfun-core`,内部 SDK 预览门面已有最小测试保护。 +- `bitfun-agent-runtime` 不依赖 `bitfun-core`,Rust Runtime SDK 已有最小测试保护。 - `bitfun-runtime-services` 提供类型化服务注入;工具 contracts、provider groups 与 execution 已分层。 - `bitfun-harness` 已提供类型化工作流描述与注册能力。 - `bitfun-core` 可继续作为 `product-full` 兼容门面,避免迁移期间一次性重写入口。 -- CLI 已以 `DeliveryProfile::Cli` 构造真实 Runtime Parts 和 SDK runtime;本地 Agent 入口、会话、用量和 - Peer Host 共用一个调用级上下文与广播事件源,审批策略不再写回全局配置。Peer Host 通过 SDK 提交/精确取消 - turn、更新会话模型并处理工具确认/拒绝,通过单一 Core 兼容门面处理会话与快照缺口,不再构造独立调度器、持久化 manager 或事件队列; +- CLI 已以 `DeliveryProfile::Cli` 构造真实 Runtime Parts 和 Rust Runtime SDK;本地 Agent 入口、会话、用量和 + Peer Host 共用一个调用级上下文与广播事件源,审批策略不再写回全局配置。Peer Host 通过该 Rust 接口提交/精确取消 + turn、处理基础会话控制、更新会话模型并处理工具确认/拒绝;本地工作区快照准备、文件清单、统计和文件回滚通过独立 owner port 复用 Core 实现, + 富历史和其余持久化维护缺口仍通过单一 Core 兼容门面处理,不再构造独立调度器、持久化 manager 或事件队列; wire schema、Relay ACK/重放和重连协议未在该切换中扩张。 -- CLI 通过 SDK 处理已覆盖的 session/turn/cancel 操作,并通过一个 Core 兼容门面处理 SDK v1 缺口; +- CLI 主会话客户端通过 Rust Runtime SDK 处理 session、transcript、fork、usage report、用量卡片完成态本地命令轮次、turn、cancel 与 settlement;其他 preview 缺口仍通过一个 Core 兼容门面处理; 该门面复用现有 owner,不建立第二套状态或事件 schema。 - CLI 托管的 ACP 服务端已以 `DeliveryProfile::Acp` 构造真实 Runtime Parts;会话创建/列举、轮次、取消、会话模型更新、工具确认/拒绝和 - Agent 事件订阅复用同一 SDK 语义,ACP stdio、连接与协议投影保持不变。Agentic Event Queue 仍是唯一事件 owner; + Agent 事件订阅复用同一 Agent Runtime API 语义,ACP stdio、连接与协议投影保持不变。Agentic Event Queue 仍是唯一事件 owner; 全局有界 broadcast 继续服务 CLI/TUI,活动 ACP prompt 使用固定容量、仅接收本会话事件的临时通道,并在最后一个订阅者 释放时立即回收。CLI 宿主进程只保留一个旧消费队列排空任务,不增加每会话转发任务或第二套事件 schema。ACP 组装入口使用独立的 轮次提交适配器,在会话锁内拒绝忙碌会话的第二个 prompt;CLI/TUI、Desktop 和远程入口的既有排队策略不变。 -- Desktop 主交互已从现有协调器与调度器端口构造窄口径 Agent Runtime SDK 门面;Tauri 命令只负责保留现有 DTO、 - 补全图片载荷并映射类型化请求。ACP 取消分支继续优先处理,Desktop 平台生命周期和未迁移服务不进入 SDK,也不创建 +- Desktop 主交互已从现有协调器与调度器端口构造窄口径 Rust Runtime SDK;Tauri 命令只负责保留现有 DTO、 + 补全图片载荷并映射类型化请求。ACP 取消分支继续优先处理,Desktop 平台生命周期和未迁移服务不进入该 Rust 接口,也不创建 第二套 owner 或事件 schema。完整 `DeliveryProfile::Desktop` 必须等待真实 Desktop `RuntimeServices` 提供方和事件 - 消费/投影路径齐备后再组装;当前切片不注册 `Events` 能力,也不以失败占位端口或无人消费的内存通道伪装可用。 + 消费/投影路径齐备后再组装;当前切片只额外注入本地工作区快照 owner port,不注册 `Events` 或快照 capability, + 也不以失败占位端口或无人消费的内存通道伪装可用。 仍需完成: -- 把 embedded relay 的 TCP bind、静态 fallback 和任务生命周期移出 assembly;room/device 状态、account/sync - 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,Cargo 反向边已删除并由通用边界检查保护。 -- 继续缩小 CLI 的 Core 兼容门面;只有稳定端口、真实生产调用方和行为等价测试齐备时才迁移 owner。 -- 继续按真实复用需求缩小 ACP 的完整持久化历史、模式、模型目录/提供方配置、MCP 与客户端兼容路径;Desktop 仅继续迁移存在稳定端口和 +- 继续缩小 CLI 的 Core 兼容门面;本地快照窄端口不扩张为远程快照、完整 checkpoint/rewind、Agent Runtime API 通用能力或公开 Agent SDK 能力。 + 只有稳定端口、真实生产调用方和行为等价测试齐备时才迁移其余 owner。 +- 继续按真实复用需求缩小 ACP 的完整持久化历史、模型/模式目录与提供方配置读取、MCP 与客户端兼容路径;Desktop 仅继续迁移存在稳定端口和 行为等价测试的入口。完整 Desktop 产品组装需先补齐真实必需服务与事件消费路径,不以桩实现提前声明能力;ACP 生命周期 和 Desktop 平台资源仍留在各自入口。 -- 为 Agent Runtime SDK 增加至少一个非 `bitfun-core` 的真实嵌入方;预览 facade 和单元测试不等于外部可用 SDK。 +- 继续用 Rust Runtime SDK 收敛真实产品入口;preview 成熟度、内部 adapter 和单元测试不等于 + 外部可用 SDK。公开 SDK 的 Python/TypeScript 消费方、SDK Host、Claude 等价矩阵和发布门槛以 + [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准。 - 仅在真实端到端切片中接入插件主机;外部插件先转换为类型化工具、Hook、事件、权限请求或诊断, 不把生态对象带入 Agent Runtime。 - 未接入的 Server、Remote、Web、Mobile 和 SDK profile 保持未交付表述,不以空计划或枚举分支代替产品验证。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md new file mode 100644 index 0000000000..9026adbe29 --- /dev/null +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -0,0 +1,688 @@ +# BitFun Agent SDK 产品与宿主架构设计 + +本文定义 BitFun Agent SDK 的公开产品心智、内部 SDK Host 边界、与 GUI/TUI/Headless CLI/ACP/Server +的关系,以及达到公开发布前必须满足的能力等价门槛。智能体内核和 Rust crate 归属继续由 +[`agent-runtime-services-design.md`](agent-runtime-services-design.md) 定义;产品级接口切面由 +[`product-architecture.md`](product-architecture.md) 定义;CLI/TUI 体验由 +[`cli-product-line-design.md`](cli-product-line-design.md) 定义;能力导入/导出和外部宿主适配由 +[`capability-runtime-integration-design.md`](extensions/capability-runtime-integration-design.md) 定义。 + +本文记录目标架构与公开兼容门槛,不表示 TypeScript/Python SDK 或下文全部能力已经交付。当前代码中的 +`agent-runtime::sdk` 是低层 Rust Runtime SDK(当前 preview),服务现有产品入口和 Rust 嵌入; +`interfaces/sdk-host` 是本地协议与连接用例 adapter,`apps/sdk-host` 是独立的原生 Host 进程及 stdio transport。 +当前代码只形成第一切片的实现候选,尚未通过跨内部调用、Headless CLI 与 SDK Host 的同一 Query golden fixture, +因此还不能称为已交付的 SDK Host preview。它们都不是本文定义的 +公开 BitFun Agent SDK,也不能据此宣称已达到 Claude Agent SDK 等价能力。 + +## 1. 最终决策 + +BitFun 采用以下组合,而不完整复制任一产品: + +1. **公开心智采用 Claude Agent SDK 模型**:以 `query()`、异步消息流、Session、Tool、MCP、 + Permission、Hook、Subagent、Skill、Result 和 Usage/Trace 为主要概念。用户不需要学习 + BitFun 内部端口、Product Assembly、ACP 或传输协议。 +2. **宿主边界采用 Codex App Server 的工程方法**:SDK 随版本携带或定位匹配的本地原生 + SDK Host,通过版本化、双向、可生成 schema 的协议管理 Session/Turn/Event、审批、回调、 + 取消、背压和进程生命周期。 +3. **复用 OpenCode 的 client/managed-server 选择,但不复制其低层公开心智**:默认由 SDK + 管理本地 SDK Host;高级调用方可以连接预启动的受信本地 SDK Host。公开 API 仍是 Agent SDK,而不是 + 全量 Server 路由的薄封装。 +4. **所有 BitFun 产品入口只使用一套 Agent Runtime API 和一组行为 owner**:GUI、TUI、 + Headless CLI、公开 SDK、ACP 和 Server 是同级 adapter。GUI/TUI/CLI 不经过公开 SDK 包, + 公开 SDK 也不创建第二套 Agent Loop、Tool、MCP、Permission 或 Hook 实现。 +5. **ACP 保持互操作协议,不成为完整 SDK 的私有传输**:ACP 能表达的能力继续走标准 ACP; + 自定义 Tool 回调、完整 Hook、结构化用量、SDK 生命周期等超出 ACP 稳定边界的能力走 SDK Host + 协议,不向 ACP 填充大量 BitFun 私有扩展。 + +一句话定义: + +> BitFun Agent SDK 是同一 BitFun Agent Runtime 的公开语言门面;它不是新的 Runtime,也不是 +> Headless CLI、ACP 或 Server 的别名。 + +### 1.1 四个阅读视图 + +| 视图 | 回答的问题 | 位置 | +|---|---|---| +| 产品视图 | GUI、TUI、CLI、SDK、ACP、Server 各自做什么 | 第 4 节 | +| 运行视图 | `query()`、Session、Tool/MCP、Permission、Hook 如何经过同一 Runtime | 第 5、7 节 | +| 开发视图 | SDK 与外部 Hook 工作如何并行而不复制 owner | 第 11 节 | +| 交付视图 | 三个 PR 的顺序、产物和退出条件 | 第 12 节 | + +## 2. 与 Claude Code、Codex 和 OpenCode 的对比 + +本表按 2026-07-23 的官方公开实现与文档核对。滚动版本的新能力不自动进入 BitFun 稳定承诺; +正式发布前必须冻结对比版本和 fixture。 + +| 维度 | Claude Agent SDK | Codex SDK / App Server | OpenCode SDK / Server | BitFun 选择 | +|---|---|---|---|---| +| 公开入口 | TypeScript 以 `query()` / `Query` 异步流和 `resume` 为主;Python 另有 `ClaudeSDKClient` | `Thread.run()` / stream;SDK 包装匹配 CLI 或 App Server | type-safe Server client | `query()` 为首选;`BitFunAgentClient` / Session 是映射(translated)或增强(additive)的高级入口,不冒充 Claude 跨语言公共接口 | +| Runtime 位置 | SDK 包含匹配的原生 Claude Code binary | SDK 携带/调用匹配 Codex runtime;rich client 使用 App Server | 可由 SDK 启动 Server,也可连接已有 Server | SDK 管理匹配原生 SDK Host;首版只允许受信本地 SDK Host | +| 公开心智 | Agent、Session、Message、Tool、Hook、MCP、Permission | Thread、Turn、Item、Approval、Event | Project/Session/Route/API | 采用用户已熟悉的 Agent/Session/Turn/Message/Event;不公开内部协议名 | +| Agent Loop | SDK 直接获得 Claude Code 同一工具和 loop | Runtime/App Server 持有 loop,SDK 是精选门面 | Server 持有 loop,SDK 接近 API client | 唯一 BitFun Runtime 持有 loop;SDK 只映射用例和回调 | +| 双向交互 | 自定义 Tool、权限、Hook、用户输入 callback | App Server request/response 支持审批和输入 | Server API/Event,客户端控制资源 | SDK Host 必须双向;不能只解析单向 JSONL 输出 | +| Session | resume/fork、外部会话存储 | thread start/resume/fork、turn/item、持久化 | session CRUD、prompt/abort、event | Session/Turn 作为稳定公共事实;存储 owner 仍唯一 | +| 协议纪律 | SDK API 主导,底层 binary 对用户透明 | 初始化握手、能力协商、schema 生成、稳定/实验分层、背压 | OpenAPI 生成类型,Server URL 是显式概念 | 采用 Codex 式版本/能力/schema/背压纪律,默认隐藏 transport | +| Headless CLI | `claude -p` 与 SDK 能力相通,CLI 更适合一次性/CI | `codex exec` 与 SDK 都调用同一 runtime | Server/CLI/TUI 均围绕同一服务 | `bitfun exec` 保持独立入口,与 SDK 做能力和事件等价 | +| 扩展暴露 | Skills、Plugins、Agents、MCP、Hooks | Skills、Plugins、MCP、Hooks 等由 Runtime/App Server 投影 | Plugin、Tool、MCP、Agent 等 Server API | SDK 只暴露已由 BitFun owner 提交的能力,不输出生态原始对象 | + +### 2.1 关键步骤采用什么成熟模式 + +| 关键步骤 | 参考实现 | BitFun 采用 | 明确不采用 | +|---|---|---|---| +| 第一次调用 | Claude `query()` async iterator | `query()` 作为默认入口 | 先学习低层 route/client | +| 多轮控制 | Claude Session;Codex Thread/Turn | Session + Turn + typed event | 把一次模型请求当完整 Turn | +| Host 启动 | Claude SDK 携带 binary;OpenCode managed/client-only | 默认管理匹配本地 SDK Host | 要求用户预装 BitFun CLI | +| 协议握手 | Codex `initialize` + capabilities | 版本、能力、稳定/实验协商 | 未握手即调用或静默降级 | +| Tool 与 MCP | Claude 内置 Tool/MCP;Codex approval flow | 复用 BitFun 既有 Tool 与 MCP owner | SDK 私有 Tool/MCP registry | +| Permission 与 Hook | Claude typed callback;Codex server request | 双向 callback,最终策略仍归 BitFun owner | callback 扩大 Host 策略上限 | +| Headless | Claude `-p`;Codex `exec` | CLI 与 SDK 同能力事实、不同接口 | SDK 解析 `stream-json` 作为正式传输 | + +### 2.2 不直接照搬的部分 + +- 不照搬 Claude SDK 的具体类名、字段名或 Hook ABI;等价的是能力类别、生命周期和使用心智, + 公开类型仍保持 BitFun 品牌与语义。 +- 不复制 Codex TypeScript/Python SDK 可能存在的不同底层 transport;BitFun 的正式语言 SDK 必须 + 由同一 SDK Host schema 生成或验证,避免语言间行为漂移。 +- 不把 OpenCode 全量 Server 路由直接发布为 Agent SDK;低层资源 API 会让常见 Agent 用例承担 + 额外协议心智,并容易把 Host 内部管理接口固化成长期公共 API。 +- 不把外部产品的插件/Hook 兼容覆盖率当成 BitFun Agent SDK 的能力覆盖率。导入外部能力、向外部 + 宿主导出能力、使用 SDK 构建应用是三条独立轨道。 + +## 3. 公开名词与内部实现名 + +### 3.1 用户需要理解的公开名词 + +| 名词 | 含义 | 不等于 | +|---|---|---| +| Agent | 一组模型、指令、工具、权限上限和可选子智能体定义 | 具体模型 Provider 或产品页面 | +| Session | 可持久化、可恢复/分支的连续工作上下文 | SDK 进程或一次 HTTP 连接 | +| Turn | Session 中由一次输入触发、直到完成/失败/取消的一次执行;采用 Codex 的输入到终态语义 | 单次模型请求;一个 Turn 可包含多个 Model Round。Claude `max_turns` 的计数单位不得直接映射为 BitFun Turn 数 | +| Message / Event | 输入、输出、增量、工具调用、权限请求和生命周期通知 | 内部事件总线 payload 或 Tauri event | +| Result | Turn 的最终状态、输出、结构化结果、用量与错误摘要 | stdout 文本本身 | +| Tool | Agent 可调用的内置能力、SDK 函数工具或 MCP 工具 | 绕过权限和审计的任意函数 | +| Permission | Tool 或外部副作用执行前的 allow/deny/ask 决策 | SDK 调用方可以扩大宿主上限的开关 | +| Hook | 在明确生命周期点执行的检查、变换或观察回调 | 通用事件总线或第二套工作流引擎 | +| Subagent | 由当前 Agent 委派、具有独立指令/工具范围的受控执行 | 独立产品 Runtime 或任意后台线程 | +| MCP Server | 通过 MCP 提供工具/资源的外部服务 | BitFun 内部 Tool owner 的替代品 | +| Skill | 文件系统或包中的可复用专业说明与资源 | 可绕过 Tool/Permission 的可执行代码 | +| Usage / Trace | 用量、成本、缓存、时延、因果链和诊断事实 | 默认采集 prompt、Tool 参数或凭据 | + +公开文档不要求用户理解以下内部名词:SDK Host/SDK Host protocol、`AgentSubmissionPort`、 +`RuntimeServices`、`ProductRuntimeParts`、`DeliveryProfile`、`RuntimeHookRegistry`、Source Adapter、 +Host Adapter、Tauri adapter、ACP request 或 Capability Resolution Generation。默认 managed SDK Host 模式下, +SDK 负责定位、启动、握手和关闭匹配 SDK Host;这些内部名词可以出现在贡献者架构文档和高级故障诊断中, +不能成为使用公开 SDK 的前置知识。 + +### 3.2 只保留三个层次 + +| 层次 | 含义 | 当前状态 | +|---|---|---| +| Agent Runtime | 唯一 Agent loop,以及 Session、Tool、MCP、Permission、Hook、Event 的唯一归属边界 | Runtime/Session/Tool/Permission/Event 有生产基础;统一 Hook Coordinator 尚未交付 | +| Rust Runtime SDK | 当前 `agent-runtime::sdk`;供内部入口和 Rust 嵌入复用 | 当前为 preview,不等于公开产品 | +| BitFun Agent SDK | 面向 Python/TypeScript 的 `query()`、Session、typed callback 产品 | 尚未交付 | + +SDK Host 只是 BitFun Agent SDK 到 Agent Runtime 的内部跨进程适配器,不是第四套 Runtime,也不是用户默认需要配置的产品。 +当前本地 stdio Host 只支持 `initialize`、Session create/close、Query start/cancel、Event/Result、typed error 和 +`shutdown`;structured output、usage、自定义 Tool、Permission/Hook callback、SDK MCP 配置和预启动 Host 均通过 +capability 明确报告为不可用。当前没有 Permission callback;遇到 `ask` 时 Host 会拒绝请求、取消对应 Turn,并以 +`action_required` 结束 Query,不会无限等待或自动放行。Host 由独立的 `bitfun-sdk-host` 二进制承载; +`bitfun` 不包含隐藏 Host 子命令,也不依赖 SDK Host 协议 crate。 + +以下事实不能由当前 Rust Runtime SDK 推导: + +- 已有可安装的 TypeScript/Python BitFun Agent SDK。 +- 已有语言内自定义 Tool、Permission 或 Hook callback。 +- GUI/TUI/CLI 应改为依赖公开 SDK 包。 +- `AGENT_RUNTIME_SDK_API_VERSION = 1` 是跨语言 wire protocol 版本。 +- 注册了 Tool 名称或 Hook registry 就等于公开 SDK 可执行这些能力。 + +## 4. 一个 Agent Runtime,多种产品形态 + +```mermaid +flowchart TB + subgraph Surfaces["产品与开发者入口"] + GUI["Desktop / Web GUI"] + TUI["交互式 TUI"] + CLI["Headless CLI: bitfun exec"] + SDK["BitFun Agent SDK: Python / TypeScript"] + ACP["ACP Server / Client"] + Server["BitFun Server / Remote Controller"] + end + + subgraph Adapters["入口适配层,不拥有 Agent 行为"] + GuiAdapter["GUI/Tauri/Web Adapter"] + TuiAdapter["TUI Adapter"] + CliAdapter["JSON / stream-json Adapter"] + SdkAdapter["SDK Host protocol adapter"] + AcpAdapter["ACP Adapter"] + ServerAdapter["HTTP/WebSocket/Remote Adapter"] + end + + App["Agent Runtime API\nquery/run/resume/fork/cancel\nsessions/tools/MCP/permissions/hooks/events"] + + subgraph Owners["单一行为 Owner"] + Runtime["SessionManager → Session → DialogTurn → ModelRound"] + McpOwner["MCP lifecycle owner"] + ToolOwner["Tool owner"] + PermissionOwner["Permission / Policy / Audit Owner"] + HookOwner["Hook Coordinator"] + EventOwner["Event / Usage / Trace Owner"] + end + + GUI --> GuiAdapter --> App + TUI --> TuiAdapter --> App + CLI --> CliAdapter --> App + SDK --> SdkAdapter --> App + ACP --> AcpAdapter --> App + Server --> ServerAdapter --> App + App --> Runtime + App --> McpOwner + App --> ToolOwner + App --> PermissionOwner + App --> HookOwner + App --> EventOwner + Runtime --> ToolOwner + McpOwner -->|"typed Tool provider / catalog"| ToolOwner + ToolOwner --> PermissionOwner + ToolOwner --> HookOwner + Runtime --> EventOwner +``` + +该图回答三个容易混淆的问题: + +1. **GUI/TUI/Headless CLI 同样需要 Query、MCP、Permission 和 Hook**。它们通过共享 Agent + Runtime API 使用这些能力,不通过公开 Python/TypeScript SDK 包。 +2. **没有两套实现**。公开 SDK 的 Tool/MCP/Permission/Hook 只是 adapter 和 callback bridge; + 最终注册、顺序、权限、执行、审计和状态提交仍由同一个 owner 完成。 +3. **共享应用层不等于共享 UI 或协议**。GUI/TUI 保留各自 renderer、交互和平台生命周期;CLI、 + SDK、ACP、HTTP/WebSocket 保留各自 wire projection。 + +### 4.1 进程与依赖视图 + +```mermaid +flowchart LR + Python["Python SDK"] -->|"manage / stdio"| Host["bitfun-sdk-host\nstandalone app"] + TypeScript["TypeScript SDK"] -->|"manage / stdio"| Host + Host --> AppTransport["apps/sdk-host\nprocess bootstrap + stdio framing"] + AppTransport --> HostProtocol["interfaces/sdk-host\nprotocol + connection use cases"] + HostProtocol --> RuntimeApi["Agent Runtime API"] + + Bitfun["bitfun\nTUI + headless CLI + ACP command"] --> RuntimeApi + Desktop["Desktop / GUI"] --> RuntimeApi + Server["Server / Remote"] --> RuntimeApi + + RuntimeApi --> Owners["single Session / Tool / MCP /\nPermission / Hook owners"] + Owners --> TerminalPort["TerminalPort\nshared internal contract"] + TerminalPort --> TerminalCore["terminal-core\ninternal service implementation"] +``` + +| 依赖事实 | 约束 | +|---|---| +| `bitfun` CLI | 不依赖 `bitfun-sdk-host` app 或协议;只依赖共享 Runtime/Capability owner | +| `bitfun-sdk-host` app | 独立 composition root;只选择 `DeliveryProfile::Sdk` 与 `AgentSubmissionSource::SdkHost`。当前与 Headless CLI 共享 Runtime owner 和 assembly-plan 能力上限,但 Host 的实际 wire 能力是明确的严格子集;它不依赖 `bitfun-cli` crate,也不冒充 CLI | +| `interfaces/sdk-host` | 只拥有 JSON-RPC DTO、初始化/能力协商和连接级 Query/Session 生命周期;不拥有 stdin/stdout、进程入口或 Runtime 业务状态 | +| `TerminalPort` | Runtime 内部的抽象端口,供内置 Bash/命令类 Tool 复用;不是 Python/TypeScript SDK 方法,也不是 SDK Host wire | +| `terminal-core` | `product-full` Host 内部的具体终端/PTY 服务;SDK Host app 可像 GUI/TUI/CLI 一样通过共享 Runtime owner 使用它,但 `interfaces/sdk-host`、公开 SDK 类型和 JSON-RPC DTO 禁止依赖或暴露它 | +| Python/TypeScript SDK | 后续随包携带或定位匹配的 `bitfun-sdk-host`;用户不需要安装 CLI | +| HTTP/WebSocket Server | 独立远程产品;不作为本地 SDK callback Host,也不被 CLI 隐式启动 | + +各 composition root 必须在配置归一化读取全局 Tool owner 前选择自己的 delivery profile;选择是进程级且不可替换, +后续 Agentic 初始化只验证同一选择。CLI、ACP 和 SDK Host 复用这一 Core 初始化契约,但不互相依赖 app 或协议: + +```mermaid +flowchart LR + Entry["CLI / ACP / SDK Host entry"] --> Profile["select delivery profile"] + Profile --> Config["initialize config + AI services"] + Config --> Agentic["initialize shared Agentic owners"] + Agentic --> Surface["build surface-specific runtime / transport"] +``` + +独立 Host 进程在接受协议输入前安装进程级 TLS crypto provider,并在受控的 16 MiB worker stack 上启动 +Tokio Runtime;这些是 Host composition root 的运行条件,不进入 SDK Host 协议,也不由 CLI 代为提供。 + +### 4.2 各形态能做什么 + +| 形态 | 主要用途 | 必须共享的 Agent 能力 | 形态特有职责 | +|---|---|---|---| +| GUI | 日常交互、审批、可视化、设置和恢复 | 完整会话、工具/MCP、权限、Hook 结果、用量和事件 | React/Tauri/Web 渲染、窗口和平台生命周期 | +| TUI | 终端内持续交互和操作 | 与 GUI 相同的业务事实和控制动作 | Ratatui/终端输入、键位、终端恢复 | +| Headless CLI | shell、CI、一次性或脚本化调用 | Query、Session resume、Tool/Permission 策略、结构化输出、取消 | flags、stdin/stdout/stderr、退出码、JSON/JSONL | +| Agent SDK | 自定义应用、服务、自动化和语言内回调 | Headless CLI 全部能力,加自定义 Tool、Permission/Hook callback 和 typed objects | Python/TypeScript API、callback 执行、Host 进程管理 | +| ACP | 编辑器与 Agent 的标准互操作 | ACP 标准可表达的 Session、Turn、Tool、Permission、Event 子集 | ACP 生命周期、RPC 和降级映射 | +| Server/Remote | 远程控制、多设备和服务化接入 | 由 Host capability 协商允许的共享用例 | 认证、网络、执行域、断线恢复、远程策略 | + +## 5. SDK Host 与公开 SDK + +### 5.1 目标拓扑 + +```mermaid +sequenceDiagram + participant App as "Python / TypeScript application" + participant SDK as "BitFun Agent SDK" + participant Host as "Standalone matched bitfun-sdk-host" + participant API as "Agent Runtime API" + participant Owners as "Runtime / MCP / Tool / Permission / Hook owners" + + App->>SDK: query(prompt, options) + SDK->>Host: initialize(clientInfo, protocolVersion, capabilities) + Host-->>SDK: initialized(runtimeVersion, supportedCapabilities) + SDK->>Host: session/start + turn/start + Host->>API: typed runtime request + API->>Owners: execute through single owners + Owners-->>API: event / permission / custom-tool / hook request + API-->>Host: typed callback request + Host-->>SDK: typed bidirectional message + SDK-->>App: async Message/Event or callback + App-->>SDK: permission/tool/hook result + SDK-->>Host: typed callback response + Host->>API: callback response + operation identity + API->>Owners: resume the pending owner request + Owners-->>API: immutable execution fact + final projection + API-->>Host: final result + usage + Host-->>SDK: turn/completed + SDK-->>App: Result + App->>SDK: session.close() + SDK->>Host: session/close + Host->>API: cancel pending work and release session resources + API-->>Host: session/closed + Host-->>SDK: session/closed +``` + +### 5.2 SDK Host 协议职责 + +SDK Host 协议只负责跨进程/跨语言边界,不拥有业务状态。目标方法族至少覆盖: + +- `initialize` / capability negotiation / graceful shutdown。 +- Session create/resume/fork/list/close、Turn start/cancel/steer 和结构化输入。`close` 只释放已加载资源, + 不等于持久化 `delete` 或 `archive`。 +- 有序 Event stream、终态、usage/cost/cache 和 trace correlation。 +- Permission request/response、用户问题和其他阻塞式交互。 +- SDK function Tool register/invoke/result/unregister。 +- SDK Hook register/invoke/result/unregister。 +- MCP server 配置进入既有 MCP lifecycle owner;SDK Host 只投影连接状态和 Tool catalog,不持有第二个连接池。 +- Agent/Subagent/Skill/Plugin source 的类型化配置与能力状态。 +- typed errors、deadline、cancellation、bounded queue、backpressure 和 late-result fence。 + +目标方法族按 capability 逐步开放。当前 PR1 实现候选的实际 wire 边界如下,不能从目标拓扑推导尚未交付的 callback: + +| 范围 | PR1 实现候选行为 | 明确未开放 | +|---|---|---| +| Query | `query/start` 通过共享 Dialog Scheduler 提交;每个 Session 同时只允许一个 SDK Query;若 Scheduler 返回 queued,Host 接受并绑定其精确 `turnId`,event、cancel 与 settlement 均只跟踪该 Turn | steer、resume/fork、structured output | +| Event | 只投影封闭的 `assistant_text_delta { text }`;内部事件和未知 payload 不上 wire | Tool、usage、trace 和原始事件总线 payload | +| Result | `query/result` 在 Turn settlement 后发布;失败携带稳定 code、retryable、correlation 和 recovery | 任意 JSON 错误或通过 message 驱动控制流 | +| Permission | capability 为 `false`;提交时关闭用户输入 Tool;出现属于当前精确 Turn 的 `ask` 时,在有界 deadline 内 reject + cancel + `action_required` | Permission callback 与隐式 auto-approve | +| Session | `session/create` 和不带 `sessionId` 的 `query/start` 只创建 connection-scoped transient Session;前者返回 `lifetime=connection`,后者返回 `sessionLifetime=connection`;`query/start.sessionId` 只能引用同一连接已创建的 Session,未知或已持久化的 ID 返回 `capability_unavailable`;`session/close` 由一个总 deadline 约束 scheduler maintenance 与资源清理;断连/close 后不可 resume/list/fork;该 Session 及其 Subagent 不暴露会读取或操纵独立 Session、持久定时任务或全局运行资源的 `SessionControl`、`SessionMessage`、`SessionHistory`、`Cron`、`ControlHub` | durable create、list、resume/fork、跨 Session 消息、持久调度和全局控制 | +| Transport | 独立 `bitfun-sdk-host` 进程使用本地换行分隔 JSON-RPC;有请求/Query/Session 上限、写超时和总 shutdown deadline;Host 用 exact ID 跟踪 transient create,在 graceful budget 后执行 abort → join → 专用 transient discard,永不通过普通 durable delete 补偿;deadline 耗尽时结束当前连接并记录结构化本地告警,当前协议不向已断开的调用方承诺清理完成结果 | managed SDK supervisor 的 Host 进程树 kill/reap、TCP、预启动 Host、远程复用 | + +`query/start` 若提供 `sessionId`,不得同时提供只在创建 Session 时有效的 `sessionName`、`agent`、`cwd` 或 +`model`;Host 返回 `invalid_request`,不会静默忽略。当前只有 `shutdown` 支持 JSON-RPC notification;会创建、返回或关闭 +Session/Query 资源的生命周期方法必须带 request ID,无 ID 时不执行且不响应。有效 JSON 但不符合 request 或 notification +envelope 的输入返回标准 `-32600`,只有 JSON 语法或行 framing 失败返回 `-32700`。 + +这里的 transient 只表示 Session 状态、turn、transcript 和 cache 不持久化;它不是权限或文件副作用沙箱。Task 创建的 Subagent 继承该边界,并在 +父 Session discard 时由现有 Session owner 按父子关系级联释放。discard 同时回收该 Session 仍持有的 Snapshot、Cron +job、Terminal binding 和后台终端进程;任一已存在资源无法确认关闭时 fail closed。文件编辑、已完成命令、MCP 或外部 +服务等 Tool 已产生的真实副作用不会随 Session discard 自动回滚。PR2 不得静默改变 PR1 方法的 lifetime;durable +create/resume 必须以新的 capability/协议版本开放,并先具备原子 commit、崩溃恢复、请求指纹和跨进程 fencing。 + +同一 `ToolRuntimeRestrictions` 必须同时约束首轮 Skill/Agent 列表、Subagent 的工具摘要、模型 tool manifest、 +`GetToolSpec` 与最终执行 admission。Agent 模板提到可选工具时必须要求先以当前 tool list 为准;不能让 prompt +继续指示模型调用已从 manifest 移除的能力,也不能为 SDK Host 单独维护一套 prompt 或工具过滤规则。 + +注册、查询和调用使用不同身份: + +| Lease scope | 典型内容 | 释放条件 | +|---|---|---| +| Connection | 调用方显式注册的 client-wide Tool/Hook | SDK dispose 或连接丢失 | +| Session | 调用方显式注册的 Session 级能力与资源引用 | `session.close` | +| Query | `query({ hooks, tools })` 注册的 callback 与 Query 持有资源 | 正常耗尽、`Query.close()`、异常退出或调用方取消 | +| Turn | 用户问题、单次 Permission interaction 和 Turn 临时等待 | 唯一 Turn 终态或所属 Query/Session 关闭 | + +每次 invoke 另带 operation/generation identity,不能把单次调用 ID 当成注册生命周期。Query 结束只撤销其 +Query lease 和 pending invocation,不影响持久化 Session、同连接其他 Query 或 connection lease;`session.close` +继续撤销该 Session 下的 Query/Turn lease。所有受影响的 pending callback 以类型化错误结束,随后到达的 late +result 被拒绝,不能把 callback future 或 handler 状态留给 owner 无限等待。 + +MCP 配置必须声明首轮就绪语义,而不是把“已配置”当“已可用”: + +| 策略 | Turn 准入 | 状态与目录 | +|---|---|---| +| 首轮必需 | admission 在独立 deadline 内等待;失败或超时则本 Turn fail closed | 只有 ready server 的工具进入绑定的 catalog generation | +| 后台连接 | Session 可先启动,初始化/状态事件明确报告 `pending` | ready/reconnect 产生目录 generation 事件,只影响之后 admission 的 Turn | + +每个 Turn 在 admission 时绑定一个 Tool catalog generation;MCP 在 Turn 执行中变为 ready 不改变该 Turn 的工具集合。 +重连、失败和 Session close 都通过既有 MCP lifecycle owner 发布状态并回收 Session 持有的引用。Python、TypeScript +和 Headless CLI 必须对同一配置采用同一 readiness policy;最终公开字段名在双语言 fixture 中冻结。 + +协议 schema 由单一合同源生成或验证 TypeScript/Python 类型。每个 SDK 版本必须绑定兼容的 SDK Host +版本范围;默认安装包携带匹配原生 SDK Host,避免依赖用户机器上未知版本的 `bitfun`。 + +首版 client-only 不开放任意 TCP URL。managed SDK Host 优先使用父子 stdio 或继承句柄;连接预启动的本地 +SDK Host 时,Windows named pipe / Unix domain socket 必须同时使用每次启动的随机 secret、端点 ACL/owner 校验, +并在平台支持时校验 peer credentials。secret 不进入命令行、日志或普通错误;Host 与 client 在业务 payload 前 +完成双向认证,再做版本/capability 握手。远程连接必须复用 Server/Remote 的认证、执行域、凭据和策略边界, +不能把 prompt、Tool 参数或 Permission/Hook callback 暴露给只通过版本握手的未知 SDK Host。能力不支持时 +返回类型化 `capability_unavailable`,不得静默降级成本地执行或另一权限模式。 + +预启动本地 SDK Host 的负向验收必须覆盖: + +| 场景 | 预期结果 | +|---|---| +| 错误、过期或重放 secret | 业务 payload 前拒绝,且不回显 secret | +| 端点被抢占,或 ACL/owner 校验失败 | fail closed,不连接同名未知 Host | +| 可用平台上的 peer credential 不匹配 | 拒绝连接并记录低敏诊断 | +| 多连接并发 | lease、callback、Session 和诊断彼此隔离 | +| 日志、进程参数和普通错误 | 不出现 secret、prompt、Tool 参数或凭据 | + +### 5.3 为什么不直接复用 ACP 或 `stream-json` + +| 候选 | 优点 | 阻断问题 | 结论 | +|---|---|---|---| +| 当前 Rust Runtime SDK 直接作为语言 SDK 发布 | 代码已有、内部调用丰富 | Rust-only、暴露端口装配、没有语言 callback/进程协议,用户心智与 Claude SDK 不等价 | 保留为低层 Rust preview | +| SDK 调用 `bitfun exec --stream-json` | 实现快、适合单向结果流 | 难以可靠承载长期 Session、并发 Turn、权限、函数 Tool、Hook、用户输入和 capability negotiation | 仅作为 Headless CLI,不作为正式 SDK transport | +| SDK 全部走 ACP | 可复用标准协议和现有 ACP adapter | 稳定 ACP 子集不能完整表达 BitFun/Claude 等价 SDK;私有扩展过多会破坏互操作心智 | ACP 作为同级 adapter | +| SDK 直接映射 HTTP Server | 远程天然可用 | 本地 callback、凭据、进程所有权和离线体验更复杂;容易把内部 route 固化为公开 API | 远程 transport 后续按真实用例增加 | +| 版本化双向 SDK Host | 支持本地函数回调、完整生命周期、schema、背压和匹配 runtime | 需要新增协议与包装发行,但不新增业务 owner | **目标方案** | + +## 6. 公开 SDK 心智与能力等价门槛 + +### 6.1 建议的语言无关形态 + +`query()` 是大多数用户的首选入口,并返回同时可异步迭代和显式控制的 Query handle;需要管理多个并发 Session +时再使用 client。两者必须共享同一消息、错误和 Session 语义。 + +```typescript +const activeQuery = query({ + prompt: "Find and fix the failing test", + options: { + allowedTools: ["Read", "Edit", "Bash"], + mcpServers: { /* ... */ }, + hooks: { /* typed lifecycle callbacks */ }, + }, +}); +try { + for await (const message of activeQuery) { + // Message / Event / Result + } +} finally { + await activeQuery.close(); +} + +// BitFun additive advanced API; not a Claude TypeScript SDK alias. +await using client = await BitFunAgentClient.start(); +const session = await client.sessions.create({ cwd, agent: "agentic" }); +const turn = await session.startTurn({ prompt }); +for await (const event of turn.events()) { + // approve/deny, answer questions, observe tools, cancel or steer +} +``` + +上述名称是设计级形态,不冻结最终 package 名、方法签名或字段。正式 API 只能在 TypeScript 和 Python +两个真实消费者、同一 SDK Host fixture 和升级测试通过后冻结。 + +Query 生命周期必须区分: + +| 动作 | 语义 | +|---|---| +| 正常耗尽 | 已观察唯一 Turn 终态;结束输入和 Query 资源,保留可恢复的持久化 Session | +| `interrupt()` | 请求取消当前 Turn;Query/Session 可继续用于后续输入,返回类型化取消结果 | +| `close()` / async dispose | 停止输入,取消未完成 Turn 与 Query 级 callback,回收 Query 持有的 MCP、子进程和 Subagent;不删除/归档 Session | +| 提前 `break`、异常或调用方取消 | 不能视为成功;语言包必须在 `finally`/异步析构中调用 `close()`,或明确把 handle 所有权交给其他任务 | + +managed SDK Host 仅在最后一个 Query/client 引用释放后退出;预启动的受信本地 SDK Host 只释放当前连接资源。 + +### 6.2 Claude Agent SDK 等价的定义 + +“等价”指公开心智和核心能力类别等价,不指 package、类名、字段名、Hook ABI 或私有实现一比一。 +公开候选版本必须冻结一个 Claude Agent SDK 稳定版本,并逐项标记: + +| 状态 | 含义 | +|---|---| +| 原生(native) | BitFun 现有 owner 直接提供同类公开语义 | +| 映射(translated) | 达到同一用户目标,但命名或生命周期由 BitFun 语义映射;差异必须可查询 | +| 增强(additive) | BitFun 独有能力,只能作为附加项,不能替代 Claude 稳定核心能力 | + +冻结版本中的稳定核心能力不得在 BitFun 正式发布(GA)中以 `unsupported` 静默缺失。冻结之后新增的竞品能力进入 +下一版本评审,不形成滚动追赶。 + +| 能力类别 | BitFun 目标 | 当前基础 | 正式发布门槛 | +|---|---|---|---| +| Query 与异步流 | 单次/连续输入、typed message/event/result、interrupt/close | Headless `stream-json` 与内部事件已有不同投影 | Python/TS 同一 fixture、顺序/终态/错误/提前退出清理等价 | +| Session | create/resume/fork/close/persist/ephemeral | Rust Runtime SDK 已有多项 Session 用例 | `close` 与 delete/archive 分离;跨进程重启、fork、并发写入和资源回收明确 | +| Turn 控制 | start/cancel/steer、用户问题与 background result | 内部取消和交互端口已有基础 | 取消树、late result、阻塞输入和终态唯一性有端到端(E2E)验证 | +| 执行上限 | model-round/iteration、预算和 deadline 的计数与终态稳定 | 已有部分用量、取消和 deadline 事实 | 与 Claude `max_turns`/budget 错误做显式映射,不把它误解为 BitFun Turn 数 | +| 认证与执行上下文 | model/provider、cwd/workspace roots、sandbox、env、配置来源与凭据 owner | 产品入口已有分散实现 | Python/TS 同 fixture;凭据不进入 wire、日志或 callback,配置优先级和远程执行域明确 | +| 内置 Tool | 文件、搜索、终端等经现有 Tool owner 执行 | 产品 Runtime 已具备 | SDK 与 GUI/TUI/CLI 使用同一 catalog/权限/审计 | +| 自定义函数 Tool | Python/TS callback 作为受控 Tool provider | 尚无公开 callback bridge | schema、deadline、取消、大小、异常和进程丢失语义完备 | +| MCP | SDK 配置本地/远程 MCP,状态和 Tool 进入同一 owner | 产品与 ACP 有既有 MCP 路径 | 不创建 SDK 专用 MCP client/registry;首轮 required/background readiness、catalog generation、身份/OAuth、重连和 Session close 可解释 | +| Permission | allow/deny/ask、`canUseTool` 式 callback、批量与审计 | 内部 Permission owner 和请求流已有 | callback 不能突破组织/产品/Host 上限;超时 fail closed | +| Hook | 生命周期 callback,确定性顺序、变换/阻止/观察 | 内部 Hook 基础与外部生态适配在演进 | 复用唯一 Hook Coordinator;SDK 不创建 HookBus | +| Subagent | 定义、委派、父子事件/权限/用量 | Runtime 已有 Subagent 能力 | 父子取消、权限收紧、预算和事件关联一致 | +| Skills/Plugins/配置来源 | 显式选择可信来源并报告实际加载状态 | Skills/外部来源控制面已有基础 | 默认/裸模式明确;来源、版本、失败和风险可查询 | +| System prompt / Agent 定义 | 默认、追加和替换语义明确 | 内部 Agent/Prompt registry 已有 | 不暴露内部模板;来源与最终摘要可审计 | +| Structured output | JSON Schema 校验、失败与重试事实 | Headless/Runtime 能力需统一核对 | 与 SDK Result 和 CLI JSON 使用同一结果合同 | +| Checkpoint / rewind | 文件/会话恢复范围可解释 | 有局部 snapshot/checkpoint 基础 | 不把本地 workspace snapshot 冒充完整 rewind;副作用边界有 fixture | +| Usage / cost / trace | token、cost、cache、duration、correlation、OpenTelemetry(OTel) | 内部 usage/event/trace 分散存在 | 低基数指标、敏感数据默认不采集、跨语言字段一致 | +| 进程与错误 | 启动、版本、退出、崩溃、背压、重试、清理 | 进程治理已有公共 owner | Host/回调/MCP 后代回收与 typed error 全平台验证 | + +BitFun 的 Goal、Deep Review、Harness、Remote execution 和 Mini App 可以作为 additive 能力出现;它们 +不能替代上表中的基础能力,也不能迫使首次使用 SDK 的用户学习一套新的 Agent 基本概念。 + +## 7. Tool、MCP、Permission 与 Hook 的单一执行链 + +```mermaid +flowchart LR + Input["Model or user input"] --> Before["Hook Coordinator: BeforeTool"] + Before --> Validate["Tool Owner: schema + identity revalidation"] + Validate --> Permission["Permission Owner: product/organization/user policy"] + Permission --> Execute["Tool Owner invokes selected implementation"] + Execute --> Fact["Tool Owner commits immutable execution fact + audit"] + Fact --> After["Hook Coordinator: AfterTool / Failure"] + After --> Projection["Model/UI-visible projection"] + Projection --> Result["Runtime emits result, event and usage"] + + GuiPolicy["GUI/TUI permission UI"] --> Permission + CliPolicy["Headless policy flags"] --> Permission + SdkPolicy["SDK permission callback"] --> Permission + ProviderCatalog["Tool provider catalog"] + BuiltIn["Built-in provider"] -. "register implementation" .-> ProviderCatalog + SdkTool["SDK function provider"] -. "register implementation" .-> ProviderCatalog + McpSource["GUI/CLI/config/SDK MCP source"] --> McpLifecycle["Existing MCP lifecycle owner"] + McpLifecycle --> McpCatalog["Typed Tool provider / catalog"] + McpCatalog -. "register implementation" .-> ProviderCatalog + ProviderCatalog -. "selected implementation" .-> Execute + ExternalHooks["OpenCode/Claude/Codex adapters"] --> Before + SdkHooks["SDK callbacks"] --> Before + ExternalHooks --> After + SdkHooks --> After +``` + +约束: + +- SDK callback、GUI/TUI 决策和 CLI flag 只是候选输入;Permission Owner 保留最终决定权。 +- MCP 配置可以来自产品设置、项目文件、CLI 或 SDK,但连接、认证、健康和回收只进入既有 MCP + lifecycle owner;只有类型化 Tool provider/catalog 进入 Tool owner,不保留 SDK 专用连接池或 registry。 +- SDK Hook 与外部生态 Hook 共享 Hook Coordinator 的事件点、顺序、deadline、冲突、隔离和审计; + adapter 只解释来源语义。 +- Before Hook 修改参数后必须重新做 schema 与权限校验。After Hook 只能改变模型/UI 可见投影,不能覆盖 + 原始结果、成功/失败、真实副作用或审计事实;After 失败只产生投影诊断,不能重放 Tool。 +- Safe Mode、Host capability 或 Remote execution-domain 不允许时,所有入口统一 fail closed;SDK 不回退本机。 + +## 8. Headless CLI 与 SDK 的关系 + +两者能力心智应对应,但交付方式不同: + +| 需求 | Headless CLI | Agent SDK | +|---|---|---| +| 一次性 shell/CI 调用 | 最优,`bitfun exec` | 可用,但需要语言运行时 | +| stdin/stdout 管道 | 原生 | 由应用自行接入 | +| text/json/JSONL | 稳定输出与退出码 | typed object / async iterator | +| 多轮 Session | ID/flag 恢复,适合脚本 | Session object 与长连接更自然 | +| 权限 | flag/配置/非交互 fail-closed | typed callback + 同一策略上限 | +| 自定义函数 Tool | 不以内嵌 shell callback 作为稳定 ABI | Python/TS function callback | +| Hook | 配置来源 + 可选事件输出 | typed callback | +| 并发多个 Session | 由调用方启动多个进程,成本较高 | 一个 Host connection 上受控并发 | +| 生命周期 | 进程退出即完成,后台资源清理 | SDK/Host 显式 close/dispose,进程丢失可诊断 | + +能力等价不要求两者 flags 与方法一一对应。应维护一组共同 fixture,证明相同 Runtime 配置、输入、 +权限决策和 Tool 结果在两种入口产生相同最终业务事实;表现层字段和传输事件可以不同。 + +## 9. 错误、日志、打点与审计 + +公开 SDK 和 SDK Host 共享一套稳定错误信封,至少包含: + +- `code`、`stage`、`retryable`、`message`。 +- operation/session/turn/tool/hook identity 中当前层可见的最小集合。 +- `correlation_id`、`causation_id`、deadline/attempt。 +- 可选且闭合的 recovery action;调用方不得解析 message 控制流程。 + +错误类别至少区分 validation、authentication、permission denied、action required、provider quota、provider billing、 +capability unavailable、version mismatch、host unavailable、overloaded、timeout、cancelled、cleanup required、 +callback failed、invalid callback output、MCP unavailable、process lost 和 internal error。未确认 Turn settlement 或隐藏 +Session 清理失败属于 `cleanup_required`,不得建议在同一 Host 上自动重试。 + +四类观测数据保持分离: + +| 类型 | 用途 | 约束 | +|---|---|---| +| Error | 当前操作失败与恢复 | 稳定 code,不解析文案 | +| Diagnostic | Session/Tool/MCP/Hook/Host 的持续降级状态 | 生命周期明确,恢复后撤销 | +| Log | 本地开发和运维排障 | 英文结构化;默认不记录 prompt、参数、结果、环境变量和凭据 | +| Metric/Trace | 成功率、时延、队列、成本和因果链 | 低基数标签;身份、路径和内容不进 metric label | +| Audit | 权限、Hook 变换、Tool 副作用和管理动作 | 不可被 SDK callback/插件覆盖 | + +## 10. 版本、成熟度与发布门槛 + +公开版本同时包含三层版本: + +1. **SDK API version**:Python/TypeScript 的公开方法和类型。 +2. **SDK Host protocol version**:wire schema、方法、事件、错误和 capability。 +3. **Runtime capability version**:Hook 点、Tool/MCP/Permission 语义和 product capability。 + +当前 Host 握手返回 `stability=not_delivered`;其 `protocolVersion` 只是实现候选内部的 wire 修订号,不构成公开 v1 +兼容承诺。进入 preview 前必须从 Rust DTO 生成或机器验证唯一协议 schema,并以 drift check 保证 Python、TypeScript +和 Host 不各自手写一套合同;达到共同 golden fixture 与 schema 门槛后,才把成熟度切换为 `preview`。 + +SDK 包默认锁定匹配 SDK Host runtime。连接预启动的受信本地 SDK Host 时先完成双向认证,再完成初始化与 +capability negotiation。稳定 API +不得依赖 experimental capability;实验字段必须显式 opt-in,并允许旧 SDK 忽略未知通知。协议必须定义 +有界队列、过载错误、重试建议和关闭语义。 + +正式发布(GA)需要同时满足: + +- TypeScript 和 Python 两个真实仓库外消费者,而不是只通过单元测试或 `bitfun-core` adapter。 +- Claude Agent SDK 冻结稳定版本的能力矩阵无静默缺口。 +- Headless CLI 与 SDK 的共同 fixture 通过。 +- managed SDK Host 的安装、启动、升级、版本不匹配、崩溃和完整进程树回收通过 Windows/macOS/Linux 验证。 +- 预启动本地 SDK Host 通过错误/过期/重放 secret、端点抢占、ACL/owner、peer credential、多连接隔离和敏感数据不泄漏测试。 +- 自定义 Tool、Permission、Hook 和用户输入的双向 callback 端到端验证通过。 +- MCP、Session resume/fork、取消、structured output、usage/trace 和敏感数据策略有真实样例。 +- API 参考、迁移说明、错误码、能力状态和最小示例完整。 + +## 11. 与 OpenCode Hook/扩展工作的并行边界 + +```mermaid +flowchart LR + SDKTrack["SDK track\nSDK Host + Python/TS + callback transport"] + HookTrack["External Hook track\nOpenCode/Claude/Codex adapters"] + RuntimeApi["Shared Agent Runtime API"] + HookOwner["Single Hook Coordinator"] + ToolOwner["Single MCP / Tool / Permission owners"] + + SDKTrack --> RuntimeApi + HookTrack --> HookOwner + SDKTrack -. "PR3 waits for stable Hook contract" .-> HookOwner + RuntimeApi --> HookOwner + RuntimeApi --> ToolOwner +``` + +SDK 工作可以与 OpenCode/Claude/Codex Hook 适配并行,但只能在以下边界内: + +- SDK track 负责 SDK Host、语言包、query/session/event、SDK callback transport 和公开文档。 +- Hook track 负责公共 Hook 语义点、顺序、策略、隔离、诊断以及各生态 Source/Handler adapter。 +- 两条 track 都只能消费同一个 Hook Coordinator;SDK track 不先实现私有 HookBus,Hook track 不定义 + Python/TypeScript wire callback。 +- SDK Hook callback 的最终接入必须基于已合并的公共 Hook owner。两条分支同时修改 owner 合同或 + `agent-runtime` 权威状态时停止并行,先串行冻结合同。 +- 文档、schema fixture、独立 `apps/sdk-host`、`interfaces/sdk-host` 和 SDK package 可以并行;Cargo workspace、公共合同和 + owner 文件的机械冲突不应通过复制类型解决。 + +## 12. 后续实现切片 + +以下三个 PR 按顺序合并,交付可验证 preview,不直接宣称 GA。它们可与外部 Hook adapter 工作在上述边界内并行。 + +```mermaid +flowchart LR + PR1["SDK-PR1\nRuntime contract + local SDK Host"] --> PR2["SDK-PR2\nPython/TS query + Session + execution context"] + PR2 --> PR3["SDK-PR3\nFunction Tool + Permission + Hook callbacks"] + PR3 --> GA["GA evidence gate\nremaining Claude parity matrix"] + Hook["External Hook PRs"] -. "parallel until shared contract changes" .-> PR1 + Hook -. "Hook Coordinator prerequisite" .-> PR3 +``` + +### SDK-PR1:Agent Runtime 合同与本地 SDK Host 基线 + +当前状态:独立本地 Host 进程与协议基线已有实现候选,但尚未满足本节退出条件;公开 Python/TypeScript 包也尚未交付。 + +- 把当前 Rust Runtime SDK 收敛为共享 Agent Runtime 用例,不扩大为 service locator。 +- 建立 versioned initialize、Session/Turn、query/event/result、cancel、session close、typed error、capability 和 shutdown 协议; + Host 新建 Session 明确为 connection-scoped transient container,不冒充公开 SDK 的 durable Session。 +- 提供同进程 contract fixture 和独立本地 stdio Host process smoke;`bitfun` CLI 不承载 Host 子命令, + ACP/CLI/GUI/TUI 行为不迁移到第二 owner。Host 使用独立 SDK profile/source;它与 CLI 共享 Runtime owner 和 + assembly-plan 能力上限,但实际 Host capability matrix 保持严格子集,不形成 app 或协议依赖。 +- 仅投影现有稳定能力,并建立通用 request lease;不先发布自定义 Tool/Hook callback。 + +完整退出条件:同一 Session/Turn fixture 能经内部调用、Headless CLI 和 SDK Host protocol 得到等价业务事实; +过载、取消、进程丢失和版本不匹配 fail closed。`session.close` 能取消并回收待处理 owner interaction、MCP 引用、 +子进程和 Subagent;Host-owned transient Session 及其 transient Subagent 后代由现有 Session owner 级联 discard; +PR1 不接管、删除或归档任何已有 durable Session。 +当前独立 process smoke 已覆盖 Host 的启动、协议握手、stdout 纯净与关闭;Headless CLI 有独立执行测试。 +跨 Headless CLI、SDK Host 和内部直调用的同一 Session/Turn golden fixture,以及 Tool/Permission 等价结果, +仍是该退出条件的未完成证据,不能用各自 smoke 名称替代。 + +### SDK-PR2:Python/TypeScript query 与 Session 产品闭环 + +- 发布预览语言包,默认携带/定位匹配 SDK Host。 +- 支持带 `interrupt/close` 的 `query()`、async stream、resume/fork/close、structured output 和 usage; + `BitFunAgentClient` / Session 作为映射或增强入口,不宣称是 Claude TypeScript SDK 的同名接口。 +- 冻结认证、model/provider、cwd/workspace roots、sandbox、env、配置来源优先级和凭据 owner;接入既有 MCP lifecycle owner, + 并冻结首轮 required/background readiness、状态事件、catalog generation 与重连语义。 +- 完成预启动本地 SDK Host 的认证负向测试:错误/过期/重放 secret、端点抢占、ACL/owner、peer credential、 + 多连接隔离和敏感数据不泄漏;任何失败都发生在业务 payload 之前。 +- 提供 bare/default source 选择,避免 CI 隐式加载用户 Hook/Skill/MCP。 +- durable create/resume 必须先完成可判定的持久化 commit、同 ID 幂等恢复、binding/request fingerprint 校验、 + partial cleanup 和 managed Host kill/reap fencing,不能复用 PR1 transient 语义冒充持久会话。 +- 用相同示例和 golden fixture 验证 Python/TypeScript/Headless CLI。 + +退出条件:两个仓库外 pilot 可完成安装、执行、取消、恢复、升级和错误恢复;managed 与预启动本地 SDK Host +均通过各自安全验收;用户不需要配置 ACP 或手工安装 BitFun CLI。 + +### SDK-PR3:函数 Tool、Permission 与 Hook callback 闭环 + +- 在同一 callback transport 上增加函数 Tool、permission callback、用户问题和公共 Hook callback。 +- 复用合并后的 Tool/Permission/Hook owner;连接租约、deadline、取消、进程丢失和 late result 共用一套语义。 +- 等待公共 Hook Coordinator 合同稳定后接入,不实现 SDK 私有 HookBus。 +- 验证 connection/session/query/turn lease 隔离、Query 正常耗尽与关闭清理、pending callback 清理与 late result 拒绝。 + +退出条件:无 SDK 专用 Tool/Permission/Hook 状态或调度器;Subagent、Skills/Plugins、checkpoint/rewind、 +OpenTelemetry 等剩余差距继续留在第 6 节 GA 证据矩阵,不能为了宣称等价塞入本 PR。 + +## 13. 明确非目标 + +- 不提供 Claude Agent SDK 的 import-compatible 或 source-compatible 替换包。 +- 不把 `bitfun-core/product-full`、内部 manager、RuntimeServices、Host ABI、Tauri command 或任意 + `serde_json::Value` service locator 暴露给公开 SDK。 +- 不要求 GUI/TUI/CLI 通过 Python/TypeScript SDK 访问 Runtime。 +- 不用 ACP 私有扩展承载所有 BitFun 能力。 +- 不把远程 Server、云托管 Agent、插件市场或跨宿主会话迁移塞入首个 SDK PR。 +- 不因设计文档完整就宣称 SDK 已可用;状态必须分别标记 `not_delivered / preview / stable`。 + +## 14. 参考基线 + +- [Claude Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview) +- [Claude Code programmatic/headless mode](https://code.claude.com/docs/en/headless) +- [Codex TypeScript SDK](https://github.com/openai/codex/blob/main/sdk/typescript/README.md) +- [Codex Python SDK](https://github.com/openai/codex/tree/main/sdk/python) +- [Codex App Server](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) +- [OpenCode SDK](https://opencode.ai/docs/sdk/) +- [OpenCode Server](https://opencode.ai/docs/server/) diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index d647538c03..01d29add65 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -8,7 +8,9 @@ 布局选择和内置扩展见 [`product-customization-blueprint.md`](product-customization-blueprint.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)。本文只补充 -CLI 产品入口、配置兼容、TUI 布局消费和 CLI Agent 体验,不重复定义这些文档中的通用契约或内部 ABI。 +CLI 产品入口、配置兼容、TUI 布局消费和 CLI Agent 体验,不重复定义这些文档中的通用契约或内部 ABI。公开 +BitFun Agent SDK 与 Headless CLI 的心智、能力等价和 SDK Host 边界见 +[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md)。 OpenCode 的完整扩展矩阵、配置资产、插件执行和 TUI Plugin 映射分别见 [`opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md)、 [`opencode-config-assets-adapter-design.md`](extensions/opencode-config-assets-adapter-design.md)、 @@ -44,7 +46,8 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 5. 消费已校验的产品组装结果和 TUI 布局选择,生成不同品牌和能力范围的 CLI 产物;通用产品定制不在 CLI 入口重复实现。 6. 以任务成功率、恢复能力、工具正确性、上下文效率和资源开销评估 Agent 能力,而不是只比较命令数量。 -7. 把 HarmonyOS PC 作为一等 CLI/TUI 目标:用户在系统真实终端中安装并执行本地 `bitfun-cli`;HAP 内终端式 +7. 把 HarmonyOS PC 作为一等 CLI/TUI 目标:用户在系统真实终端中安装并执行本地 `bitfun`;废弃兼容入口 + `bitfun-cli` 不作为新集成入口。HAP 内终端式 界面、`hdc shell`、移动 Remote App 和其他设备代执行均不构成该目标。 ### 1.2 能力对齐口径 @@ -92,31 +95,42 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop - Agent、模型、MCP、会话、用量、诊断、ACP 外部 Agent 和插件来源管理命令。 - BitFun 原生插件目录的发现、内容校验、来源确认,以及 OpenCode custom tool 静态名称预览。 - CLI 本地 Agent 入口以类型化 `RuntimeServices` 调用 `ProductAssembler`,选择 `DeliveryProfile::Cli`, - 并把 `ProductRuntimeParts`、Agent Runtime SDK、事件源和调用级审批策略保存在一个 `CliRuntimeContext` 中。 -- TUI、`exec`、会话、用量和交互模式下的 Peer Host 复用同一上下文。SDK 已承接会话创建(包括 - `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除、轮次提交和取消;普通创建 - DTO 保持 v1 字段集合,固定 ID 冲突返回 `InvalidRequest`。会话模型更新、工具确认/拒绝和用户问题回答也通过 - SDK 的窄端口回到 Core owner;模型目录、模式和提供方配置仍由产品入口解释。SDK v1 尚未覆盖的恢复视图、 - 消息、分支、用量和快照由一个 Core - 兼容门面转发给原 owner。 + 并把 `ProductRuntimeParts`、Rust Runtime SDK、本地工作区快照 owner port、事件源和调用级审批策略保存在一个 `CliRuntimeContext` 中。 +- TUI、`exec`、会话、用量和交互模式下的 Peer Host 复用同一上下文。Rust Runtime SDK 已承接会话创建(包括 + `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、本地分支、 + 用量生成、轮次提交/取消和精确结算;普通创建 + DTO 只增加可选工作区 ID 与模型 ID 事实,固定 ID 冲突返回 `InvalidRequest`。会话模型更新、工具确认/拒绝和用户问题回答也通过 + Rust Runtime SDK 的窄端口回到 Core owner,TUI 与 ACP 的活动会话模式更新也复用同一窄端口;模型/模式目录和提供方配置仍由产品入口解释。 + TUI 模式切换以异步待提交状态调用 Core,期间终端输入、resize 和重绘保持响应,新的对话提交不会消费用户输入; + 只有 Core 校验并持久化成功后才更新本地显示,失败时保留原模式并给出可重试提示,同值选择不产生持久化写入。 + 等待期间可以切换或新建会话;只有原会话的发送继续等待。首次退出请求在持久化成功后自动退出,失败时留在界面提示重试;再次退出允许立即离开, + 并明确提示下次恢复以 Core 的持久化模式为准。恢复主会话时,已从当前目录移除的持久化模式由 Core 迁移到可执行回退模式; + TUI 对比恢复前后的会话摘要并显示模式变化,如果同时携带启动输入,只预填而不自动执行,须由用户确认后发送。TUI 用量卡片通过固定语义的 + 完成态本地命令轮次端口持久化。Peer Host 的本地工作区准备、会话文件清单、类型化快照统计和工作区文件回滚通过 + 独立的本地 owner port 回到现有 Core 快照实现;该端口不进入共享 Agent Runtime API 或公开 Agent SDK,不接受远程身份,也不承载历史截断、维护锁或完整 checkpoint/rewind。 + 账号同步、富历史及 Peer Host/ACP 的其余维护缺口继续由一个 Core 兼容门面转发给原 owner。 - Agentic Event Queue 仍是唯一事件 owner;TUI、`exec` 与 Peer Host 使用独立广播订阅,不互相消费事件。 - 有界旧队列只承担兼容存储;达到容量时不得抑制广播。CLI 保持一个后台 drain,订阅方一旦报告 lag/closed, 必须取消活动 turn 并显式失败,不能在状态不完整时继续报告成功。 - 会话 ID 在进入存储路径前统一校验;运行时索引同时绑定 ID 与规范化存储路径,并以待提交 claim 计数保护 并发恢复。同一进程不能把另一个工作区中已加载的同 ID 会话当作当前会话,单个失败恢复也不能释放其他 - 同路径恢复仍在使用的绑定;已加载会话只校验身份,不通过完整 restore 重置活动状态。删除路径不能通过 + 同路径恢复仍在使用的绑定;主会话提交不做前置完整 restore,只有 Runtime owner 返回结构化 `NotFound` 时才恢复或 + 按原 ID 重建并重试一次,其他资源缺失与后端错误原样失败。删除路径不能通过 相对路径、绝对路径或分隔符越出 sessions 根目录。 - TUI 终端句柄由恢复守卫持有;初始化中途失败、正常返回、错误返回或 panic 展开都会尽力退出 alternate screen、 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 启动页进程冒烟测试已验证 resize 后仍可交互、 多行输入、空闲 Ctrl+C 和可观察的终端清理序列;Chat 活动 turn 的 resize 静默期已有状态单测,窄屏流式 - reflow 已有 TestBackend 回归,真实 PTY 活动 turn、初始化失败与异常退出等仍需独立验收。 + reflow 已有 TestBackend 回归,Linux PTY 与 Windows ConPTY 活动 turn 的 resize/取消已有本地确定性流式模型夹具进程测试; + `exec stream-json` 的 Ctrl+C 也由真实 PTY/ConPTY 进程验证断流、非零退出和单一取消终态。OS 级初始化失败与 + 异常退出仍需独立验收。 - Startup 与 Chat 共用 CLI 私有输入读取器;一次读取同时受 256 个事件和 50ms 限制,跨批次仅延续快速文本尾部, 短批次普通按键保持原有路由。被识别为粘贴的文本按批次写入输入缓冲,每批只刷新一次命令菜单;粘贴内容中的 Tab 明确转换为四个空格。 - 初始化按入口分级:交互模式启动 Peer Host 与 MCP,`exec` 只启动 MCP;本地 session 管理和 usage 查询不启动 Peer Host/MCP。该分级不改变 Agentic/Terminal owner,也不等同于管理命令已有独立轻量 Runtime。 - Peer Host 保持既有 HostInvoke / DeviceEvent wire schema 与 Relay 路由,但执行已接入上述调用级上下文: - 对话提交和精确取消走 SDK,会话与快照缺口走单一 Core 兼容门面。Peer Host 只跟踪由 Peer 提交的根 turn、 + 对话提交、精确取消、会话创建/基础恢复/重命名/归档、thread-goal 查询和模型更新走 Rust Runtime SDK;本地快照文件清单、统计和文件回滚走窄 owner port; + 富历史及其余维护缺口走单一 Core 兼容门面。Peer Host 在进入本地端口前对远程身份与远程路径返回明确不支持错误,并继续拥有回滚前取消、维护锁、历史截断、部分失败提示和事件投影。Peer Host 只跟踪由 Peer 提交的根 turn、 其子 turn 与待确认工具;可确认工具始终由控制器确认,即使宿主全局策略跳过确认,Agent 也会暂停等待控制器。 该 Peer 专属确认要求会沿精确后台结果 follow-up 保留。后台结果按 Core 内部元数据中的精确父 turn 与来源子 turn 继承 ownership;仅在父 turn 仍运行时注入,否则排在 @@ -128,26 +142,26 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 显示警告。不承诺本次变更范围外的 ACK、重放或重连恢复。 - `doctor` 与 `health` 构造并校验真实 Runtime Parts,区分 assembly-ready、Core compatibility owner 和不可用扩展。 它们证明必需能力已注册,不把 Core 的 Network/Git/MCP compatibility marker 描述为外部服务实时可用。 -- 独立 CLI 测试与打包工作流;主 CI 的三平台 workspace check 同时覆盖 `bitfun-cli` 编译,发布归档在上传前校验 - SHA-256 摘要,并从解压后的目录执行 `--version` / `--help`。 +- 独立 CLI 测试与打包工作流;主 CI 的 Windows/macOS/Linux workspace check 同时覆盖 Cargo package + `bitfun-cli` 编译,原生发布归档包含主入口 `bitfun` 和废弃兼容入口 `bitfun-cli`,上传前校验 SHA-256 摘要, + 并从解压后的目录验证两个入口及废弃告警。 上述切换不等于运行时 owner 已迁移,也不表示 CLI-P0 全部完成。CLI crate 仍以 `bitfun-core/product-full` -承载协调器、调度器、持久化、工具管线和部分 SDK v1 缺口,但 Peer Host 不再自行构造这些 owner;ACP 的 stdio、 -连接和协议投影仍由 `interfaces/acp` 持有,后端已切换至 `DeliveryProfile::Acp` 与组装后的 SDK runtime;插件命令 +承载协调器、调度器、持久化、工具管线和 Rust Runtime SDK 当前未覆盖的部分,但 Peer Host 不再自行构造这些 owner;ACP 的 stdio、 +连接和协议投影仍由 `interfaces/acp` 持有,后端已切换至 `DeliveryProfile::Acp` 与组装后的 Rust Runtime SDK;插件命令 仍以来源管理和静态预览为主。兼容门面只转发,不重新计算或写入同一事实。 目标态仍存在以下结构缺口: | 缺口 | 影响 | 本设计的处理 | |---|---|---| -| CLI 已消费 Runtime Parts,但部分执行与持久化操作仍由 `bitfun-core/product-full` 兼容 owner 提供 | SDK 尚不能独立覆盖完整产品会话,过早删除兼容路径会改变行为 | 仅在稳定端口、真实嵌入方和行为等价测试齐备后迁移 owner;兼容门面保持单一且不扩展成第二套 Runtime。 | +| CLI 主会话客户端已仅消费 Rust Runtime SDK;本地工作区快照的准备、文件清单、统计和文件回滚已有 Desktop/Peer Host 共用的窄 owner port,但快照记录/持久化/事件、账号同步、富历史及 Peer Host/ACP 的其余维护仍由现有 Core owner 提供 | 窄端口只消除重复宿主转发,不代表完整快照系统、远程快照或公开 SDK 能力已迁移;过早删除其余兼容路径会改变行为 | 保持快照实现和工具拦截在 Core,远程与历史维护留在宿主;仅在新的真实调用方、独立语义和行为等价测试齐备后继续迁移。 | | TUI 编排、输入、命令、副作用和渲染仍有大文件聚集 | 交互回归难以隔离,终端状态与业务状态容易耦合 | 在现有模块上增量收敛为事件、状态归约、副作用和渲染四个边界,不重写全部 TUI。 | -| `ShortcutsConfig` 已加载,但主要按键分发仍硬编码;Slash、Palette、帮助和执行来自不同位置 | 配置可能保存却不生效,展示和真实行为会漂移 | 在 CLI 宿主建立单一 action registry 和上下文键位解析;不借机重写 renderer。 | | CLI 配置只覆盖入口本地选项,缺少统一层级、来源解释和兼容导入 | 用户无法安全复用其他 CLI 资产,也难以解释最终配置来源 | 建立 BitFun Canonical Config、持续来源视图和可选的显式导入报告。 | | OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 第一条闭环只完成一个无外部依赖的契约样例;取得真实 `execute` 并注册到 Tool Runtime 后才显示可用。 | | 当前 CLI 使用 `product-full`,OHOS target 图包含多组未验证的平台依赖 | 不能据依赖可解析、`hdc shell` 或移动 Remote App 推导 PC 本地 CLI/TUI 可用 | 问题与风险统一记录在平台规约;具体工作另立专题,HAP 不作为替代。 | | Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | -| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;Linux 与 Windows 分别运行启动页 PTY/ConPTY 生命周期冒烟,发布归档上传前完成 SHA-256 与解压执行验证 | 真实模型审批/取消、真实 PTY 活动 turn 的 resize、Patch I/O 失败和终端故障注入仍可能晚于 PR 发现 | 继续补剩余进程级故障契约;避免为同一依赖图重复建立三平台编译矩阵。 | +| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;本地确定性模型夹具覆盖 HTTP 403 授权拒绝、流中断后的重试失败和 Patch 写入失败,真实 PTY/ConPTY 进程覆盖启动页、Chat resize/取消恢复及 `exec` Ctrl+C 的断流和单一取消终态;发布归档上传前完成 SHA-256 与解压执行验证 | 真实供应商审批交互和 OS 级终端初始化故障注入仍可能晚于 PR 发现 | 只按剩余真实故障补进程契约;避免为同一依赖图重复建立三平台编译矩阵。 | ## 3. 分阶段产品需求 @@ -157,19 +171,20 @@ CLI-P0 的目标是建立后续功能补齐所需的稳定边界,不改变现 CLI-P0 不是一个统一重构 PR。静态 profile、真实 Runtime Services、Runtime Parts、调用级审批、共享事件源和 本地 Agent 纵向入口已接入;旧门面仅在后续 owner 迁移的行为等价成立后退出。配置解释、产品定制消费和 TUI -进一步拆分仍需独立交付。CLI 托管的 ACP 服务端已独立切换到 ACP profile 与组装后的 SDK runtime;启动页 -PTY/ConPTY 生命周期冒烟测试与发布归档冒烟测试已存在;Chat 活动 turn 的 resize 静默期已有确定性状态单测, -窄屏流式 reflow 已有 TestBackend 回归,真实模型、真实 PTY 活动 turn、终端故障注入与权限失败等完整进程级验收仍需另行完成。 +进一步拆分仍需独立交付。CLI 托管的 ACP 服务端已独立切换到 ACP profile 与组装后的 Rust Runtime SDK;启动页 +PTY/ConPTY 生命周期、Chat 活动 turn 的 resize/取消和发布归档冒烟测试已存在;resize 静默期与窄屏流式 reflow +分别有确定性状态单测和 TestBackend 回归。真实 `stream-json` 进程已保护 Patch 写入失败、本地模型 HTTP 403 授权拒绝、 +流中断后的重试失败和 `exec` Ctrl+C 单一取消终态;真实供应商审批交互与 OS 级终端初始化故障注入仍需另行完成。 其余工作独立立项,不能与 profile 迁移互相充当完成条件: | 切片 | 范围 | 退出条件 | |---|---|---| | 调用级审批 | TUI、`exec` 与 ACP 已使用各自调用级策略且不写全局配置 | Runtime-context `Allow always`、审批规划、`exec` 安全默认值和显式 `--auto` 有 focused test;真实模型/PTY 审批流与 ACP 仍需另行验收 | -| 输出协议 | 保持通用 `text/json/stream-json` 心智,复用现有 Agentic envelope,不新建 CLI schema | 已覆盖结果/envelope 序列化、参数与前置 JSON 失败、失败完成、同会话跨 turn 隔离和 stream-json/Patch stdout 冲突;真实信号、模型权限失败与 Patch I/O 故障注入仍需进程级契约 | +| 输出协议 | 保持通用 `text/json/stream-json` 心智,复用现有 Agentic envelope,不新建 CLI schema | 已覆盖结果/envelope 序列化、参数与前置 JSON 失败、失败完成、同会话跨 turn 隔离、stream-json/Patch stdout 冲突及 Patch 写入失败;写入失败返回非零并只发送结构化错误,不提前发送成功终态;真实 PTY/ConPTY 信号、本地模型 HTTP 403 授权拒绝和流中断后的重试失败已有进程契约,真实供应商审批交互另行验收 | | 配置解释 | Canonical Config 层级、全局/项目持续来源、加载状态和兼容导入 dry-run | 不自动写入;冲突、未知字段、待确认能力和凭据引用可解释 | | 产品定制 | 消费最小产品定义、组装结果和已注册 TUI layout/theme ID | 第二个真实 CLI 产品复用后再提升公共字段 | -| TUI 边界 | 增量提取终端恢复守卫、命令分发和副作用边界 | 不改版视觉设计,恢复/取消回归可单独验证 | +| TUI 边界 | 增量提取终端恢复守卫、命令分发和副作用边界 | 不改版视觉设计;Linux PTY 与 Windows ConPTY 活动 turn 的 resize/取消、恢复可编辑状态和正常退出清理可单独验证,macOS 活动 turn 与 OS 级初始化失败注入另行补齐 | CLI-P0 不包含插件 JS/TS 执行、完整 checkpoint/rewind 或大规模 TUI 重写。 CLI-P0/P1/P2 在 Windows、macOS、Linux 完成不表示 HarmonyOS PC 已支持;HarmonyOS PC 的具体适配由未来独立专题 @@ -212,16 +227,28 @@ CLI-P1 应保证: |---|---| | `text` | 最终助手文本写 stdout;进度、思考、工具状态、日志和诊断写 stderr。显式 `--output-patch -` 是用户选择的额外 stdout 内容。 | | `json` | stdout 只写一个结果对象,包含 `type=result`、`subtype`、`is_error`、`result`,以及已建立时的 `session_id`/`turn_id`、本 turn 累计 `usage` 和可用的 `patch`。 | -| `stream-json` | 每行直接序列化一个现有 `AgenticEventEnvelope`;不增加 `schema_version`、`sequence` 或第二套 CLI 事件 taxonomy。 | +| `stream-json` | 每行直接序列化一个现有 `AgenticEventEnvelope`;不增加 `schema_version`、`sequence` 或第二套 CLI 事件 taxonomy。所有 terminal envelope 都只在精确结算和 Patch 交付完成后发布一次;结算失败优先于 Patch 失败,Patch 失败优先于 turn 终态,前两者统一改为 `SystemError` 并以非零状态退出。一次执行最多发布一个 terminal envelope 和一条 `BITFUN_EXIT` 分类。 | | 事件范围 | 只输出本次 session/turn 的事件,以及与其明确关联的 subagent link/tool 事件;同 session 的其他并发 turn 不得混入。 | | Patch | `json` 可把 `--output-patch -` 放入最终对象;`stream-json` 要求显式文件路径。Patch 是写出显式 Patch 文件前捕获的仓库 `HEAD` 相对工作区快照,包含 staged、unstaged、untracked 及命令启动前已有改动,不包含输出 artifact 本身,也不表达改动归因。 | | 权限 | 非交互默认拒绝并返回权限失败;`--auto` 只改变当前提交策略,不修改持久化配置。 | | 人工输入 | 非交互 `exec` 不暴露 `AskUserQuestion`;调用方必须在初始输入中提供完整上下文。该事实沿 Task、SessionMessage 及其自动回复链传播,避免子 Agent 或后续 turn 等待不存在的 stdin 处理器。 | -| 终止 | terminal event 决定结果;`success=false` 不能映射为成功。`Ctrl+C` 请求取消,并在有界等待内继续转发当前 turn 的 terminal envelope 后返回取消结果。当前公开契约不新增 Agent turn 总时限参数;调用方可使用进程级期限,只有出现真实消费方时才单独设计 deadline。 | +| 终止 | terminal event 决定结果;`success=false` 不能映射为成功。`Ctrl+C` 请求取消,终态观察与精确结算共享同一有界等待;若取消与完成/失败竞争,以实际观察到的 terminal event 为准,若结算失败则只发布替代的 `SystemError`。窗口内未观察到 terminal envelope 时发布结构化错误,不能无终态退出。当前进程仍以通用错误码 `1` 退出;只有实际取消终态使用 stderr 的 `BITFUN_EXIT: cancelled:` 稳定分类。当前公开契约不新增 Agent turn 总时限参数;调用方可使用进程级期限,只有出现真实消费方时才单独设计 deadline。 | CLI 不提供 `--output-schema v1`。Codex/Claude 同类参数表达的是调用方提供的 JSON Schema,用于约束最终模型 响应,不是协议版本选择;如未来支持,应复用该语义并独立设计,不能借此重定义事件 envelope。 +#### 与公开 Agent SDK 的关系 + +Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付形态不同。本文件只保留 CLI 约束: + +- `bitfun exec` 面向 shell、CI 和一次性任务,使用 stdin/stdout/stderr、退出码与 `text/json/stream-json`。 +- `bitfun` 不承载隐藏 SDK Host 子命令,也不依赖 SDK Host 协议;独立 `bitfun-sdk-host` app 与 CLI + 分别选择 SDK/CLI profile 和 submission source,只复用同一 Runtime owner,以及由共享产品事实生成的等价能力集合。 +- CLI 不在进程内执行 Python/TypeScript Tool、Permission 或 Hook callback。 +- 公开 SDK 不解析 `stream-json` 作为正式双向协议;它通过版本化 SDK Host 获得 callback 与连接生命周期。 +- 两者的能力对照、共同 fixture 和等价门槛以 + [Agent SDK 产品与宿主架构第 8 节](agent-sdk-product-architecture.md#8-headless-cli-与-agent-sdk)为唯一事实源。 + #### 管理与诊断 CLI-P1 应统一以下命令的文本和结构化只读视图: @@ -267,10 +294,13 @@ TUI renderer、实验性接口和完整外部 Server 协议按总矩阵明确降 | 脚本执行服务 | 物理进程健康、资源预算、进程树与句柄、类型化脚本请求和回收 | 决定工具权限、业务结果、TUI 或品牌资源 | | 生态配置适配器 | 解析受支持外部格式并生成导入候选/诊断 | 直接写运行时配置、读取密钥、决定最终权限 | -CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Runtime SDK 的类型化端口完成;TUI 只把 -`SessionTranscript` 投影为本地渲染状态,不再消费 Core `Message`。Peer Host 的对话提交、精确取消、会话模型更新和 -工具确认/拒绝通过 SDK 回到 Core owner;账户同步、会话分支、用量、快照及其他未覆盖操作仍使用经过审查的 Core -compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 +CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Rust Runtime SDK 的类型化端口完成;TUI 只把 +`SessionTranscript` 投影为本地渲染状态,不再消费 Core `Message`。Peer Host 的对话提交、精确取消、基础会话控制、thread-goal 查询、会话模型更新和 +工具确认/拒绝通过 Agent Runtime API 回到 Core owner;本地会话分支通过显式本地范围的内部端口完成,携带远程身份的请求返回类型化 +`NotAvailable`,本轮不扩展远程分支。TUI 用量卡片通过固定语义的完成态本地命令轮次端口持久化,不暴露通用 transcript writer。 +本地工作区快照准备、会话文件清单、类型化统计和工作区文件回滚通过 `runtime-ports` 中不属于公开 SDK 的窄 owner port 完成, +由 Desktop 和 Peer Host 分别投影现有协议;Desktop 保留既有远程空结果,Peer Host 返回明确不支持错误,远程请求都不进入本地实现。快照记录、持久化、事件、历史截断与维护编排仍在原 owner。 +账户同步、富历史及其他未覆盖操作继续使用经过审查的 Core compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 这是一条垂直链路迁移,不是删除整个兼容门面或新建 CLI 专用服务层。 Runtime Configuration Service 当前由 `bitfun-core/service/config` 负责。在经评审的 port/provider @@ -359,7 +389,7 @@ Plugin Runtime 状态。 ### 4.6 HarmonyOS PC 原生终端产品 HarmonyOS PC 复用现有 `DeliveryProfile::Cli`、action、TUI 和 Runtime 语义;平台 target 不成为新的 Delivery -Profile。目标产物是普通用户在系统真实终端中直接执行的本地 `bitfun-cli`,不是 HAP、ArkUI/ArkWeb 终端模拟器、 +Profile。目标产物是普通用户在系统真实终端中直接执行的本地 `bitfun`,不是 HAP、ArkUI/ArkWeb 终端模拟器、 `hdc shell` 工具或现有 HarmonyOS 手机 Remote App。 问题清单、风险和旧设计闭环统一见[平台规约](platform-portability-design.md)。具体鸿蒙化工作、OpenCode 平台资格、 @@ -453,8 +483,9 @@ Plugin/Tool、可执行 Skill/Command、MCP/LSP/Formatter、远程 Reference 等 规则文件优先复用项目已有文件,不复制出第二份内容。若不同生态规则冲突,导入报告必须展示目标文件、 优先级和冲突段,不能自动拼接。 -现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现需要补来源身份、全局/项目作用域、自动应用偏好、 -变化监听和可见性测试。OpenCode 兼容来源继续直接发现官方目录;Codex/Claude 是否增加新的持续来源另行决定。 +现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现已经保留来源身份和全局/项目作用域,并在 GUI/TUI +展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。变化监听与可见性测试仍需 +后续补齐。OpenCode 兼容来源继续直接发现官方目录;Codex/Claude 是否增加新的持续来源另行决定。 Skill 说明和索引可按 L1 处理,脚本、URL 和外部依赖按 L2 确认;显式导入仍不得复制凭据值。MCP 启用状态按 OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示。 @@ -472,9 +503,9 @@ OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示 | 能力 | 入口 | 说明 | |---|---|---| -| 外部 ACP 智能体 | `bitfun-cli acp ...` | 启动外部智能体进程并通过 ACP 协作。 | -| 配置兼容与导入 | 启动时持续兼容来源;`bitfun-cli config import ...` 显式迁移 | 前者后台发现并按风险应用/确认 OpenCode 来源,后者写入 BitFun 原生配置;两者都不在解析线程执行插件。 | -| 运行时插件 | `bitfun-cli plugins ...` | 当前只提供 BitFun 专用包的来源、启用和静态工具名称预览;目标直接运行 OpenCode 插件。 | +| 外部 ACP 智能体 | `bitfun acp ...` | 启动外部智能体进程并通过 ACP 协作。 | +| 配置兼容与导入 | 启动时持续兼容来源;`bitfun config import ...` 显式迁移 | 前者后台发现并按风险应用/确认 OpenCode 来源,后者写入 BitFun 原生配置;两者都不在解析线程执行插件。 | +| 运行时插件 | `bitfun plugins ...` | 当前只提供 BitFun 专用包的来源、启用和静态工具名称预览;目标直接运行 OpenCode 插件。 | 三者不能共享“已安装/已启用”状态,也不能互相推导来源确认或运行权限。 @@ -601,9 +632,9 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes 通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行 `cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime`。Linux 启动页 PTY 生命周期冒烟随独立 CLI -测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;发布归档在上传前完成 SHA-256 与解压执行 -验证。真实模型进程级交互、真实 PTY 中的 Chat 活动 turn resize 与故障进程矩阵仍按对应切片补入门禁,不能由序列化单测或基础冒烟 -测试代替。 +测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;Windows x64、macOS 和 Linux 的原生发布归档 +在上传前完成 SHA-256、双入口和解压执行验证。真实供应商模型进程级交互、macOS 活动 PTY 与 OS 级终端故障进程矩阵仍按对应切片补入门禁;Linux PTY +与 Windows ConPTY 的 Chat 活动 turn resize/取消及 `exec` Ctrl+C 已由本地确定性流式模型夹具覆盖,不能用它替代上述验收。 ### 10.2 阶段退出条件 diff --git a/docs/architecture/deep-review.md b/docs/architecture/deep-review.md index 36e16f80b1..e5c872dd7d 100644 --- a/docs/architecture/deep-review.md +++ b/docs/architecture/deep-review.md @@ -30,15 +30,10 @@ The backend does not resolve the review target or build the launch manifest. The `src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs` defines read-only reviewer agents: -- `ReviewBusinessLogic` -- `ReviewGeneral` (internal managed-batch worker) -- `ReviewPerformance` -- `ReviewSecurity` -- `ReviewArchitecture` -- `ReviewFrontend` +- `ReviewWorker` - `ReviewJudge` -These agents form an optional specialist pool, not mandatory coverage lanes. A new strict run may launch at most one specialist for a concrete uncertainty. The existing generic Git exposure remains for legacy compatibility, but it is not authorized as prepared changed-code evidence. Prepared `GetFileDiff` is the source of truth for changed code; when the local binding is `matching_clean`, existing Read/Grep/Glob/LS tools may supplement it with repository context. `ReviewJudge` is a conditional quality check used only for a high-severity finding, conflicting evidence, or a materially low-confidence conclusion; it does not perform a full independent review pass. +`ReviewWorker` is an optional capability, not a fixed domain lane. The owning `DeepReview` agent selects a concrete lens from the actual change or the user's requested focus, then supplies the exact question, scope, and evidence expectation in the launch prompt. A new strict run may launch at most one such worker for a concrete uncertainty. The retired `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, and `ReviewGeneral` ids remain non-discoverable compatibility aliases for stored configuration, historical manifests, and their direct task invocations; they resolve to `ReviewWorker` under the same DeepReview visibility, manifest, read-only, and budget gates, but are not registered or emitted for new runs. The existing generic Git exposure remains for legacy compatibility, but it is not authorized as prepared changed-code evidence. Prepared `GetFileDiff` is the source of truth for changed code; when the local binding is `matching_clean`, existing Read/Grep/Glob/LS tools may supplement it with repository context. `ReviewJudge` is a conditional quality check used only for a high-severity finding, conflicting evidence, or a materially low-confidence conclusion; it does not perform a full independent review pass. `ReviewFixer` is the separate writable remediation identity. DeepReview runtime policy rejects it during review execution. The frontend action surface invokes it only after user approval, and a new read-only Review run checks the fix when requested. @@ -83,6 +78,8 @@ An explicit, complete Git range with a matching clean workspace or a provider PR Prepared Review target evidence uses bounded `GetFileDiff` pages as changed-code evidence. Local ranges read exact Git revisions; PR targets read provider diffs on demand and revalidate base/head before each file. The parent Review has a 240,000-character aggregate allowance and admits at most 128 provider diff acquisitions before provider I/O; one acquisition normally performs one file-page request and one detail request. Repeating the same page for the same reviewer returns a compact already-served result instead of the diff again. Exhaustion and stale target bindings return structured limited evidence. Existing generic Git exposure remains for legacy compatibility but does not authorize ref guessing or scope widening; Read/Grep/Glob/LS are supplemental only for a matching clean Git-range binding, never for a provider-only PR target. +Local Review `Read` calls also keep a session-scoped, metadata-only receipt of returned line ranges keyed by logical path, nanosecond mtime, byte length, and a streamed SHA-256 content digest. A fully covered repeat on the unchanged revision returns a compact already-served result; changed files, remote workspaces, tail reads, partial overlaps, and non-Review agents continue through the normal read path. Digest work is restricted to receipt-enabled Review agents. Replacing or compacting model context clears these receipts so the runtime never suppresses content that is no longer present in context. + Deleted, renamed, binary, oversized, conflicted, or unavailable files remain visible as coverage facts. The PR panel is the only built-in PR Review entry and associates progress/results by provider repository, PR id, and immutable revisions. Cached overview data is display-only until the selected PR is revalidated; revision or runtime-evidence changes make prior results stale, and failed or unavailable results remain distinct from limited coverage. The implementation does not add automatic checkout, reviewer command execution, speculative cache plans, automatic Review, inline comments, approval, merge, or automatic publishing. ## Strict Review Delegation Policy @@ -115,7 +112,7 @@ For new strict launches: For managed large L1 launches: -- `workPackets` contains only deterministic `ReviewGeneral` file batches; +- `workPackets` contains only deterministic `ReviewWorker` file batches; - packet calls are foreground-waited and may never be converted to background `Task` calls; - `managedReviewPlan` records total, planned, and deferred file counts plus batch, concurrency, and timeout bounds; - the final report must mark deferred, provider-omitted, timed-out, or unavailable scope as limited coverage; @@ -133,13 +130,13 @@ Review launches start directly without routine confirmation. Exceptional states ## Managed Work Packets and Historical Compatibility -New strict reviews do not generate work packets or module-aware reviewer shards. New managed large L1 reviews generate only bounded `ReviewGeneral` packets. Stored manifests may also contain historical reviewer/judge packets, launch batches, packet ids, assigned scopes, and retry metadata. Runtime parsing, report enrichment, recovery UI, and target-evidence validation distinguish the new managed plan from historical manifests. +New strict reviews do not generate work packets or module-aware reviewer shards. New managed large L1 reviews generate only bounded `ReviewWorker` packets. Stored manifests may also contain historical fixed reviewer ids, reviewer/judge packets, launch batches, packet ids, assigned scopes, and retry metadata. Runtime parsing, report enrichment, recovery UI, and target-evidence validation distinguish the new managed plan from historical manifests. Packet support is not a general fan-out policy. New packets are admitted only when `managedReviewPlan` is present; strict specialist policy remains unchanged. Packet-specific queue and retry behavior applies only when the prepared manifest actually contains those packets. ## Backend Policy and Admission -`DeepReviewExecutionPolicy` parses runtime policy and the per-turn specialist-call ceiling. `DeepReviewRunManifestGate` admits specialist-pool members, the optional `ReviewJudge`, and `ReviewGeneral` only when it is named by a prepared managed packet. It rejects `ReviewFixer`, nested `DeepReview`, skipped members, and unconfigured agents. +`DeepReviewExecutionPolicy` parses runtime policy and the per-turn specialist-call ceiling. `DeepReviewRunManifestGate` admits the dynamic `ReviewWorker`, explicitly configured custom specialists, and the optional `ReviewJudge`; worker packets require a prepared bounded managed plan. It rejects `ReviewFixer`, nested `DeepReview`, skipped members, and unconfigured agents. `DeepReviewBudgetTracker` separately permits at most one initial specialist and one Judge call for a new strict turn. This keeps the safety boundary deterministic without hard-coding which domain deserves delegation. diff --git a/docs/architecture/extensions/capability-runtime-integration-design.md b/docs/architecture/extensions/capability-runtime-integration-design.md index 4c405f9b60..c54e4baeba 100644 --- a/docs/architecture/extensions/capability-runtime-integration-design.md +++ b/docs/architecture/extensions/capability-runtime-integration-design.md @@ -1,14 +1,15 @@ -# BitFun 能力装配、SDK 与外部宿主集成设计 +# BitFun 能力装配、导入导出与外部宿主集成设计 本文定义 BitFun 如何把记忆、上下文、工作流、Subagent、工具和调度策略做成可装配能力,以及这些能力如何在 不修改外部产品内核的前提下接入 OpenCode、Claude Code、Codex、Trae 等宿主。本文同时约束反向路径:外部 配置、插件和能力如何进入 BitFun。 -仓库级依赖方向、接口切面和产品形态以[产品运行时架构](../product-architecture.md)为准;Agent Runtime SDK、 +仓库级依赖方向、接口切面和产品形态以[产品运行时架构](../product-architecture.md)为准;共享 Agent Runtime API、 运行时服务、工具和工作流归属见[智能体内核与运行时服务](../agent-runtime-services-design.md);第三方进程可靠性见 [插件运行时主机](plugin-runtime-host-design.md);外部来源的发现、确认和产品体验见 [外部 AI 工作内容](external-ai-work-sources-design.md);OpenCode 的具体兼容承诺见 -[OpenCode 扩展兼容总览](opencode-extension-compatibility.md)。 +[OpenCode 扩展兼容总览](opencode-extension-compatibility.md)。公开 BitFun Agent SDK 的产品心智、SDK Host 和 +各入口关系见[Agent SDK 产品与宿主架构](../agent-sdk-product-architecture.md)。 本文是目标设计和演进约束,不表示已存在一个通用 `CapabilityRuntime` crate、稳定公共 SDK、跨宿主插件包或下文 所有 Provider 接口。只有真实消费方、独立版本边界和端到端验证同时成立的接口才能进入公开面。 @@ -19,7 +20,8 @@ BitFun 采用“一个能力核心,多种宿主适配”的方向,而不是 1. **BitFun 内部装配**:产品组装选择已编译能力、Provider/factory 和能力上限;运行时能力 owner 消费不可变静态组装结果,并维护自己的不可变 Resolution Generation。 2. **外部能力进入 BitFun**:生态 adapter 保留外部来源、顺序和行为,再转换成 BitFun 能力专属贡献。 -3. **BitFun 能力进入外部产品**:对外能力门面暴露窄用例,MCP、Skill、Plugin、Hook 或 SDK adapter 再映射到具体宿主。 +3. **BitFun 能力进入外部产品**:对外能力门面暴露窄用例,MCP、Skill、Plugin 或 Hook adapter 再映射到具体宿主; + 这条“能力导出”路径不同于使用公开 BitFun Agent SDK 构建完整 Agent 应用。 4. **以外部 Runtime 组装新产品**:Claude Agent SDK、Codex App Server、OpenCode Server 等可成为新产品的执行内核, 但这不等于替换原 Claude Code、Codex 或 OpenCode 产品中的内核模块。 @@ -41,6 +43,24 @@ BitFun 采用“一个能力核心,多种宿主适配”的方向,而不是 - 不复制完整 OpenCode Server、Claude Code 或 Codex 产品协议来证明插件兼容。 - 不为了覆盖竞品矩阵同时实现全部 Memory、Workflow、Hook、Subagent、Server 和 Remote 能力。 +### 当前 Hook 合同切片边界 + +本 PR 只为现有 managed-package `.js` / `.ts` 只读 source projection 增加 OpenCode tool Hook 的 typed 静态映射;不新增 +TypeScript bridge/runtime,不执行 TypeScript,不新增插件发现、加载、激活或 dispatch 路径。adapter 复用 workspace 已锁定的 +OXC parse-only profile,只从受 managed-package 文件大小与数量限制的固定 source bytes 中提取 exported plugin return object 的静态顶层 +属性名;OXC 不解释 OpenCode 语义,OpenCode adapter 只映射明确的 `tool.execute.before` / `tool.execute.after`。解析在 package +load 时每份源码执行一次,read projection 不重复解析,动态属性、spread 内容和普通字符串引用均不推断为 Hook。映射结果只通过既有 +`read_plugins` 诊断链声明 `tool.execute.before` / `tool.execute.after` 已被静态识别、风险事实仍不完整且执行不可用; +`@opencode-ai/plugin` `Hooks` 接口中其他静态顶层属性继续使用通用 projection-only 诊断,`tool` 则由既有 custom-tool +projection 负责;event-bus 的具体事件类型只属于顶层 `event` Hook 的 payload,不作为 Hook 属性。OXC 解析失败使用稳定的 +`opencode.hook_projection_parse_failed` 诊断,不等同于“没有 Hook”,也不猜测贡献。Hook runtime、顺序解析、启停策略、 +产品 UI 与 metric backend 均延期。 +OpenCode 映射只接受 `opencode.plugins` 来源,异常和诊断使用稳定低基数代码;source、稳定 contribution 和既有 +`PluginSourceRef.content_hash` 保留审计关联身份,不作为 metric label。纯映射函数自身不记录日志,既有只读投影负责 +用户可见诊断,后续 runtime owner 再统一负责脱敏日志与指标投影,避免在 adapter/DTO 层形成第二套观测链路。 +当前公共合同只保留该生产投影实际消费的 identity、Hook point 与 safety facts;不预置 resolver、terminal decision、 +排序、冲突或 override 公共面,也不使用 dead module 或测试替身宣称产品能力。 + ## 2. 能力分类与可替换边界 “模块可拆卸”不等于“所有事实都可以被替换”。能力按下表分为实现、策略、贡献和内核事实: @@ -62,7 +82,8 @@ BitFun 采用“一个能力核心,多种宿主适配”的方向,而不是 |---|---|---| | `exclusive` | 主 Session Store、最终 Compactor 等只能有一个 active owner 的能力 | 组装时选出一个 Provider;运行时不允许两个实现双写。 | | `ordered-chain` | Context Transformer、Prompt/Tool Hook、验证器 | 顺序由能力 owner 或生态 adapter 明确;每步校验,失败策略类型化。 | -| `namespace-union` | Tools、Skills、Commands、Agents | 先按来源限定身份保留候选,再按名称和作用域解析;同名不静默跨生态覆盖。 | +| `namespace-union` | Tools、Commands、Agents | 先按来源限定身份保留候选,再按名称和作用域解析;同名不静默跨生态覆盖。冲突界面先列 BitFun、再按稳定 provider 身份列其他生态,但展示顺序不自动决定胜者。 | +| `ordered-namespace` | 现有 Skill 根 | 保留来源限定身份并按 Skill Registry 已发布的根顺序解析同名项;被覆盖项继续可见。来源元数据只用于解释结果,不参与重新排序。 | | `fallback` | Memory Retriever、模型 Provider、外部服务 | 只对声明为可恢复的错误切换;权限拒绝、取消和副作用不自动 fallback。 | | `fan-out` | 只读事件 Observer、运维遥测 | Observer 互相隔离;不能阻塞或改变权威业务结果。 | @@ -100,7 +121,7 @@ flowchart LR subgraph Export["BitFun 能力进入外部宿主"] Facade["对外能力门面"] - HostAdapter["MCP / Skill / Plugin / Hook / SDK Adapter"] + HostAdapter["MCP / Skill / Plugin / Hook Adapter"] Host["OpenCode / Claude Code / Codex / Trae"] Providers --> Facade --> HostAdapter --> Host end @@ -109,8 +130,10 @@ flowchart LR 上图是职责关系,不要求新增一个大而全的运行时服务。对应职责继续分布在现有 owner: -BitFun 自身 GUI、TUI/CLI、Server、Remote 和 Agent Runtime SDK 继续消费产品组装后的能力服务、只读视图或 -Runtime 接口,不经过对外能力门面。只有某个具体 DTO 同时出现真实内外部消费者并满足独立版本要求时,才评审 +当前 GUI、TUI/CLI、Server 和 Remote 通过各自 adapter 消费产品组装后的 Agent Runtime API、能力服务、只读视图或 +Runtime 接口,不经过对外能力门面,也不经过公开语言 SDK package。公开 SDK 尚未交付;目标态由 SDK Host adapter +调用同一 Agent Runtime API,而不是让现有入口改为依赖 Python/TypeScript package。 +只有某个具体 DTO 同时出现真实内外部消费者并满足独立版本要求时,才评审 共享该 DTO;不能让内部入口与外部宿主共享整个 facade。 | 部分 | 负责 | 不负责 | @@ -123,8 +146,26 @@ Runtime 接口,不经过对外能力门面。只有某个具体 DTO 同时出 | 对外能力门面 | 暴露真实消费者需要的窄用例、只读状态、事件和类型化错误 | 暴露内部 manager、插件 Host ABI、任意服务查找或产品 UI。 | | Host Adapter | 把门面映射为某宿主的 MCP、Skill、Plugin、Hook、SDK 或 Server 调用 | 声称突破宿主未提供的生命周期、状态或替换能力。 | -对外能力门面不等于 Agent Runtime SDK 的全部接口。SDK 可以包含构建与运行 Agent 所需的底层能力;宿主 adapter -只消费当前场景需要的最小子集。外部产品只需要调用一个 BitFun workflow 时,不应被迫嵌入完整 Agent Runtime。 +当前外部 Subagent 输入切片落实了上述边界:`contracts/product-domains` 只定义来源无关的 Subagent contribution、 +provenance、兼容状态、摘要和冲突契约;OpenCode adapter 独立维护本生态来源与字段语义;生命周期协调器隔离 provider +失败并发布不可变候选;现有 AgentRegistry/Task owner 再解析模型、工具、权限上限和同名路由。产品主体不读取 +OpenCode 类型,也不通过统一 agent JSON 理解未来 Codex/Claude Code。 + +fresh external invocation 在 admission 前绑定逻辑名到 runtime generation,并把 generation lease 传入前台或后台 +调度请求。来源更新或撤下只改变后续 admission,不修改已接受调用的 prompt/model/tool 绑定;安全策略收紧仍由现有 +owner 按原规则优先执行。当前外部 Subagent 不支持 session follow-up,结果与管理 surface 必须明确标为 single-run, +不能用持久化 session 绕过重新审批或 generation 解析。 + +当前外部来源控制切片进一步落实了内部宿主共享边界:Command、Tool、Subagent、MCP 的 payload、审批与冲突仍归各自 +owner,`ExternalSourceControlPlane` 只收敛 bounded discovery、provider 隔离和 generation fencing; +`ExternalSourceControlSnapshotV1` 则只投影 discovery/desired/review/runtime/support、Host 能力、诊断和恢复动作。 +Desktop、TUI 和 Peer 发送同一闭合控制动作,Server 只返回相同 DTO 的 read-only 投影。该 DTO 是当前产品宿主契约, +不是公共插件 SDK;没有独立仓库外消费方和版本策略前,不向 Host Adapter facade 扩张。 + +对外能力门面不等于公开 BitFun Agent SDK。前者只为一个外部宿主暴露当前场景需要的最小能力子集;后者提供 +`query()`、Session、Tool/MCP、Permission、Hook 和 Event/Result 等完整 Agent 应用心智,并通过 SDK Host +调用同一 Agent Runtime API。外部产品只需要调用一个 BitFun workflow 时,不应被迫嵌入完整 Agent Runtime +或公开 SDK。 ### 3.1 宿主 adapter 的产品交付生命周期 @@ -269,6 +310,9 @@ Host Adapter 必须把宿主 `AbortSignal`、turn interruption 或 session stop 内容版本、执行域或权限包络变化后重新求值。产品保护项只限身份、数据隔离、权限入口、故障恢复、升级/卸载完整性 和法律要求,不能把所有内置能力设成不可覆盖。 +同名候选在 GUI/TUI 中固定先展示 BitFun 来源,其余生态按稳定 `provider_id` 排序,同一生态内部沿用 adapter 的 +正式来源顺序。这个顺序只用于减少阅读成本;用户未选择时仍保持冲突未决,不能把“BitFun 排在第一”误实现为静默激活。 + ## 7. 权限、信任与执行边界 权限检查分成五个不同阶段: @@ -315,6 +359,10 @@ Host Adapter 必须把宿主 `AbortSignal`、turn interruption 或 session stop - outcome、类型化错误、是否可重试、是否可能已产生副作用。 - payload 或 artifact reference,以及隐私/脱敏分类。 +外部来源控制错误已经统一提供 `code/stage/retryable/correlation/causation/recoveryActions`,供 GUI、TUI、 +Peer 和 Server 做一致映射;用户文案仍归各宿主,不能解析 `detail` 控制流程。当前实现只使用有界结构化日志,不据此 +宣称公共事件或完整产品分析打点已经冻结;后续指标应复用 operation/generation/stage 身份叠加 observer,不能另建状态 owner。 + 投递按 at-least-once 和可去重设计;不承诺跨进程、网络和第三方宿主 exactly-once。Observer 失败只影响观测,不 回滚业务结果。外部宿主事件转换必须记录 loss/degradation,例如“宿主没有 queue-wait 事件”或“只能观察 PostToolUse,无法观察 admission”。 @@ -354,22 +402,27 @@ adapter 一律保持 `experimental/unsupported`,不能据本表标记产品可 | 宿主 | 不改内核入口 | Memory / Context / 压缩上限 | Workflow / Subagent 上限 | Tool / Hook 上限 | 当前证据与稳妥结论 | |---|---|---|---|---|---| | Claude Code | Plugin、Skill、Agent、Hook、MCP | `degraded`:可在生命周期注入上下文、观察压缩,不能替换历史或 Compactor owner | `translated`:可定义 Agent/Subagent 和 Hook,不能替换 Agent Loop 或全局调度 | `translated`:可增加工具并按 Hook 规则修改/拒绝;多个匹配 Hook 并行,最终权限仍属宿主 | 官方滚动文档核对;完整 Provider 替换 `unsupported`,父子权限、Hook 并发和 transcript/文件系统 fork 差异需冻结样例。[Hooks](https://code.claude.com/docs/en/hooks) | -| Codex | Plugin、Skill、Hook、MCP | `degraded`:`SessionStart`/`UserPromptSubmit` 可增加上下文;Compact Hook 只能观察或阻止当前压缩,不能替换历史或 Compactor owner | `translated`:支持 Subagent 和生命周期 Hook,不能通过普通插件替换调度内核 | `degraded`:MCP 可增加工具;Hook 对 Bash、`apply_patch` 和 MCP 提供有限拦截,不是全部内置 Tool 的 enforcement boundary;多个匹配命令 Hook 并发 | 官方滚动文档核对;现有产品扩展以有限生命周期拦截为上限,完整 Provider/权限替换 `unsupported`。[Hooks](https://learn.chatgpt.com/docs/hooks) | -| OpenCode | 配置、Agent、Skill、Plugin、Custom Tool、MCP | `degraded`:Plugin 可变换消息/参数,实验 Hook 可影响压缩 Prompt,不能替换会话内核 | `translated`:Primary/Subagent/Task/子会话可组合,插件不能替换 scheduler | `translated`:Hook 顺序执行;同名插件 Tool 可覆盖内置 Tool,是局部正式替换点 | 具体冻结版本、提交和样例见 [OpenCode 专项文档](opencode-extension-compatibility.md#1-基线与判断方法);风险是实验 Hook 漂移、加载顺序、Bun/npm 供应链和任意脚本副作用。[Plugins](https://opencode.ai/docs/plugins/) | +| Codex | Plugin、Skill、Hook、MCP | `degraded`:`SessionStart`/`UserPromptSubmit` 可增加上下文;Compact Hook 只能观察或阻止当前压缩,不能替换历史或 Compactor owner | `translated`:支持 Subagent 和生命周期 Hook,不能通过普通插件替换调度内核 | `degraded`:MCP 可增加工具;Hook 对 Bash、`apply_patch` 和 MCP 提供有限拦截,不是全部内置 Tool 的 enforcement boundary;多个匹配命令 Hook 并发 | 官方滚动文档核对;现有产品扩展以有限生命周期拦截为上限,完整 Provider/权限替换 `unsupported`。[Hooks](https://github.com/openai/codex/blob/main/docs/hooks.md) | +| OpenCode | 配置、Agent、Skill、Plugin、Custom Tool、MCP | `degraded`:Plugin 可变换消息/参数,实验 Hook 可影响压缩 Prompt,不能替换会话内核 | `translated`:Primary/Subagent/Task/子会话可组合,插件不能替换 scheduler | `translated`:Hook 顺序执行;同名插件 Tool 可覆盖内置 Tool,是局部正式替换点 | 具体冻结版本、提交和样例见 [OpenCode 专项文档](opencode-extension-compatibility.md#1-基线与判断方法);风险是实验 Hook 漂移、加载顺序、Bun/Node/npm 供应链和任意脚本副作用。[Plugins](https://opencode.ai/docs/plugins/) | | Trae | Rules、Skills、Memory、Custom Agent、MCP、Hooks | `degraded`:可贡献上下文,尚无公开稳定的压缩/Context Provider 契约 | `translated` 候选:Custom Agent 可独立或作为 Subagent;调度和任务状态仍由宿主持有 | `experimental`:MCP 可调用;2026-06 新增 Hooks,但公开事件、顺序和错误契约不足 | 官方滚动 Changelog 核对,尚无冻结版本样例;近期只评估 Rules/Skill/MCP/Custom Agent,Hook 深适配及状态/压缩/调度替换保持 `unsupported`。[Changelog](https://www.trae.ai/changelog) | -### 9.2 使用 SDK/Server 组装新的 BitFun 宿主 +### 9.2 使用其他产品的 SDK/Server 组装新宿主 | Runtime 控制面 | 可获得的控制 | 不能据此宣称 | |---|---|---| -| Claude Agent SDK | session、外部 Session Store、Hook、Subagent、取消和 OTel 等嵌入式能力 | 已替换 Claude Code 产品内核,或其 transcript/文件系统 fork 与 BitFun 等价。[Session Store](https://code.claude.com/docs/en/agent-sdk/session-storage)、[Observability](https://code.claude.com/docs/en/agent-sdk/observability) | -| Codex App Server / SDK | thread/turn/item、fork/resume/cancel、审批和事件;schema 随版本生成 | 已在现有 Codex 产品中替换 Memory、Compactor、Scheduler 或 Tool owner;WebSocket 仍是实验且不受支持的传输。[App Server](https://learn.chatgpt.com/docs/app-server)、[SDK](https://learn.chatgpt.com/docs/codex-sdk) | +| Claude Agent SDK | `query()`、session、外部 Session Store、函数 Tool、Permission、Hook、Subagent、MCP、取消和 OTel 等嵌入式能力 | 已替换 Claude Code 产品内核,或其 transcript/文件系统 fork 与 BitFun 等价。[Overview](https://code.claude.com/docs/en/agent-sdk/overview)、[Session Store](https://code.claude.com/docs/en/agent-sdk/session-storage)、[Observability](https://code.claude.com/docs/en/agent-sdk/observability) | +| Codex App Server / SDK | thread/turn/item、fork/resume/cancel、审批和事件;本地 Runtime 与 schema 随版本绑定 | 已在现有 Codex 产品中替换 Memory、Compactor、Scheduler 或 Tool owner;App Server 的实验传输/方法也不自动成为稳定能力。[App Server](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md)、[TypeScript SDK](https://github.com/openai/codex/blob/main/sdk/typescript/README.md)、[Python SDK](https://github.com/openai/codex/tree/main/sdk/python) | | OpenCode Server / SDK | HTTP/OpenAPI、Session API、Abort 和 SSE,可用于新客户端控制面 | Plugin 已拥有内部队列/调度,或 Server 暴露可跳过认证和网络隔离。[SDK](https://opencode.ai/docs/sdk/)、[Server](https://opencode.ai/docs/server/) | | Trae | 尚无已验证的同等级通用 Agent Server/SDK | 在公开稳定控制面出现前,不进入新宿主控制面的交付承诺。 | 两张表分别验收,不能用 SDK/Server 的控制能力抬高现有产品插件覆盖率。无论哪种路径,都不使用“覆盖竞品完整 能力”描述部分 Hook 或 MCP 集成。 +BitFun 自身公开 SDK 不直接选择上述三种形态之一:公开心智采用 Claude Agent SDK 已形成的 +`query/session/tool/MCP/permission/hook` 模型;本地宿主采用 Codex App Server 式的版本、schema、双向请求、 +能力协商和背压纪律;默认 managed SDK Host/显式 client-only 选择参考 OpenCode。最终结构和等价门槛以 +[`agent-sdk-product-architecture.md`](../agent-sdk-product-architecture.md) 为准。 + ## 10. 产品体验要求 1. **不阻塞正常工作**:发现、准备、兼容检查和无关待确认项在后台进行;只有当前操作真正依赖待确认能力时返回 @@ -399,13 +452,14 @@ adapter 一律保持 `experimental/unsupported`,不能据本表标记产品可 | A1 内部装配 | 已有 owner 和第二个真实实现 | 只为一个能力增加一个 Slot,证明不可变 Resolution Generation、状态和 fallback | 同时拆 Memory/Context/Workflow/Subagent/Scheduler | 两个真实 Provider 可替换,入口行为等价,只有一个状态 owner。 | | B1 能力导出 | 具名试点消费者、具体用例、验收 owner、冻结宿主版本 | 一个窄用例通过 MCP/Skill/sidecar 或最小 adapter 接入一个宿主,并完成安装到卸载闭环 | 完整 SDK、全部宿主插件、通用 Hook ABI | 仓库外消费者真实使用;注册、启停、调用、权限、取消、事件、降级、卸载和恢复均有端到端证据。 | | B2 生命周期导出 | B1 的真实用例被一个生命周期缺口阻塞 | 只增加该用例需要的一个 Host Hook/Plugin adapter | 全事件镜像、多个宿主并行铺开 | 宿主顺序、失败、并发、权限合并和卸载残留有冻结样例;适配损失可查询。 | -| C1 外部能力导入 | 既有 OpenCode 专项前置条件 | 完成一个真实 standalone Tool,再按阻塞样例增加 package/Hook | 全量 config、TUI renderer、Server、Remote plugin | 一个外部能力完成发现、确认、执行、取消、故障、更新和 UX 闭环。 | -| D1 SDK 发布 | 已有非 `bitfun-core` 嵌入方真实使用 B1 或现有 Runtime 能力 | 为已被消费的最小 API 冻结版本和迁移策略 | 发布 `product-full`、manager、Host ABI 或空 profile | 最小依赖、兼容测试、示例、升级路径和独立版本边界同时成立。 | +| C1 外部能力导入 | 既有 OpenCode 专项前置条件 | 已完成 Prompt Command、standalone Tool、Subagent、MCP 的真实纵向切片与跨产品宿主控制面;下一步只按阻塞样例增加 package/Hook | 全量 config、TUI renderer、OpenCode Server、SSH Remote plugin | 一个外部能力完成发现、确认、执行、取消、故障、更新和 UX 闭环,且 Desktop/TUI/Peer 不出现第二状态 owner。 | +| D1 SDK 发布 | 已有具名 design partner、冻结的 Session/Turn fixture,且 Agent Runtime owner 明确 | 按 Agent SDK 专题的最多三个纵向 PR 建立 SDK Host、`query()`/Session 和 callback 等价闭环 | 发布 `product-full`、manager、Plugin Host ABI、内部 port 或空 profile | 冻结 Claude Agent SDK 对比版本的稳定核心能力无静默缺口;两个语言包、匹配 SDK Host、升级路径和仓库外消费者同时成立。 | | X1 单项扩大 | 对应轨道前一切片稳定且有新真实需求 | 每次只增加一个宿主或一个能力类别 | 追求矩阵全绿 | 新切片通过自己的兼容、产品、生命周期和故障验收。 | -近期唯一已承诺实现方向是 C1 的 OpenCode Prompt Command / standalone Tool 纵向路线。A1、B1 和 D1 在各自 -启动门槛满足前只允许收集具名需求和冻结证据,不创建公共接口或并行铺开;Trae 在公开稳定 Hook/SDK 契约不足时 -继续保持研究状态。 +C1 的 OpenCode Hook/插件纵向路线与 D1 的 Agent SDK 路线可以在独立 adapter、SDK Host、语言包、文档和 +fixture 上并行;两者都必须消费同一个 Tool/MCP/Permission/Hook owner。SDK Hook callback 的最终接入等待公共 +Hook Coordinator 合并,不能先建 SDK 私有 HookBus;若两条分支同时修改 owner 合同或权威状态,停止并行并先串行 +冻结合同。A1、B1 和 D1 在各自启动门槛满足前不创建空公共接口;Trae 在公开稳定 Hook/SDK 契约不足时继续保持研究状态。 暂停扩大当前轨道的条件: diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 4257689289..c7746b9acc 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -7,12 +7,20 @@ SDK 或 Server 输出到外部宿主,以及内部 Provider Slot、状态、事 [`capability-runtime-integration-design.md`](capability-runtime-integration-design.md);两条方向共用适用的身份事实和能力 owner, 但不共用一个大一统 adapter 或状态模型。 -本文同时记录当前可用纵向切片与目标架构。当前 BitFun 已具备通用外部来源目录和生命周期协调器,并通过 -OpenCode Prompt Command 适配器接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突, -CLI/TUI 可列出并执行 prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 -预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与 CLI/TUI 使用同一决策状态。完整 -TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Subagent 仍属于后续阶段,不能因来源被识别 -就宣称已经可用。 +本文同时记录当前可用纵向切片与目标架构。当前 BitFun 已具备通用外部来源目录、四条能力专属发现通道和单一 +`ExternalSourceControlPlane` 生命周期 owner;`contracts/product-domains` 提供版本化控制事实、闭合动作与错误语义, +Desktop、交互式 TUI、Peer Host 和只读 Server 只做宿主投影,不再各自派生另一套状态机。OpenCode Prompt Command +适配器已接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突,交互式 TUI(ChatMode)可列出并执行 +prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 +预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与交互式 TUI(ChatMode)使用同一决策状态。第三条纵向 +切片已把 OpenCode 全局/项目 Subagent 的安全子集通过独立 provider 契约接入现有 Subagent owner:首次启用与 +同名冲突使用非阻塞决策,fresh 调用固定不可变 generation,更新和撤下不会静默切换到同名实现。第四条纵向切片 +已把 OpenCode 用户/项目 MCP 的 local stdio 与 HTTPS remote 安全子集接入现有 MCP owner,沿用显式审批、冲突、 +工作区隔离和失败回推;现有 Skill Registry 另行展示来源、用户/项目作用域和固定优先级产生的覆盖结果,不并入上述 +可执行来源选择规则。完整 +TypeScript/Bun、包依赖、package plugin 执行、Codex/Claude Code 运行时适配、primary agent 替换和外部 Subagent +续接仍属于后续阶段,不能因来源被识别就宣称已经可用。OpenCode、Claude Code 与 Codex 的本地 Hook 静态目录 +已作为独立只读切片接入;它只证明来源和声明能够安全展示,不证明 handler 已加载、获得权限或可以执行。 ## 1. 产品判断与竞品启示 @@ -36,7 +44,9 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub 1. 自动发现当前执行域中的用户全局、项目和工作区外部来源,不阻塞项目打开、TUI 输入或无关会话。 2. 当前能够安全消费且不存在同名冲突的低风险内容默认无感应用,并通过可撤销的非阻塞摘要说明来源和影响; - 外部能力与产品本地能力、或独立外部 provider 之间发生同名冲突时,不得静默选择胜者。 + Command、Tool、Subagent 等外部可执行能力与产品本地能力、或独立外部 provider 之间发生同名冲突时,不得 + 静默选择胜者。现有 Skill 根继续按已发布顺序解析,但必须展示来源和默认覆盖状态;带模式的管理界面再展示 + 应用模式开关后的实际采用项。 3. 插件、Hook、Command、MCP 等可执行或有外部副作用的内容先发现,首次启用或能力扩大时再由用户确认。 4. 运行中感知来源修改、升级、删除和重新出现;成功更新安全切换,失败时优雅保留仍合规的上一有效代次。 5. 用户始终能解释“发现了什么、来自哪里、当前是否生效、为何降级、下一步能做什么”。 @@ -56,7 +66,9 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub ### 3.1 首次发现 -发现始终在后台进行。Desktop、CLI/TUI 和未来 Web 入口消费同一只读来源状态,但按宿主展示: +发现始终在后台进行。Desktop、交互式 TUI(ChatMode)和 Peer 控制界面消费事实所在 Host 的同一来源状态,但按 +宿主展示;Peer 控制界面只代理 Peer Host,不读取控制端同名来源。Server 当前只提供只读快照,未来 Web 入口必须 +通过已接入的 Host 能力消费,不能由浏览器扫描来源: ```text 已发现 OpenCode 工作内容 @@ -74,6 +86,8 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub 读取凭据或主动联网;提示可以稍后处理。 - 当前阶段范围外的 TypeScript、依赖型 Tool 和 package plugin 只进入“已发现,静态预览”清单,不进入模型可调用 集合;受支持 JS Tool 也必须在明确启用前保持同一状态。 +- 外部 Subagent 即使只是声明文件,也会把 prompt、模型和工具集合带入一次独立 agent 调用,因此按当前行为与能力 + 包络确认;列表和 IPC 只显示描述、来源、模型、工具和诊断摘要,不传输 prompt 正文。 - 全局来源首次在当前执行域识别时提示一次;项目来源按工作区提示。相同全局来源不能在每个项目重复轰炸用户。 - “撤销已应用内容”对持续兼容来源表示在用户选择的当前项目或当前执行域内抑制对应来源/资产并重新计算下一 来源或产品默认;后续 watcher 更新不得绕过该偏好重新应用。该操作不写回外部文件,也不同于显式导入的字段级撤销。 @@ -84,15 +98,22 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub | 信息 | 产品要求 | |---|---| -| 来源 | 产品、规范化位置、用户全局/项目/工作区作用域、实际执行域;敏感路径默认缩略显示。 | -| 内容 | 配置、Rules、Agents、Skills、Commands、MCP、插件、工具等类别与数量。 | +| 来源 | 产品、规范化位置、用户全局/项目/工作区作用域、实际执行域;Agent 普通视图只接收 `/…`、`~/.config/…` 或 `/…` 等安全标签,不传绝对用户路径。 | +| 内容 | 配置、Rules、Agents、Skills、Commands、MCP、Hooks、插件、工具等类别与数量。 | | 状态 | 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用或不可用。 | | 变化 | 最近成功读取时间、候选摘要、已应用摘要、权限或能力变化。 | -| 操作 | 查看详情、应用/启用、按项目或执行域抑制/恢复兼容来源、停用执行 target、撤销显式导入、重新加载、低风险/代码更新改为先询问、显式导入为 BitFun 配置。 | +| 操作 | 查看详情、应用/启用、按项目或执行域抑制/恢复兼容来源、进入/退出 Safe Mode、停用执行 target、撤销显式导入、重新加载、低风险/代码更新改为先询问、显式导入为 BitFun 配置。 | 默认视图只展示用户需要处理的事项和聚合结果;文件级诊断、字段来源、依赖和执行身份进入详情。来源删除或更新 失败不要求用户阅读日志才能理解结果。 +Safe Mode 是执行域/工作区实例内的易失控制状态,不写入来源偏好,也不把来源伪装成 `disabled`。进入后继续发现和 +展示 Command、Tool、Subagent 与 MCP,但立即撤下外部 Tool、Subagent 和 MCP 的新调用路由;Prompt Command 作为 +静态模板继续可见。退出后基于当前来源 generation 重新协调,不能恢复已删除、已撤销或已过期审批的旧路由。 +GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;只读 Server 通过 +`hostCapabilities` 明确拒绝变更。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` +并重新读取,不能用界面本地状态覆盖并发进程的新决定。 + ### 3.3 兼容来源与显式导入 | 方式 | 适用场景 | 来源变化后 | 写入边界 | @@ -103,6 +124,39 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub 显式导入完成后,已选字段由 BitFun 原生配置拥有,不再同时叠加外部值;未导入内容仍可继续作为兼容来源。 插件、Tool 和 Hook 不通过配置复制获得执行资格。 +### 3.4 静态 Hook 目录 + +Hook 首先以独立、只读的 `ExternalHookCatalogSnapshotV1` 展示,而不进入可执行来源控制面。Desktop 设置页的 +“外部 AI 应用 → Hooks”和交互式 TUI 的 `/hooks` 消费同一份 Rust 快照;`/help hooks`、`/hooks -h` 与 `/hooks --help` 提供说明,不增加快捷键、 +命名空间变体或生态专用命令。`/hooks` 与其他内置命令采用同一套既有冲突策略:无冲突时使用普通命令名;发生 +同名冲突时由现有命令菜单展示来源限定项,静态 Hook 目录不增加另一套保留字或路由规则。 + +当前目录的来源与降级边界如下: + +| 生态 | 当前静态来源 | 当前可见事实 | 明确不做 | +|---|---|---|---| +| OpenCode | 用户、legacy、项目祖先 `.opencode` 与显式配置目录中的 `plugin/`、`plugins/`;相同层级的 JSON/JSONC `plugin` 声明 | 以稳定、确定的 adapter 顺序展示每个具名导出的静态对象属性、未知属性和动态注册提示;相同事件在不同具名导出中保留独立注册身份;软件包声明只显示“已声明、未解析”;显式配置目录保持原生的末级优先顺序 | 不安装依赖、不解析软件包导出、不 import JS/TS、不执行 handler;类型声明 `.d.ts` 不作为运行时插件源;不把项目根下任意 `plugin(s)/` 当成 OpenCode 目录。 | +| Claude Code | 用户 `~/.claude/settings.json`,以及从项目根到当前工作区的 `.claude/settings.json`、`.claude/settings.local.json` | Hook 事件、matcher、`command/http/mcp_tool/prompt/agent` handler 类型;先合并已观察设置层,再把有效 `disableAllHooks=true` 投影为 disabled | 不复制 managed Hook 例外或运行时信任判断;任一参与层无效或超限时不猜测有效激活状态;不传输 command、prompt、URL、server/tool 参数或其他 handler 正文。 | +| Codex | 用户与按持久 `project_root_markers` 有界的项目祖先 `hooks.json`、`config.toml`;linked worktree 的 `hooks.json` 和 `config.toml` 中整个 `[hooks]` 表均映射到主 checkout 对应目录 | 展示固定 schema 中的 Hook 事件与 command handler;非原生 prompt/agent 声明只标为 unsupported;User state/feature 仍可能被未观察的 SessionFlags 覆盖,项目声明还受 trust 约束,因此 command handler 的有效激活统一保持 unknown | 不把 Claude Code 的 matcher 或全局禁用字段套用到 Codex;不猜测插件、托管层、会话注入、state/feature 合并或 trust-gated 项目激活来源,这些未观察来源以覆盖诊断明确显示。 | + +只有语义完全一致的 `PreToolUse`/`PostToolUse` 和 OpenCode `tool.execute.before`/`tool.execute.after` 分别映射到 +BitFun 已有 `ToolBefore`/`ToolAfter` 契约。其他原生事件仍可见,但标为 `native_only`;静态分析不能安全确定的注册 +标为 `opaque`,不得猜测映射。目录 DTO 只包含 provider 身份与稳定 adapter 顺序、来源、作用域、脱敏位置、matcher 摘要、 +handler 类型、原生激活状态、覆盖投影状态和固定诊断,不包含 handler body、命令、prompt、URL、环境变量、凭据 +或任意执行 payload。`content_version` 仅指纹化这些已脱敏语义事实,原始文件字节和敏感正文不进入版本值。 + +发现按文件、目录项、软件包声明、handler 和总目录条目设置硬上限;单个文件或 provider 失败只产生 Hook 资产诊断, +健康 provider 仍然发布。刷新失败时协调器保留该 provider 的最后有效静态结果并显式标记 stale;首次失败使用独立 +provider 失败事实,避免在空目录界面中伪装成“成功但没有来源”,错误正文仍只保留在共享诊断中。Desktop 和 TUI +只按需刷新,并复用已有类型化 Discovery Lane 的 provider 级合并、超时和延后结果机制,不建立第二套 watcher、 +任务调度器或状态机。同一工作区有发现仍在运行时,后续 Desktop/TUI 刷新只读取共享 pending 快照,不再排队第二代 +发现;超时后的终态在既有 refresh gate 内原子完成、发布。Desktop 轮询同一缓存快照直至 pending 结束,TUI 在单次 +命令内等待该快照收敛。GUI 对每个 provider 的来源、条目、诊断以及目录级诊断分别使用共享分页预算,TUI 对来源、 +条目和诊断设置输出上限,避免大型目录一次挂载或打印数千项。Git/worktree 服务只提供当前 checkout 边界与主 checkout 身份;adapter +在最多 32 层的边界内解释各生态祖先规则,无法确认 Git 边界时只读取当前工作区,不向任意父目录扩散。当前只允许 +本机执行域:Remote workspace 和 Peer Device Mode 显示明确不支持,绝不 +回退读取控制端本地同名配置。Server、Mobile、SDK、ACP、Host 和任何 Hook Runtime 都不属于该切片。 + ## 4. 来源、资产与加载策略 ### 4.1 来源身份与作用域 @@ -130,6 +184,14 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub | L2 受归属模块保护的外部能力 | 可执行 Skill/Command、远程 Reference、MCP、LSP、Formatter、Provider 连接 | 发现后进入“需确认”;由真实归属模块展示命令、网络、凭据和作用域后启用。 | | L3 任意第三方代码 | JS/TS Tool、服务插件、Hook、TUI target、动态 import | 发现但不 import;首次按来源/target 启用时说明执行用户、工作目录和直接文件/网络/进程的粗粒度执行包络,不能承诺 import 前已知全部动态贡献。 | +OpenCode Subagent 属于 L2:adapter 只读取声明,不执行外部代码;激活仍需确认实际模型、工具、执行域和来源谱系。 +仅 description 等 catalog 文案变化不扩大包络,不重复询问;prompt 行为、provenance、模型或工具变化必须重新确认。 +生态 adapter 只提交类型化的模型请求,不能把 `provider/model` 等来源语法交给通用 owner 解析。Subagent owner 在审批 +前将请求物化为唯一、已启用的具体模型,并形成“配置 ID + 运行配置指纹”的不可变绑定;`inherit`、`primary`、`fast`、 +`auto`、`default` 等字符串在此绑定中都是普通配置 ID,不能再次经过模型选择器解释。无法唯一确定时保持不可用。provider、 +模型名、endpoint 或其他影响运行身份的配置在同一 ID 下变化,也会重建未来 generation 并产生新的审批决策;generation +lease 保留旧绑定事实,执行入口若发现当前配置与旧指纹不一致则安全失败,不能静默改用新配置或父会话模型。 + 确认结果分成两层:来源级加载偏好按“来源限定身份 + target + 执行域 + 更新策略”保存,并明确作用于当前项目 还是当前执行域内所有已通过来源校验的项目;项目/工作区实例只作为“有效来源图 + 粗粒度执行包络 + 已知贡献 摘要”的重新求值上下文,不形成第二个确认键。选择全局作用域后,跨项目本身不重复询问;只有新实例使执行包络、 @@ -160,6 +222,8 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub ``` 在途调用固定使用发起时的激活代次。新调用只在切换完成后使用新代次;旧代次的迟到响应和贡献引用在退出后失效。 +Subagent 通过随调度请求传递的 generation lease 实现这一点;路由撤下后有 lease 的精确运行定义保留到调用结束, +无 route 且无 lease 的旧定义立即回收。该机制不允许新调用使用已删除或已撤销的来源。 文件观察事件不是业务事实。协调器先按来源聚合连续 create/rename/write/remove 事件,并在可配置的文件稳定窗口后 重新扫描完整来源图;编辑器的原子保存不能被误判成删除后重装。用户显式停用、组织策略收紧或安全撤销不等待 @@ -186,21 +250,24 @@ flowchart LR Sources["用户全局 / 项目 / 工作区外部来源"] Adapters["同级生态适配器:OpenCode / Codex / Claude Code"] Ports["能力专属 provider 契约"] - Catalog["外部来源目录与只读状态"] + Catalog["外部来源目录"] Watch["文件观察与外部变化事实"] - Coordinator["共享生命周期协调器:候选、差异与原子替换"] + Coordinator["ExternalSourceControlPlane:四条 typed discovery lanes"] + Projection["v1 控制事实 + 公共 catalog"] Policy["激活策略与各能力 owner"] Config["Runtime Configuration Service"] Host["Plugin Runtime Host"] Owners["Command / Skill / MCP / Tool / Config / Subagent 等归属模块"] - Surface["Desktop / CLI / Web / SDK"] + Surface["Desktop / TUI / Peer / read-only Server"] Sources --> Adapters Adapters --> Ports Watch --> Coordinator Coordinator --> Ports Ports --> Catalog - Catalog --> Surface + Catalog --> Projection + Coordinator --> Projection + Projection --> Surface Coordinator --> Policy Policy --> Config Policy --> Host @@ -214,10 +281,13 @@ flowchart LR | 外部来源目录 | 聚合来源身份、作用域、资产清单、用户处理偏好和可读状态 | 解释所有生态格式、保存凭据、授予脚本权限或管理 worker。 | | 生态发现与解析适配器 | 发现本生态标准来源,保留真实优先级、格式、参数展开和诊断,并通过能力专属 provider 输出 | 写 BitFun 配置、依赖兄弟生态 adapter、执行其他生态语义或创建跨生态最低公分母。 | | 能力专属 provider 契约 | 用来源限定身份交付 Command、Tool、Subagent 等类型化定义与调用/展开结果 | 携带任意 payload 的通用资产对象,或让一种能力的新增字段污染其他能力。 | +| 静态 Hook provider 与目录协调器 | 通过 runtime-free 契约聚合 OpenCode、Claude Code、Codex 的脱敏声明;隔离 provider 失败、保留最后有效结果并向 Desktop/TUI 投影 | import handler、选择脚本运行时、授予执行权限、复用可执行来源审批 DTO,或把静态映射宣称为运行时兼容。 | | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和严格同卷原子替换等通用文件能力;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | -| 共享生命周期协调器 | 调用已注册 provider、生成不可变候选、按 provider 原子替换、保留隔离诊断,并请求能力 owner 切换 | 按生态 ID 分支业务行为、解析生态文件、直接提交配置、工具、权限或界面状态。 | -| 冲突解析 | 对独立 provider 或产品本地能力的同名候选建立版本敏感指纹;未选择时不激活,选择后只在指纹不变时复用 | 用 adapter 优先级静默覆盖另一生态或本地能力,或把选择写回外部文件。 | +| 共享生命周期协调器 | 由单一 `ExternalSourceControlPlane` 持有 Command、Tool、Subagent、MCP 四个 typed coordinator 和四条 discovery lane;按 provider 复用唯一 in-flight 请求、隔离超时和失败、以 generation fencing 拒绝迟到结果,再请求能力 owner 切换 | 按生态 ID 分支业务行为、把四类 payload 合并为通用资产、解析生态文件、直接提交配置、工具、权限或界面状态。 | +| 版本化控制投影 | 以正交的 discovery/desired/review/runtime/support 事实推导一级状态;向宿主提供同 generation 的 control/catalog、`hostCapabilities`、恢复动作和闭合通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行 payload、替代能力专属审批和冲突 DTO,或让 GUI/TUI 自行推导生命周期。 | +| 产品展示投影 | 按作用域、工作区或用户目录关系统一生成安全来源位置,清理可见诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | +| 冲突解析 | 对独立 provider 或产品本地可执行能力的同名候选建立版本敏感指纹;未选择时不激活,选择后只在指纹不变时复用。现有 Skill 固定根顺序由 Skill owner 独立维护 | 用 adapter 优先级静默覆盖另一生态或本地可执行能力,或把选择写回外部文件。 | | 激活策略与各能力 owner | 根据风险、用户选择、组织上限和执行域决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | | Runtime Configuration Service | 应用兼容配置视图,执行显式导入、冲突预览、原子写入和撤销 | 读取凭据值或加载插件代码。 | | Plugin Runtime Host / 执行服务 | 准备代次、监督进程、期限、取消、背压、健康和贡献生命周期 | 决定来源优先级、产品提示策略或最终业务状态。 | @@ -232,9 +302,17 @@ Assembly 知道当前构建注册了哪些具体 adapter;产品入口、目录 或 Claude Code 的私有类型。新增生态通过同级 adapter 与现有能力契约接入,不能修改另一个生态 adapter。 provider discovery 必须是可独立调度的 request/result,不在协调器锁内串行扫描。产品组装为每个 provider 设定期限, -超时后只沿用该 provider 的上一有效结果;健康兄弟 provider 继续更新。同步文件适配器超时后底层阻塞任务未必可取消, -因此同一 provider 同时最多保留一个 in-flight discovery,后续刷新复用它,完成后再提交结果,不能无限堆积线程。 -未来网络 provider 还应实现协作式 deadline/cancel,但不改变目录、冲突或产品入口契约。 +超时后只沿用该 provider 的上一有效结果;健康兄弟 provider 继续更新。同一 provider 同时最多保留一个 in-flight +discovery;期间到达的新刷新只合并最新 request,并把旧完成标记为不可发布,随后立即执行新 generation。同步文件适配器 +超时后底层阻塞任务未必可取消,因此进程级 discovery budget 固定为 8,后台等待最多 30 秒;超限后报告隔离状态, +但同一 provider 保留 generation tombstone 直到真实 worker 退出,期间只合并最新 request、不得重复占用线程或 permit。 +未来网络 provider 仍应实现协作式 deadline/cancel, +但不改变目录、冲突或产品入口契约。 + +控制请求的通用动作固定为 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`。能力专属的审批、冲突选择和执行参数继续由 +各 owner 的类型化契约承担,不能为了“一个 API”塞入任意 payload。错误以 `code + stage + retryable + +correlationId/causationId + recoveryActions` 表达;`detail` 只用于有界诊断,界面和远端协议不得解析文本 +决定控制流。日志只记录动作、阶段、关联 ID、错误类别和脱敏对象身份;产品打点可在同一结果上叠加,但不得反向改变状态。 来源降级必须区分粒度:整个配置/目录状态未知时回退对应来源;能确定身份的单个 Command 读取或解析失败时只回退该 Command;明确缺失且未被标记失败的 Command 是稳定删除。产品调用在刷新后还要校验先前投影的候选 ID 与内容版本, @@ -278,8 +356,8 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 4. 含 `!shell`、`@file`、`{env:...}`、`{file:...}`、`agent`、`model`、`variant` 或 `subtask` 等未接通语义的命令标记为“部分受限”,不做 静默忽略后的部分执行。 5. Desktop 提供统一来源状态、刷新、按执行域抑制/恢复和冲突候选选择;首次 provider 扫描完成前显示中性检查状态, - 不把暂时空目录误报为最终空结果;已经选择且指纹未变化的冲突退出待处理区。CLI/TUI 使用同一目录列出和执行 - Command;跨 provider 候选以来源限定别名供 CLI 用户直接选择,同次选择也解析本地同名冲突。发现或确认不阻塞 + 不把暂时空目录误报为最终空结果;已经选择且指纹未变化的冲突退出待处理区。交互式 TUI(ChatMode)使用同一目录列出和执行 + Command;跨 provider 候选以来源限定别名供 TUI 用户直接选择,同次选择也解析本地同名冲突。发现或确认不阻塞 普通聊天输入;发现未完成时未限定 slash 别名不猜测冲突结果,显式 `/builtin:` 仍可立即执行。执行域全局偏好 使用独立偏好文件、跨进程锁和严格原子替换,并在查询、刷新和执行前重新读取,使并行 Desktop/CLI 进程不会继续使用 另一进程已停用的来源或丢失并发选择。Desktop IPC 仅返回设置页所需摘要,不携带 Prompt Command 模板正文。 @@ -295,8 +373,12 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 3. 内置、MCP 与外部同名 Tool 使用包含候选身份和内容版本的指纹;选择前保留已有本地实现,不按注册顺序静默覆盖。 候选集合由已识别定义而非成功加载结果计算;候选更新、暂不可用或删除后重新选择,已选外部实现失效时保持 unavailable,不静默回退同名本地实现。其他无关 Tool 和生态不受影响。 -4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;CLI/TUI 状态栏只做非阻塞提示,`/external-tools` - 以编号映射稳定 key 完成启用、保持停用、冲突选择和刷新。普通聊天输入和无关会话不等待用户处理。 +4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;交互式 TUI(ChatMode)状态栏只做非阻塞提示,`/extensions` + 负责共享状态、刷新和 Safe Mode,通用 `/tools` + 入口在“外部 AI 应用”分组中以编号映射稳定 key 完成启用、保持停用、冲突选择和刷新;Agent 相关能力统一复用 + `/agents`,在同一列表中以文字区分主 Agent、Subagent 与外部 AI 应用,不另建 `/subagents` 或 `external-*` 命令。 + 普通聊天输入和无关会话不等待用户处理;活动 turn 期间入口仍可查看和管理,但主 Agent 切换单独禁用并说明原因, + 不改变正在执行的 session。 5. 每个 target 使用一个持久 Node worker,支持 load/invoke、`AbortSignal` 取消、500 ms 宽限后的 target 级硬终止、 30 秒请求期限和 dispose;产品配置的更短外层期限丢弃调用 future 时也会终止 worker,并在终止完成前保持 target 串行许可。输出限制为 1 MiB,协议帧限制为 8 MiB。稳定删除、抑制或撤销先撤下新调用;更新加载 @@ -306,7 +388,49 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 不形成循环重启。某个全局、项目、legacy 或显式目录不可读时只降级该目录,其他健康目录继续发现和运行。 6. `.ts`、依赖型 `.js`、`metadata`/`ask`、附件、package plugin、Hook、TUI plugin renderer 和 Remote worker 明确 延后;本机 Node 进程不是 OS 沙箱,VM realm 与隐藏响应令牌只保护协议稳健性。脚本继承当前用户的文件、网络、 - 环境与进程权限;本阶段没有 Job Object/process group,脚本直接启动的后代进程和系统资源风险必须持续显示。 + 环境与进程权限。脚本 worker 与 local stdio MCP 通过同一进程树边界启动:Unix 使用独立 process group 并在宽限后 + 升级终止,Windows 在恢复子进程前先加入 kill-on-close Job Object;附着失败时 fail closed。该机制回收已纳管后代; + Unix 主动创建新 session/process group 的逃逸进程不在该边界内。它也不限制文件/网络权限、CPU/内存消耗, + 因此仍须明确显示残余风险,不能称为沙箱。 + +第三阶段以 OpenCode MCP 配置来源验证声明式外部连接的接纳与生命周期: + +1. OpenCode adapter 按冻结版本解释用户全局、自定义文件/目录和项目 JSON/JSONC 的 MCP 合并顺序,只输出脱敏静态 + 候选;远程地址只展示 HTTPS origin,环境变量只展示变量名,值只在确认后的准备阶段解析。为避免一次确认在环境变化后 + 改写可执行文件、参数、工作目录或网络目标,本阶段只允许 `{env:NAME}` 出现在显式 environment 或 Header 值中;其他 + 位置明确标记不支持。展开后再次执行大小、HTTPS 与契约校验。 +2. MCP coordinator 与 Command、Tool、Subagent 平行,负责 provider 隔离、last-success、稳定删除和 watcher roots; + 审批、同名冲突和 BitFun 原生优先级由产品组装层决定,具体进程、连接、工具注册和回收仍由 MCP owner 负责。 +3. 首次启用或命令、参数、工作目录、环境声明、URL origin、Header 名、认证方式等行为变化后重新确认;拒绝在行为变化前不重复 + 提示。同名候选未选择时保留 BitFun 当前实现,不把显示顺序当成用户决定,也不在外部失效时静默切换。 +4. Desktop 在“外部 AI 应用”设置页显示安全摘要、状态、审批和冲突;交互式 TUI 复用 `/mcps`,在同一列表中用来源 + 标签区分 BitFun 与外部候选,不新增 `external-*` 命令。两端使用同一 generation、preference revision 和操作接口。 +5. 已确认候选按规范化 workspace 建立独立运行实例;工具包装器同时校验当前 workspace route,Remote owner 未实现前直接 + fail-closed。同名冲突选择外部候选时,只在当前 workspace 隐藏对应 BitFun 原生工具,不停止其他 workspace 的原生实例。 +6. 更新、停用、空闲回收或稳定删除先撤下 workspace route 和新工具入口,再有界等待在途连接释放;慢启动在发布连接、 + 工具和目录前再次检查撤销标记,不能在撤销后重新发布。第三方启动、握手和回收在后台有界执行,不要求全量重启 + MCP 或 BitFun,也不中断无关 session。 +7. 产品快照区分“正在启动”“已启用”“暂时不可用”。后台启动失败会触发快照更新;暂时不可用的候选仍可由用户停用, + 当前恢复入口是先停用该服务器,修复来源配置或认证后再启用;普通刷新不会自动重试失败进程,避免持续拉起故障或恶意 + 扩展。删除项的一次性用户通知/有界墓碑尚未实现,当前行会在稳定重扫后消失;该展示增强 + 留待后续 PR,不能改变“先撤 route、禁止新调用”的运行语义。 +8. 外部本地进程不继承 BitFun 的完整父进程环境,只保留启动所需的系统基线和配置显式声明的变量;这仍不是 OS + 沙箱,进程继续拥有当前用户的文件、网络和子进程权限。Remote 执行域、OpenCode OAuth client 配置、SSE、完整 + `timeout`/Agent 范围和通用凭据 owner 明确延后。 +9. 本阶段只把外部 MCP 的 Tool 目录接入 Agent Tool owner。通用 Resource/Prompt/MCP App Desktop 接口不接受无工作区 + 上下文的外部 runtime id;外部服务器发起的 roots、sampling 和 elicitation 请求也 fail-closed,防止跨工作区读取或借用 + BitFun 宿主能力。后续若接入这些能力,必须先补独立契约、工作区路由与权限交互,不能复用全局连接绕过当前边界。 + +独立的静态 Hook 目录切片不依赖上述运行时阶段: + +1. `product-domains` 只定义 runtime-free DTO 和 capability-specific provider port;三个生态 adapter 各自解释来源, + `assembly/external-sources` 负责 provider 聚合与 last-valid,`assembly/core` 只负责本地产品装配、Git/worktree 边界 + 解析,以及在既有 refresh gate 内按工作区串行完成发现与发布。 +2. Desktop/Web UI 与交互式 TUI 只消费共享快照;无 Server、Remote、Peer、Mobile、SDK 或运行时投影。 +3. 验收覆盖脱敏序列化、部分失败、首次失败与空目录区分、非法/过大输入、有界枚举、provider 身份冲突、刷新竞态、stale 结果、保留命令、 + `/help hooks`、GUI 空/错/刷新/不支持状态,以及 Host 返回未知 v1 枚举或可执行字段时 fail closed。 +4. 后续覆盖率、版本差异和冲突分析在此目录之上增加只读派生事实;Hook 执行 Host、权限、顺序、取消与故障恢复必须 + 另立运行时切片,不能通过扩展本目录 DTO 偷渡执行语义。 验收至少覆盖: @@ -324,6 +448,10 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 - 持续来源撤销后不会被下一次 watcher 更新重新应用;当前项目与整个执行域的抑制范围可验证。 - 同名外部候选和产品本地能力在用户选择前均不会被静默覆盖;选择在候选内容版本和参与集合不变时不重复询问, 任一候选更新、删除或参与集合变化后重新进入待选择,即使变化后只剩一个实现也不静默切换。 +- 冲突谱系对 0/1/N 个当前参与者都生成当前指纹;自动更新谱系或将旧选择改为“需重选”时,与用户选择一样原子推进 + `preference_revision`。跨进程旧 revision 的操作必须返回 stale,不得重新写入旧授权或旧候选。 +- GUI/TUI 的外部 Agent 冲突选择在同一上下文中展示将被原子批准的模型、工具、执行域、安全来源、兼容影响和恢复动作; + 同工作区决策串行化,成功后读取权威快照,不以较旧整表覆盖无关的 Command/Tool 新状态。 - 冲突偏好按执行域与命令族只保留当前指纹,并以去重候选身份标记曾发生冲突;连续内容更新不会按历史指纹线性膨胀。 - 显式导入的字段级预览、冲突、撤销和凭据脱敏可验证。 - 当前只支持静态预览的资产不会被产品文案误报为已应用或可执行;支持子集与完整 OpenCode 兼容不会混写。 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 37ab4db7c4..769361df34 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -10,9 +10,9 @@ 配置字段与来源以 [OpenCode 配置文档](https://opencode.ai/docs/config/)和稳定提交中的主/TUI 配置实现为准。 -本文同时记录首个可用切片与后续目标。当前 BitFun 已实现本地用户全局/项目 Prompt Command 的来源发现、 -JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,但尚未实现本文定义的完整 OpenCode 配置来源图、 -全部合并语义和其他资产映射。 +本文同时记录当前可用切片与后续目标。BitFun 已实现本地用户全局/项目 Prompt Command 的来源发现、 +JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,也已接入全局/项目 Subagent 声明的安全子集; +但尚未实现本文定义的完整 OpenCode 配置来源图、全部合并语义和其他资产映射。 ## 1. 目标与边界 @@ -95,6 +95,10 @@ PR1 只实现上述本地 Command 子集:XDG 用户配置根、`OPENCODE_CONFI 扫描。`OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源仍保留在目标来源图中,不得在产品状态中误报 为已加载。路径按规范化来源身份去重,显式环境路径与默认路径相同时只保留 OpenCode 顺序中的最后一个阶段。 +当前 MCP 子集复用同一来源解释,但只读取本地用户全局配置、`OPENCODE_CONFIG`、root-first 项目配置以及作为全局 +晚覆盖的 `OPENCODE_CONFIG_DIR`;设置该目录后不再叠加默认全局目录。`OPENCODE_CONFIG_CONTENT`、远程、组织、 +系统管理员和 MDM 来源尚未接入,不能因 MCP 候选可运行就把目标来源图标记为完整实现。 + ### 3.2 TUI 独立来源顺序 `tui.json/jsonc` 不是主配置来源图的附属字段。稳定版使用独立顺序: @@ -172,17 +176,20 @@ OpenCode 来源顺序决定兼容输入如何合并;BitFun 产品能力上限 ## 5. 声明式资产映射 -下表的“默认行为”是对应交付阶段完成后的目标行为。首个实现切片只接入本地 prompt-only Command:不启动进程、 -不 import module、不读取凭据、不主动联网;其他远程或可执行资产只解析、展示来源与诊断。 +下表的“默认行为”是对应交付阶段完成后的目标行为。当前已接入本地 prompt-only Command 和 Subagent 安全子集。 +OpenCode adapter 在来源发现、解析和审批前不 import module、不读取来源凭据、不主动联网;用户确认模型、工具和 +执行位置后,Subagent owner 才通过现有 Task 执行链发起 fresh single-run 调用。激活后的模型、工具、权限与凭据 +使用仍由对应 owner 按已确认包络控制。除已闭环的 standalone Tool 外,其余尚未闭环的远程或可执行资产仍只 +解析、展示来源与诊断。 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| | Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | Workspace Instructions 归属模块保存有序来源引用 | 本地内容按 L1 合并并保留来源;主动获取远程 URL 前确认 | 远程或单文件失败只排除该来源。 | -| Agents / Modes | JSON、Markdown、description、mode、prompt、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、permission、disable、options、hidden、color | Agent 归属模块创建兼容定义和作用域视图 | 纯声明字段按 OpenCode 顺序应用;扩大工具、权限或外部能力时确认 | BitFun 不支持字段进入诊断,不阻止其他 Agent。 | +| Agents / Modes | JSON、Markdown、description、mode、prompt、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、permission、disable、options、hidden、color | Agent 归属模块创建兼容定义和作用域视图 | 当前支持 Subagent 安全子集;首次按 behavior、provenance、模型和工具包络确认,fresh single-run 调用 | primary/mode、permission、variant/options、采样、steps 与续接保持诊断或阻断,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | Skill 归属模块复用按需加载并补齐规则顺序 | 说明和索引按需加载;URL、脚本或外部依赖按 L2 确认 | URL 或可执行资源失败只降级对应 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | -| Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;OpenCode adapter 保留发现、覆盖、解析和参数展开语义,CLI/TUI 只消费中立定义与展开结果 | PR1 支持 prompt-only 模板,用户显式选择或输入即确认本次发送;未接通的文件、shell、agent/model/variant/subtask 标为部分受限且不做部分执行 | 已知命令文件无效只回退该命令;稳定删除撤下新调用;目录枚举未知时回退对应目录来源,不能把未知当空目录。 | -| MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 首次按命令、网络、凭据和作用域确认,再按来源启用状态运行 | 凭据或网络不可用时单个 Server 不可用。 | +| Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;OpenCode adapter 保留发现、覆盖、解析和参数展开语义,交互式 TUI(ChatMode)只消费中立定义与展开结果 | PR1 支持 prompt-only 模板,用户显式选择或输入即确认本次发送;未接通的文件、shell、agent/model/variant/subtask 标为部分受限且不做部分执行 | 已知命令文件无效只回退该命令;稳定删除撤下新调用;目录枚举未知时回退对应目录来源,不能把未知当空目录。 | +| MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 当前支持 local stdio 和 HTTPS remote 的静态发现、首次/行为变化审批、冲突选择与 workspace 隔离的运行期接纳;外部本地进程只继承系统启动基线和显式环境 | `{env:NAME}` 当前只允许用于 environment/Header 值;SSE、OpenCode OAuth client 配置、完整 timeout/Agent 范围与 Remote 执行域保持明确不支持;凭据或网络失败只影响单个 Server。 | | LSP | command、extensions、env、initialization | LSP 归属模块注册兼容实例 | 首次确认外部进程和作用域后按文件类型启动 | 自定义 Server 缺少 extensions 或启动失败时只禁用该项。 | | Formatters | command、environment、extensions、`$FILE` | **基础能力缺失**:先补文件写入后的 Formatter 执行消费点,再做格式转换 | 首次确认命令后执行匹配 Formatter | 超时后标记未格式化,文件写入结果保留。 | | Themes | builtin/user/project/cwd JSON | **部分已有**:GUI Theme 已有;TUI 主题消费边界在终端阶段补齐 | 保留覆盖顺序和语义角色 | 颜色能力不支持时做可见降级。 | @@ -198,13 +205,37 @@ OpenCode 来源顺序决定兼容输入如何合并;BitFun 产品能力上限 ### 5.2 Agents、Modes 与 Skills -兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。字段映射分为: +兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。当前已实现范围按是否能保持行为等价划分: -- 可等价映射:名称、description、prompt、model、variant、`primary|subagent|all`、隐藏/停用状态、采样选项、 - `steps` / deprecated `maxSteps`、deprecated `tools` 和 options。 -- 策略映射:`permission` pattern、Skill 访问和 task/subagent 调用。 +- 可等价映射并激活:名称、description、prompt、`subagent|all`、隐藏/停用状态、可精确解析的 model,以及能映射到 + 当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Subagent 默认集并展示在确认摘要中。 +- 可识别但不激活:`primary`/legacy mode、`permission` pattern、variant/options、temperature/top_p、steps/ + deprecated maxSteps,以及不能精确解析的模型或工具。当前不能把这些字段静默忽略后宣称兼容。 - 展示映射:color 等只影响来源 Surface,不进入运行时权威事实。 -- 未知字段:保留并诊断;后续版本支持时可以重新解释原始来源。 +- 未知字段:进入来源限定诊断,不作为任意 payload 传给 core;后续版本支持时由 OpenCode adapter 更新解释。 + +全局与项目贡献在 adapter 内按稳定 OpenCode 顺序深合并并保留有序 provenance。Core 只消费来源无关候选,按当前 +模型、工具、执行域和本地/其他 provider 同名项生成审批与冲突指纹。无冲突候选首次确认一次;只有 catalog 文案 +变化不重问,prompt 行为、provenance 或实际模型、工具与执行范围变化重新确认。冲突未选择时逻辑名不可用,候选 +变化后不静默回退。 + +OpenCode adapter 负责把 `provider/model` 语法解析成来源无关的 provider 提示与模型名;Core 不解释 OpenCode 字符串 +格式。进入审批前,Subagent owner 必须把该请求或 BitFun 的固定 Subagent 默认项解析成唯一、已启用的具体模型,并把 +具体模型的配置 ID 与运行配置指纹写入决策和 generation 指纹。`inherit`、`primary`、`fast`、`auto`、`default` 在已 +物化绑定中只可能是普通配置 ID,不得再次解释成继承或默认选择;未配置的默认项、歧义匹配或已停用模型保持不可用并 +给出诊断,不能用运行时回退绕过审批。同一 ID 下的 provider、模型名、endpoint 或其他运行身份变化也会异步重建后续 +调用使用的 generation 并要求重新确认;旧 generation lease 保留旧绑定,执行时若指纹已不匹配则安全失败而不静默漂移。 + +通用诊断携带 `Source / Command / Tool / Subagent` 资源类型,产品入口只按该类型路由;`opencode.*` 诊断码仅用于技术详情, +不能成为 Core、GUI 或 TUI 的业务分支条件。能力 provider 契约限制来源、定义、provenance 和诊断集合,校验诊断码、 +可见文本、资源类型及来源归属,异常快照整体拒绝。Adapter 不把原始绝对路径写入诊断文本;产品快照边界还会把结构化 +位置及诊断中的已知路径统一转换为 `/…`、`~/…`、`/…` 等安全标签,`.opencode` 路径识别仍只属于 +本 adapter。 + +Subagent owner 仍通过现有 Task 执行链完成调用。fresh admission 固定 runtime generation 至调用完成;当前不支持外部 session +follow-up、primary agent 替换、OpenCode 会话内核、permission DSL 或 package plugin。Desktop/TUI 摘要不包含 prompt +正文,静态 system prompt 也不因该适配而改写。来源 `description` 只进入审批和管理界面;已批准 Agent +进入现有 `` 动态投影时使用 BitFun 生成的稳定摘要,避免 catalog-only 文案更新绕过行为重批而改变模型上下文。 ### 5.3 Commands @@ -226,7 +257,7 @@ stale selection 并等待重新选择,不能直接执行刚刷新的新内容 的同步 shell 路径;展开有期限、取消和输出大小限制,大输出保存后只把引用交给命令模板。 OpenCode 生态内部仍按其规则覆盖同名内置命令,但跨独立 provider 或与 BitFun 本地命令同名时不得静默覆盖。 -发生冲突后,兼容视图展示全部来源;CLI/TUI 对本地/单一外部冲突提供 `/builtin:name` 与 `/external:name`,对跨 +发生冲突后,兼容视图展示全部来源;交互式 TUI(ChatMode)对本地/单一外部冲突提供 `/builtin:name` 与 `/external:name`,对跨 provider 候选提供 `/external::`。一次外部候选选择同时解析同名本地命令冲突。选择按候选身份和 `content_version` 形成的冲突指纹持久化,同一指纹只询问一次;任一外部候选更新、删除或参与集合变化后指纹变化并 重新询问,即使变化后只剩一个外部或内建候选也不能静默切换实现。持久化只保留每个执行域/命令族的当前指纹和 @@ -236,7 +267,12 @@ provider 候选提供 `/external::`。一次外部候选选择 这些能力使用 BitFun 原生归属模块,但“原生已有”不自动等于兼容: -- MCP 必须覆盖 local 的 `environment`,remote 的 Headers、OAuth(含 clientId/clientSecret/scope/callbackPort/redirectUri)、超时、启用状态和 Agent 范围。 +- MCP 当前覆盖 local 的 `command/environment/cwd/enabled` 与 remote 的 HTTPS URL、Headers、动态 OAuth 开关和 + `enabled`,并在批准后按 workspace 交给现有 MCP owner;工具在调用前复核 workspace route,Remote 不回退到本机实例。 + 远端静态摘要只展示 HTTPS origin,环境引用只展示变量名;为避免审批后通过环境变量替换执行包络,`{env:NAME}` 仅 + 支持 environment/Header 值,展开后重新校验大小和协议。未配置 `cwd` 时遵循 OpenCode,使用当前 workspace。 + 外部本地进程默认不继承 BitFun 的完整父进程环境。SSE、OpenCode + `clientId/clientSecret/scope/callbackPort/redirectUri`、完整超时和 Agent 范围仍需后续接入,不能静默忽略。 - LSP 必须覆盖 initialization、扩展名匹配、环境变量和工作区生命周期。 - Formatter 必须覆盖写入后时机、`$FILE` 替换、`environment`、多个 Formatter 顺序和失败行为。 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index ec35316414..6a1112ff34 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -19,17 +19,18 @@ ## 1. 基线与判断方法 -本次清单刷新于 2026-07-17: +本次清单刷新于 2026-07-21: -- 最新稳定版为 [`v1.18.3`](https://github.com/anomalyco/opencode/releases/tag/v1.18.3),提交为 [`127bdb30784d508cc556c71a0f32b508a3061517`](https://github.com/anomalyco/opencode/commit/127bdb30784d508cc556c71a0f32b508a3061517)。 -- 开发分支前瞻检查记录为提交 [`08fb47373509ba64b13441061314eeacf4264f51`](https://github.com/anomalyco/opencode/commit/08fb47373509ba64b13441061314eeacf4264f51)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 +- 最新稳定版为 [`v1.18.4`](https://github.com/anomalyco/opencode/releases/tag/v1.18.4),提交为 [`49c69c5ed3ccf706b61b3febb43c8aaff7f8325e`](https://github.com/anomalyco/opencode/commit/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e)。 +- 开发分支前瞻检查记录为提交 [`849c2598abc7d2b40261e74b5826bc74ffc78308`](https://github.com/anomalyco/opencode/commit/849c2598abc7d2b40261e74b5826bc74ffc78308)。该值会持续变化,只用于发现差异,不计入稳定兼容承诺。 +- `v2` 前瞻分支检查记录为提交 [`44b6938b2ad2895bdd89d5d5cf753fb01ca8e3dd`](https://github.com/anomalyco/opencode/commit/44b6938b2ad2895bdd89d5d5cf753fb01ca8e3dd):仓库仍以 Bun 作为 package manager、开发和默认编译路径,同时提供 Node 26 SEA 并行构建与 Node 启动器。它不是“已经完全切换 Node”的稳定承诺。 - 配置、插件、工具、Agent、Skill、Command、Rule、MCP、LSP、Formatter、Theme、Keybind、开发工具包、Server 和 ACP 以 [OpenCode 官方文档](https://opencode.ai/docs/) 为准。 -- 稳定服务插件接口以 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/index.ts) 为准; -- custom tool 接口以 [`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/tool.ts) 为准; -- 终端插件接口以 [`packages/plugin/src/tui.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/tui.ts) 为准; -- 终端插件行为说明以 [`tui-plugins.md`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/opencode/specs/tui-plugins.md) 为准。 +- 稳定服务插件接口以 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/index.ts) 为准; +- custom tool 接口以 [`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tool.ts) 为准; +- 终端插件接口以 [`packages/plugin/src/tui.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tui.ts) 为准; +- 终端插件行为说明以 [`tui-plugins.md`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/opencode/specs/tui-plugins.md) 为准。 -稳定兼容只冻结 `v1.18.3` 的公开文档、接口源码和样例;开发提交仅用于发现未来差异,不进入当前承诺。升级时必须 +稳定兼容只冻结 `v1.18.4` 的公开文档、接口源码和样例;开发及 `v2` 提交仅用于发现未来差异,不进入当前承诺。升级时必须 重新比较实际消费的文件和行为,不能沿用本次结论。 ### 1.1 差异类型 @@ -66,11 +67,14 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 非阻塞摘要;可执行内容在首次启用或能力扩大时等待来源/target 级确认,但不阻塞项目和无关会话。 - 设置中的统一外部来源视图负责解释全局/项目作用域、当前支持范围、待处理项和变更结果;显式导入只是把 非执行内容转为 BitFun 原生配置的可选快照,不是 OpenCode 项目可用或插件执行的前置条件。 +- Desktop、TUI、Peer Host 与只读 Server 使用同一版本化控制 DTO。控制面只共享正交生命周期、Host 能力、恢复动作、 + `Refresh/SetSourceEnabled/SetSafeMode`,不携带 OpenCode 私有 payload;审批和冲突仍归 Tool/Subagent/MCP 等能力 owner。 - 第一条执行闭环已覆盖官方复数目录和源码验证过的单数目录中的受支持单文件 `.js` standalone tool;`.ts`、模块依赖、 package plugin、完整配置、Hook 和 TUI target 仍只识别或延后。当前范围和完整兼容目标必须分别表达,不能用 一个 JS fixture 宣称 OpenCode runtime 完整兼容。 -- PR2 使用本机 Node.js 验证受限 JS 子集,并在 Desktop 与 CLI/TUI 显示运行时和无 OS 沙箱边界;固定 Bun、完整 - TypeScript/Zod 与包依赖仍是后续兼容目标。HarmonyOS PC 原生 CLI/TUI 必须按 +- 当前 standalone Tool 使用本机 Node.js 验证受限 JS 子集,并在 Desktop 与交互式 TUI(ChatMode)显示运行时和无 OS 沙箱边界; + 脚本 worker 与 local stdio MCP 已共享跨平台进程树回收。OpenCode v2 的 Node SEA 前瞻证明 Node 是可行执行路线,但其 + Bun 编译路径仍存在;BitFun 后续 TypeScript/Zod、`$` 与包依赖必须按冻结样例选择运行时 adapter,不能提前把 Bun 或 Node 固化进 Host ABI。HarmonyOS PC 原生 CLI/TUI 必须按 [平台专题](../platform-portability-design.md)独立取证,不包含 HarmonyOS 手机 Remote App。 - 扩展调用必须有期限、取消、有界队列、大小检查和可观察的崩溃降级;更细的权限、沙箱和组织策略沿用现有控制点并延期 单独设计,不在首条闭环扩大接口。 @@ -81,7 +85,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | 优先级 | 可观察结果 | 暂不绑定的工作 | |---|---|---| | OC-E0 | 固定版本、官方 custom tool 契约、受支持单文件 fixture、当前静态预览明确显示“未执行” | 全量配置导入 | -| OC-E1 | 上述 fixture 的真实 `execute` 进入现有 Tool Runtime,支持身份/路径字段、合作式与硬取消,并在 Desktop/CLI/TUI 完成非阻塞审批和冲突选择 | `metadata`/`ask`、依赖型样例、package plugin、Hook、TUI 插件 API | +| OC-E1 | 上述 fixture 的真实 `execute` 进入现有 Tool Runtime,支持身份/路径字段、合作式与硬取消,并在 Desktop/交互式 TUI(ChatMode)完成非阻塞审批和冲突选择 | `metadata`/`ask`、依赖型样例、package plugin、Hook、TUI 插件 API | | OC-E2 | 一个真实 package plugin,仅实现其需要的 loader 和最小 client/context | 全部 loader fallback 和 Client API | | OC-E3 | 按阻塞样例加入 Hook;TUI 先接 command/slash/key,toast 需先有 CLI 类型化状态/通知 owner | 原始 renderer、Server、Remote、连接器 | @@ -103,12 +107,12 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 未实现 | 可主要适配 | OC-R1 | 有效配置保持 OpenCode 解码语义;未知字段保留和非安全字段局部恢复属于 BitFun 鲁棒性增强,安全/执行字段无效时不激活受影响结果 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | | Rules / Instructions | 转换参数 | 未实现 | 可完整适配 | OC-R1 | R1 映射本地/已缓存内容;需要主动联网的远程 instruction 在 R2 通过归属模块保护后获取 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Agents / Modes | 融合现有能力 + 转换参数 | 未实现 | 可主要适配 | OC-R1 | 转换 Markdown/JSON、primary/subagent、模型、工具和权限;由 Agent 归属模块提交 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | -| Skills | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 发现/展示;R2 保留按需加载及 allow/deny/ask 顺序,可执行资源走 Skill 归属模块保护 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | +| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集 | 可主要适配 | OC-R1 | 已支持全局/项目 Markdown、JSON/JSONC 的 subagent/all、prompt、description、disable/hidden、精确模型与工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;primary/mode、permission、variant/options、采样与续接明确降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | +| Skills | 转换参数 | 部分实现:现有 Registry 发现与来源/覆盖展示 | 可完整适配 | OC-R2 | 已沿用现有根顺序发现 `.opencode` 等用户/项目 Skill,并在 GUI/TUI 展示来源、作用域和覆盖结果;尚未复现 OpenCode 按需加载、完整配置合并及 allow/deny/ask 顺序,也未并入外部来源策略 owner | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Commands | 补扩展接口 + 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 只展示;R2 把声明注册到 CLI action registry,再由既有 controller 执行参数、`@file`、shell 展开、Agent/model/subtask | [Commands](opencode-config-assets-adapter-design.md#53-commands) | +| Commands | 补扩展接口 + 转换参数 | 部分实现:prompt-only 子集 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、`$ARGUMENTS` 与位置参数,并接入动态命令目录、运行时刷新和显式冲突选择;`@file`、shell、Agent/model/variant/subtask 保持受限且不做部分执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | | Models / Providers 配置 | 融合现有能力 | 未实现 | 可主要适配 | OC-R1 | 静态字段进入模型归属模块;动态模型、鉴权和请求头交给插件运行时 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| MCP | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 解析;R2 转换本地命令、远程 URL、Headers、OAuth、超时和 Agent 选择并由 MCP owner 启动 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | +| MCP | 转换参数 | 部分实现:local stdio 与 HTTPS remote 子集 | 可完整适配 | OC-R2 | 已支持用户全局、自定义目录/文件和项目 JSON/JSONC 的静态发现、显式审批、原生/外部冲突、workspace 隔离、启动失败回推、更新撤下及现有 MCP owner 运行;环境引用仅用于 environment/Header 值,Remote 不回退本机实例;SSE、OpenCode OAuth client 配置、完整 timeout/Agent 范围和其他配置来源继续明确降级 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | | LSP | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 解析;R2 转换 command、extensions、env 和 initialization 并由 LSP owner 启动 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | | Formatters | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 补文件写入后的格式化执行能力,再映射 command/environment/extensions/`$FILE` | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | | Themes | 转换参数 | 未实现 | 可主要适配 | OC-R1 | 保留 builtin/user/project/cwd 覆盖顺序,分别映射 GUI 和 TUI 色彩能力 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | @@ -125,17 +129,17 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | OpenCode 扩展项 | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | 细节 | |---|---|---|---|---|---|---| | `.opencode/tools/*.js` | 补基础能力 | 受支持单文件子集已接入 Tool Runtime | 可完整适配 | OC-R2 | 当前 Node worker 支持基础 schema、默认值、字符串结果、取消/超时/撤下;完整 Zod、模块依赖、`metadata`/`ask` 和附件结果继续走类型化进程通信扩展 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | -| `.opencode/tools/*.ts` | 补基础能力 | 已识别,执行不支持 | 可完整适配 | OC-R2 | 当前静态显示不 import;后续由固定 Bun/TypeScript worker 保留真实 schema 与 execute,不在 Rust 猜测 TS 语义 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | +| `.opencode/tools/*.ts` | 补基础能力 | 已识别,执行不支持 | 可完整适配 | OC-R2 | 当前静态显示不 import;后续由冻结样例选择 Node 转译或 Bun/TypeScript worker,保留真实 schema 与 execute,不在 Rust 猜测 TS 语义 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | | 插件 `tool` map | 补基础能力 + 补扩展接口 | 未实现 | 可完整适配 | OC-R2 | 运行插件工厂,按同一双表示注册真实工具,并接到 Tool 归属模块 | [工具加载](opencode-plugin-runtime-adapter-design.md#5-工具与插件加载) | | 项目与用户目录插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 直接发现本地 JS/TS 模块,不要求 BitFun 专用清单;来源/target 确认后由隔离候选加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | -| 配置中的软件包插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 来源/target 确认后用 npm 配置、Arborist、package-lock 和 `ignoreScripts: true` 准备依赖,再由固定版本 Bun 加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | +| 配置中的软件包插件 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 来源/target 确认后用 npm 配置、Arborist、package-lock 和 `ignoreScripts: true` 准备依赖,再由与冻结插件样例匹配的 Node/Bun adapter 加载 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | | 全局插件加载 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 自动发现全局配置和 ConfigPaths 全局目录,并按完整来源图生成 `plugin_origins`;首次可执行启用按来源/target 确认,决定只提示一次且可按项目覆盖 | [服务插件](opencode-plugin-runtime-adapter-design.md#52-服务插件) | | `package.json`、入口与依赖 | 补基础能力 | 未实现 | 可主要适配 | OC-R2 | 复现 server target、入口回退、`engines.opencode`、npm 配置和锁文件;原生模块失败只影响对应插件 | [来源与执行版本](opencode-plugin-runtime-adapter-design.md#4-来源与执行版本) | | 内置/MCP/外部同名工具;后续 pure/重复插件顺序 | 融合现有能力 | standalone Tool 显式选择已实现 | 可完整适配 | OC-R2 | 当前按候选身份与内容版本记忆选择且不静默覆盖;package plugin 阶段再复现 internal-first、pure、来源顺序和去重 | [注册与覆盖](opencode-plugin-runtime-adapter-design.md#53-注册与覆盖) | | `project` / `directory` / `worktree` | 直接桥接 | standalone Tool 已传 `directory/worktree/sessionID`;完整 `project` 未实现 | 可完整适配 | OC-R2 | 当前 `directory` 为打开的 workspace、`worktree` 为 Git 根并传递真实 session;完整插件 `project` 和 Remote 在 OC-R5 前保持 `unsupported` | [兼容门面](opencode-plugin-runtime-adapter-design.md#7-opencode-兼容门面) | | `client` | 补扩展接口 | 未实现 | 可主要适配 | OC-R2 | 提供版本化插件客户端门面,按方法转发到现有 BitFun 归属模块 | [兼容门面](opencode-plugin-runtime-adapter-design.md#7-opencode-兼容门面) | | `serverUrl` | 补扩展接口 | 未实现 | 可主要适配 | OC-R2 | 在 worker 执行域提供真实回环服务,只实现插件所需的版本化路由 | [兼容门面](opencode-plugin-runtime-adapter-design.md#7-opencode-兼容门面) | -| `$` 与脚本环境 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 固定 Bun worker 提供真实 `$`;受限模式只能依赖真实 OS/容器边界,无法落实时停用 target | [默认策略](opencode-plugin-runtime-adapter-design.md#3-默认策略与可调权限) | +| `$` 与脚本环境 | 补基础能力 | 未实现 | 可完整适配 | OC-R2 | 只有需要 OpenCode/Bun `$` 的冻结样例才启用 Bun-compatible adapter;Node 路径不能伪造等价语义。受限模式依赖真实 OS/容器边界,无法落实时停用 target | [默认策略](opencode-plugin-runtime-adapter-design.md#3-默认策略与可调权限) | | 加载、停用、更新与崩溃恢复 | 补基础能力 | standalone Tool fail-closed 已实现 | 可主要适配 | OC-R2 | 已有来源限定 target、后台重载、删除撤下与 worker 终止;精确物化旧版本、健康旧进程保留和退避恢复仍待完整 Host | [生命周期](opencode-plugin-runtime-adapter-design.md#9-生命周期与能力暴露) | | 跨插件进程全局共享 | 明确降级 | 未实现 | 明确降级 | OC-R2 | 每 target 使用独立可终止进程;不承诺 `globalThis`、进程环境或模块单例的未文档化共享 | [故障域](opencode-plugin-runtime-adapter-design.md#81-故障域) | @@ -143,23 +147,29 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 ### 3.3 稳定服务 Hook +本节的“实现”指进入真实 OpenCode 插件运行时。BitFun 当前按插件声明与具名导出顺序,从本地插件文件静态展示 +下列 Hook 属性,并把 `tool.execute.before/after` 投影到已有 Tool Hook 点;未知或动态注册保持 `opaque`。映射仅表示 +BitFun 已识别等价契约覆盖,不表示外部 handler 已加载、激活或执行。目录不会 import 或执行插件,内容版本也只 +指纹化脱敏后的目录事实,因此不改变任何 Hook Runtime 的“未实现”结论。`tool` 是工具注册能力,不作为 Hook +事件猜测或投影。 + | Hook | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | |---|---|---|---|---|---| -| `dispose` | 直接桥接 | 未实现 | 可完整适配 | OC-R3 | 调用清理并设置期限;超时回收 worker。 | -| `event` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 提供版本化事件代理并隔离插件异常。 | -| `config` | 补扩展接口 + 融合现有能力 | 未实现 | 可完整适配 | OC-R3 | 按插件顺序变换,最后由 Config 归属模块校验提交。 | +| `dispose` | 直接桥接 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 调用清理并设置期限;超时回收 worker。 | +| `event` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 提供版本化事件代理并隔离插件异常。 | +| `config` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 按插件顺序变换,最后由 Config 归属模块校验提交。 | | `tool` | 补基础能力 + 补扩展接口 | 未实现 | 可完整适配 | OC-R2 | 注册真实工具定义与执行函数。 | -| `auth` | 补扩展接口 | 未实现 | 可主要适配 | OC-R3 | 提供 API/OAuth 方法和脱敏凭据代理。 | -| `provider` | 补扩展接口 + 融合现有能力 | 未实现 | 可主要适配 | OC-R3 | 将动态模型列表接入 Provider 归属模块。 | -| `chat.message` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 依次变换消息和 parts,变换后重做结构校验。 | -| `chat.params` | 补扩展接口 + 融合现有能力 | 未实现 | 可完整适配 | OC-R3 | 依次变换模型参数,显式产品上限最后生效。 | -| `chat.headers` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 依次变换请求头,敏感值不进入日志。 | -| `permission.ask` | 融合现有能力 | 未实现 | 可主要适配 | OC-R3 | 默认保留 allow/deny/ask 语义;用户或组织策略可收紧。 | -| `command.execute.before` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 在命令执行前依次变换消息 parts。 | -| `tool.execute.before` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 变换最终参数,随后重做 schema 和权限判断。 | -| `shell.env` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 在实际执行域构造环境变量。 | -| `tool.execute.after` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 依次变换 title、output、metadata,保留原始结果引用。 | -| `tool.definition` | 补扩展接口 + 融合现有能力 | 未实现 | 可完整适配 | OC-R3 | 变换模型可见 JSON Schema;真实执行继续使用 worker 中原始 Zod 校验,保持 OpenCode 双表示语义。 | +| `auth` | 补扩展接口 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 提供 API/OAuth 方法和脱敏凭据代理。 | +| `provider` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 将动态模型列表接入 Provider 归属模块。 | +| `chat.message` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换消息和 parts,变换后重做结构校验。 | +| `chat.params` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换模型参数,显式产品上限最后生效。 | +| `chat.headers` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换请求头,敏感值不进入日志。 | +| `permission.ask` | 融合现有能力 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 默认保留 allow/deny/ask 语义;用户或组织策略可收紧。 | +| `command.execute.before` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 在命令执行前依次变换消息 parts。 | +| `tool.execute.before` | 补扩展接口 | 静态映射可见,运行未实现 | 可完整适配 | OC-R3 | 变换最终参数,随后重做 schema 和权限判断。 | +| `shell.env` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 在实际执行域构造环境变量。 | +| `tool.execute.after` | 补扩展接口 | 静态映射可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换 title、output、metadata,保留原始结果引用。 | +| `tool.definition` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 变换模型可见 JSON Schema;真实执行继续使用 worker 中原始 Zod 校验,保持 OpenCode 双表示语义。 | Hook 的共同风险是把变换误做成通知、并行调用破坏顺序或插件写入非法状态。所有 Hook 都走类型化调用、顺序执行和归属模块终检;具体调用协议见[服务插件运行时设计](opencode-plugin-runtime-adapter-design.md#6-钩子适配与权威提交)。 @@ -268,7 +278,11 @@ worker。授权在准备前、准备后 import 前、load 后注册前和每次 会立即撤下该 target 路由并标记 `load_failed`,不回退同名内置/MCP 实现,也不自动重放;下一次 Tool Catalog 暴露前 只消费一次恢复预算,仍失败则等待显式刷新或来源变化,不形成重启风暴。 -CLI/TUI 更新订阅在活动期间持有工作区服务;Desktop/Agent 每次装配模型可见 Tool Catalog 时续期并在首次或空闲 +当前脚本 worker 与 local stdio MCP 统一通过 `services-integrations` 的进程树边界启动:Unix 以独立 process group +完成宽限终止和强制回收,Windows 在子进程恢复前附着 kill-on-close Job Object,附着失败则不运行。这解决了受管 +后代在取消/崩溃/Host 退出后的常规回收,但不限制文件、网络、CPU、内存或可逃逸行为,仍不构成安全沙箱。 + +交互式 TUI(ChatMode)更新订阅在活动期间持有工作区服务;Desktop/Agent 每次装配模型可见 Tool Catalog 时续期并在首次或空闲 回收后同步刷新。首次后台刷新与 catalog 装配共享同一个完成门闩:catalog 等待在途结果,失败后允许下一次装配重试。 没有订阅或目录装配活动的工作区在 5 分钟后撤下路由并回收 worker,避免依次打开多个项目后永久累积 Node 进程; 下一次目录装配会在向模型暴露前恢复仍获批准且仍有效的 route。Remote catalog 与执行解析显式 fail-closed,即使远端 @@ -316,7 +330,7 @@ CLI/TUI 更新订阅在活动期间持有工作区服务;Desktop/Agent 每次 | 未文档化内部接口 | 不承诺 | 没有稳定版本和契约 | 返回稳定不支持并进入版本前瞻报告。 | | `experimental_workspace.register` | 暂不承诺 | 接口未稳定且会改变工作区与远程连接归属 | 继续使用 BitFun Workspace/Remote 归属模块,稳定后重评。 | | 受限策略下拦截任意脚本副作用 | 只能部分控制 | 插件可以直接调用脚本运行时,绕过细粒度能力代理 | 来源激活后默认兼容策略放开;用户收紧时明确列出被禁用或无法拦截的能力。 | -| 无硬资源限制平台上的系统资源耗尽 | 不能保证完全隔离 | 独立进程可终止死循环,但未必能阻止内存、CPU 或子进程风暴拖慢整机 | 使用进程树回收与平台可执行的 Job Object、cgroup/rlimit;缺少硬限制时显示残余风险。 | +| 无硬资源限制平台上的系统资源耗尽 | 不能保证完全隔离 | 已有进程树可回收受管后代,但仍不能阻止内存、CPU、网络或逃逸进程拖慢整机 | 在真实需求下增加 cgroup/rlimit/容器等平台额度;缺少硬限制时显示残余风险。 | 这些限制已经作为当前架构决策:项目状态只能表述为“兼容矩阵已审计、已实现项按证据列示”,不能表述为“稳定 扩展面已完整实现”或“所有插件完整兼容”。只有真实需求和新证据可以重新开启延期项。 diff --git a/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md b/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md index 2dee3b55ac..7487d99e7d 100644 --- a/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md +++ b/docs/architecture/extensions/opencode-plugin-runtime-adapter-design.md @@ -8,24 +8,29 @@ 外部来源的产品提示、风险分级和用户选择见 [`external-ai-work-sources-design.md`](external-ai-work-sources-design.md)。 -冻结接口以 `v1.18.3` 稳定提交的 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/index.ts)、 -[`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/127bdb30784d508cc556c71a0f32b508a3061517/packages/plugin/src/tool.ts) +冻结接口以 `v1.18.4` 稳定提交的 [`packages/plugin/src/index.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/index.ts)、 +[`packages/plugin/src/tool.ts`](https://github.com/anomalyco/opencode/blob/49c69c5ed3ccf706b61b3febb43c8aaff7f8325e/packages/plugin/src/tool.ts) 和实际插件 loader/npm 服务为准;[插件文档](https://opencode.ai/docs/plugins/)用于行为说明。 本文同时区分当前纵向切片与完整目标。当前实现已支持用户/项目 standalone `.js` Tool 的静态发现、非阻塞审批、 Node worker `load/invoke/cancel/dispose`、现有 Tool Runtime 注册、同名冲突选择与更新/删除撤下;`.ts` 和依赖型 JS -只显示不支持。固定 Bun、完整 Zod/TypeScript、package plugin、OpenCode 兼容 Client 和稳定 Hook 仍是目标设计, +只显示不支持。完整 Zod/TypeScript、package plugin、OpenCode 兼容 Client 和稳定 Hook 仍是目标设计, 不能把 PR2 的 Node 子集写成完整运行时兼容。 +2026-07 的前瞻检查显示 OpenCode `v2` 仍以 Bun 作为 package manager、开发和默认编译路径,同时提供 Node 26 SEA +并行构建与 Node 启动器。该分支尚未形成稳定运行时承诺,因此 BitFun 保持 `ScriptToolRuntime`/worker protocol +运行时中立:当前 `.js` 子集使用系统 Node;只有冻结的 TypeScript、Zod、`$` 或 package plugin 样例证明需要时, +才增加 Node 转译或 Bun-compatible Runtime Adapter,不把任一物理运行时固化进 Host ABI。 + ## 1. 核心决策 BitFun 实现自己的插件 Runtime,不启动完整 OpenCode Runtime: - 每个外部插件 target 在 BitFun 管理的独立、可终止脚本进程中运行;服务 target 与 TUI target 也彼此隔离。 -- 首选执行后端是由 BitFun 固定版本并随产品交付或按需安装的 Bun,而不是自行重写 JavaScript 引擎或用 - Node 兼容层猜测 Bun 行为。Bun 负责 TypeScript、模块执行和 Bun Shell `$`; - 发布前必须完成对应平台的许可证、签名、更新和体积验证。 -- 依赖准备不使用 `bun install` 猜测 OpenCode 行为。`v1.18.3` 兼容模块使用 npm 配置、`@npmcli/arborist`、 +- 物理执行后端通过 Runtime Adapter 选择。当前 standalone `.js` 子集使用经实际 `--version` 探测的系统 Node; + TypeScript、模块解析、Zod refinement 和 `$` 必须按冻结样例选择 Node 转译或 Bun-compatible adapter,不能互相猜测语义。 + 若随产品交付运行时,发布前必须完成对应平台的许可证、签名、更新和体积验证。 +- 依赖准备不使用 `bun install` 猜测 OpenCode 行为。`v1.18.4` 兼容模块使用 npm 配置、`@npmcli/arborist`、 `package-lock.json` 和 `ignoreScripts: true`;后续 OpenCode 版本改变实现时随兼容版本更新。 - 执行进程提供 OpenCode 兼容上下文和插件接口;插件无需感知 BitFun Rust 内部类型。 - OpenCode Adapter 把插件调用、钩子变换和工具结果转换成类型化进程消息。 @@ -35,11 +40,11 @@ BitFun 实现自己的插件 Runtime,不启动完整 OpenCode Runtime: 前置条件,用户、产品和组织可以按需收紧。 “BitFun 自有 Runtime”表示 BitFun 自己实现来源发现、依赖准备、worker 监督、OpenCode API 适配、进程通信、 -Rust 能力转发和状态恢复;并不表示重写 Bun,也不表示在后台启动 OpenCode 的 Agent Runtime。插件工厂和钩子 -在 Bun worker 中运行,需要 BitFun 能力时通过兼容接口转发给 Rust 归属模块。 +Rust 能力转发和状态恢复;并不表示重写 JavaScript 引擎,也不表示在后台启动 OpenCode 的 Agent Runtime。插件工厂和钩子 +在所选 Runtime Adapter 的 worker 中运行,需要 BitFun 能力时通过兼容接口转发给 Rust 归属模块。 PR2 的 `ScriptToolRuntime` 是 provider-neutral 的窄端口,当前 Node 实现只用于受支持的单文件 JavaScript standalone Tool。 -它不把 Node 固化进 Host ABI,也不承担 OpenCode 格式、审批或冲突规则;后续 Bun 实现可以替换物理执行服务而 +它不把 Node 固化进 Host ABI,也不承担 OpenCode 格式、审批或冲突规则;后续 Node 转译或 Bun-compatible 实现可以替换物理执行服务而 无需修改 Tool owner、产品表面或其他生态 adapter。 “最终由归属模块提交”不是把 OpenCode 可写钩子降级成只读通知。默认策略允许合法钩子变换生效;归属模块只负责 @@ -58,7 +63,7 @@ flowchart LR Host["Rust Plugin Runtime Host"] Adapter["OpenCode Adapter"] Bridge["类型化进程通信"] - Worker["Bun 插件执行进程"] + Worker["Runtime Adapter 插件执行进程"] Facade["OpenCode Compatibility Facade"] Plugin["OpenCode 插件 / Tool"] Owners["Tool / Session / Config / Provider / Event 归属模块"] @@ -104,7 +109,7 @@ Runtime Product Assembly 只选择并构造已编译的 adapter/provider,再 | OpenCode Source Coordinator | 读取 Config 归属模块提供的快照,维护来源身份/顺序、监听、候选代次和切换决定,并消费来源激活结论 | 写 BitFun 配置、保存凭据、决定产品提示、安装依赖、管理 worker 或提交贡献 | | 外部来源目录与激活策略 | 展示来源/作用域/能力摘要,保存用户加载偏好,并结合各 owner 与组织上限给出自动应用、待确认或限制结论 | 解释 OpenCode 加载顺序、import module、管理 worker 或代替调用时权限判断 | | 依赖准备服务 | 按兼容版本管理 npm 配置、Arborist、package-lock、缓存和安装锁 | 执行插件代码或擅自启用安装脚本 | -| 脚本执行服务 | 唯一持有固定版本 Bun、每 target OS 进程树/句柄,负责物理健康探测和资源回收 | 理解 OpenCode Hook 语义或提交 BitFun 业务状态 | +| 脚本执行服务 | 唯一选择并持有 Node/Bun-compatible Runtime Adapter、每 target OS 进程树/句柄,负责物理健康探测和资源回收 | 理解 OpenCode Hook 语义、把物理运行时固化进 Host ABI 或提交 BitFun 业务状态 | | OpenCode worker 适配库 | 加载真实模块,提供插件工厂上下文、`client`、`$` 和 target API | 暴露 Rust 内部类型或自行修改 BitFun 存储 | | 进程协议适配器 | 把工具、Hook、Client 和生命周期调用转换成类型化消息 | 使用任意 JSON 和字符串事件替代所有接口 | | Plugin Runtime Host | 期限、取消、有界队列、逻辑 target 状态、进程失联事实消费、响应校验和诊断 | 持有 OS 进程句柄/进程树,或决定 OpenCode 加载顺序、权限结果和工具结果 | @@ -118,7 +123,7 @@ Runtime Product Assembly 只选择并构造已编译的 adapter/provider,再 ### 3.1 OpenCode 兼容策略(本地默认) -- 使用 BitFun 管理的固定版本 Bun 加载 JS/TS;依赖按冻结 OpenCode 版本的 npm/Arborist 语义准备。 +- 使用与冻结样例匹配的 Runtime Adapter 加载 JS/TS;当前 `.js` 子集使用系统 Node,依赖按冻结 OpenCode 版本的 npm/Arborist 语义准备。 - 自动发现项目/用户插件、standalone tools 和配置目录 `package.json`。 - OpenCode 配置列出的插件和标准插件/工具目录自动进入来源清单,但首次不得直接 import。系统按“来源限定身份 + target + 执行域 + 能力摘要”形成非阻塞待确认项;用户处理后,同一摘要下的依赖准备、worker 启动和贡献注册 @@ -153,7 +158,7 @@ Runtime Product Assembly 只选择并构造已编译的 adapter/provider,再 | 控制层 | 可执行机制 | 控制粒度 | |---|---|---| | 经 Compatibility Facade / Tool Runtime 的 BitFun 能力 | 在 Rust 归属模块检查来源、凭据、文件范围、工具覆盖、配置写入、模型请求修改和界面贡献 | 可按调用和贡献细分 | -| Bun 直接文件、网络、环境和子进程能力 | 兼容模式使用当前执行用户;受限模式只能依赖真实操作系统或容器边界 | 只能按执行环境粗粒度限制;边界无法落实时停用 target | +| 脚本运行时直接文件、网络、环境和子进程能力 | 兼容模式使用当前执行用户;受限模式只能依赖真实操作系统或容器边界 | 只能按执行环境粗粒度限制;边界无法落实时停用 target | 用户、产品或组织可以选择兼容/受限模式,并在 Host 代理层调整自己有权修改的细项。策略必须满足: @@ -189,7 +194,7 @@ OpenCode 项目和用户目录可以直接成为兼容来源。OpenCode Source C | 插件和工具入口 | 固定本次准备加载的模块。 | | 配置目录 `package.json`、npm 配置、`package-lock.json` 及内容摘要 | 判断何时需要重新准备依赖。 | | 软件包实际解析版本、完整性信息和安装目录摘要(能够取得时) | 避免只记录包名导致错误复用缓存。 | -| Bun 版本、平台和架构 | 解释平台差异并选择正确 worker。 | +| Runtime Adapter 类型、版本、平台和架构 | 解释平台差异并选择正确 worker。 | | 原生模块和运行时发现的动态依赖 | 展示风险并在变化后使相关缓存失效;安装脚本在当前兼容版本中禁用。 | | 内容摘要、能力/权限摘要和生成时间 | 缓存失效、确认复用、诊断和重载。 | @@ -228,8 +233,9 @@ BitFun 原生 `bitfun.plugin.json` 包继续使用现有来源校验;OpenCode 为 8 MiB,无法认证的 stdout 累计到 1 MiB 后终止 worker。`import.meta.url` 使用已校验的来源 URL。 - 当前进程不是 OS 沙箱。模块使用独立 VM realm 且协议响应令牌不经 stdin 暴露,只用于降低偶然协议破坏,不能阻止 已批准脚本获得当前用户的文件、网络、环境和进程能力,也不构成针对恶意模块的协议认证;获批脚本控制其 target - 内的执行语义。本阶段没有进程树/Job Object;硬取消只保证直接 worker 退出,其后代进程和已创建系统资源可能 - 存活,必须在 Desktop 与 CLI/TUI 确认详情持续披露。 + 内的执行语义。当前脚本 worker 与 local stdio MCP 已共享进程树边界:Unix 使用独立 process group,Windows 在恢复 + 子进程前加入 kill-on-close Job Object,附着失败时 fail closed。硬取消会回收已纳管后代,但不限制文件/网络、CPU、 + 内存或边界外逃逸进程,必须在 Desktop 与 CLI/TUI 确认详情持续披露。 - global、project、legacy 或 `OPENCODE_CONFIG_DIR` 任一目录读取失败时记录绑定该 source 的诊断并继续扫描其他目录, 不把单目录 I/O 故障升级成整个 OpenCode Tool provider 不可用。 - invoke 超时、空闲/调用中 worker 退出或 cancel 硬终止会通过带独立加载代次的 runtime health 事件立即撤下 target @@ -239,7 +245,7 @@ BitFun 原生 `bitfun.plugin.json` 包继续使用现有来源校验;OpenCode 完整目标在不改变 provider/owner 边界的前提下继续补齐: -- 固定 Bun 真实加载 JS/TS、依赖、default/具名导出;原始 Zod shape、refinement 和 `execute` 留在 worker, +- 与冻结样例匹配的 Node 转译或 Bun-compatible adapter 真实加载 JS/TS、依赖、default/具名导出;原始 Zod shape、refinement 和 `execute` 留在 worker, worker 用原始 Zod `safeParse` 校验调用,只把模型可见 JSON Schema 传给 Rust。 - `ToolContext` 补齐 message/agent 等真实执行上下文;`metadata({ title?, metadata? })` 与 `ask()` 通过反向类型化 调用更新流式元数据和请求权限。 @@ -358,7 +364,7 @@ OpenCode 类型。 每个外部插件 target 使用独立操作系统进程。单线程内的超时不能终止死循环,进程级隔离是回收失控执行的必要 条件,但独立进程本身不能保证系统级资源耗尽不影响其他插件: -- 插件初始化失败、崩溃或死循环不得直接终止 Host;期限到达后回收该 target 的完整进程树。 +- 插件初始化失败、崩溃或死循环不得直接终止 Host;期限到达后回收该 target 已纳管的进程组/树,并明确披露 Unix 主动脱离 process group 的残余风险。 - Windows 用 Job Object 管理进程树,Unix 至少使用独立 process group;内存、CPU 和子进程数在平台可执行时使用 Job Object、cgroup/rlimit 等硬预算。无法提供硬限制的平台必须显示残余风险,不能承诺内存或进程风暴完全不影响宿主。 - 初始化失败回滚该插件本次注册的工具、Hook 和状态,继续加载其他插件。 - 服务插件和终端插件分别启动、启停和恢复;一端失败不使另一端自动失效。 diff --git a/docs/architecture/extensions/plugin-runtime-host-design.md b/docs/architecture/extensions/plugin-runtime-host-design.md index 7498e376b6..39a69412e0 100644 --- a/docs/architecture/extensions/plugin-runtime-host-design.md +++ b/docs/architecture/extensions/plugin-runtime-host-design.md @@ -87,11 +87,11 @@ OpenCode 的可写钩子不是只读通知。适配器按 OpenCode 顺序执行 | `src/crates/execution/plugin-runtime-host` | 当前请求校验、期限和故障状态 | 承担通用调用可靠性;不依赖具体脚本运行时或生态类型 | | `src/crates/assembly/core` 的产品组装点 | 当前选择插件运行时 binding 与可用性 | 选择并构造已编译的 adapter/provider,注入窄 `PluginRuntimeBinding`、执行服务和产品能力/策略上限;不发现动态来源、不准备依赖、不 import 插件代码 | | `src/crates/contracts/product-domains` / `src/crates/services/services-integrations` 的插件来源模块 | 当前 BitFun 专用目录、内容校验、审核与启停状态 | 继续服务 BitFun 原生包;OpenCode 外部目录由兼容来源发现流程直接读取,不要求重新打包 | -| `src/crates/adapters/opencode-adapter` | 当前静态解释少量 OpenCode 文件 | 承担 OpenCode 格式/进程协议适配和 OpenCode Source Coordinator;不持有 worker、最终工具、配置、权限或界面状态 | -| 目标脚本执行服务 | 当前不存在 | 作为可替换服务管理运行时、依赖、worker 和资源回收;产品组装只依赖窄接口 | +| `src/crates/adapters/opencode-adapter` | 当前分别解释受支持的 Command、standalone Tool 和 Subagent 来源,并映射到独立 provider 契约 | 承担 OpenCode 格式/进程协议适配和 OpenCode Source Coordinator;不持有 worker、最终工具、配置、权限或界面状态 | +| 脚本执行服务 | standalone Tool 已有 provider-neutral `ScriptToolRuntime` 端口和 Node worker;package plugin 的依赖准备、Bun loader 与通用 worker 服务尚不存在 | 作为可替换服务管理运行时、依赖、worker 和资源回收;产品组装只依赖窄接口 | | Tool / Config / Permission / Session / TUI 等消费边界 | Tool、Config、Permission、Session 等已有真实 owner;TUI Input/Command/State/Effect 等仍聚集在现有终端代码 | 复用已有 owner;缺失边界只从真实消费路径增量抽取,不先建通用扩展框架 | -| CLI | 当前消费 BitFun 原生包的管理状态、诊断和 OpenCode 静态预览;尚无真实 OpenCode 插件执行闭环 | 只消费能力服务、状态视图和操作接口,不直接调用主机或适配器 | -| Desktop | 当前没有 managed-plugin 管理或 OpenCode 静态预览的生产 UI/调用方 | 出现真实入口后仍只消费能力服务、状态视图和操作接口 | +| `src/apps/cli` 交互式 TUI(ChatMode) | 当前通过外部来源能力服务使用 Command、管理 standalone Tool 和 Subagent;顶层 headless CLI/`exec` 没有对应入口,也尚无 OpenCode package plugin 执行闭环 | 只消费能力服务、状态视图和操作接口,不直接调用主机或适配器 | +| Desktop | “外部 AI 应用”已展示来源、standalone Tool 与 Subagent 的审批、冲突和诊断;仍没有 managed-plugin/package plugin 的生产管理入口 | 继续只消费能力服务、状态视图和操作接口,不直接调用主机或适配器 | | Web、Server、Remote | 当前没有生产插件执行入口;Server 仅有健康检查、信息与 ping 路由 | 出现真实入口后仍只消费类型化状态和操作接口,不复制插件主机 | `src/crates/assembly/core` 的 `plugin_runtime` 运行时组合点是唯一可以选择具体生态 adapter factory、构造 adapter @@ -107,7 +107,7 @@ trait object 并注入 Host 的位置。Host 只围绕注入对象工作,不 | Config owner → OpenCode Source Coordinator | 规范化配置值、来源身份与顺序、配置代次;不含 worker 或动态导出 | Config owner 保存配置与来源解释 | | Source Coordinator → 依赖/脚本执行服务 | 来源限定身份、target、候选代次、入口、依赖与有效策略;返回经摘要校验的 prepared target 引用 | Coordinator 保存候选/激活代次;执行服务保存缓存、进程句柄和物化结果 | | Source Coordinator → Plugin Runtime Host | 已完成来源准入的 prepared target 引用、adapter binding、切换/停用请求;返回激活或失败状态 | Host 保存逻辑 target 状态、在途调用和贡献注册状态,不保存产品确认偏好 | -| Host → 依赖/脚本执行服务 | 经注入控制端口请求启动、类型化调用、取消、整树终止和物理健康探测;返回类型化结果/健康事实 | 执行服务保存 OS 句柄、进程树与资源事实;业务结果仍由归属模块提交 | +| Host → 依赖/脚本执行服务 | 经注入控制端口请求启动、类型化调用、取消、已纳管进程组/树终止和物理健康探测;返回类型化结果/健康事实 | 执行服务保存 OS 句柄、进程树与资源事实;业务结果仍由归属模块提交 | 新增接口前必须先指出真实调用方和最终状态归属。工具调用、钩子变换、OpenCode Client 代理和终端贡献的输入、 期限、错误与返回语义不同,应分别建立窄路径;不能为了减少接口数量把它们编码成字符串事件和任意 JSON。 @@ -223,7 +223,7 @@ Host 不解析 Prompt、Memory 或 Tool 内容自行打点,也不重复计算 ### 6.1 进程与队列 - 外部插件 target 使用独立操作系统进程,不与 Rust 主进程或其他插件共享不可终止的执行线程。 -- 脚本执行服务必须唯一持有完整进程树:Windows 使用 Job Object,Unix 至少使用独立 process group。Host 不持有 +- 脚本执行服务必须唯一持有已纳管进程树:Windows 使用 Job Object,Unix 至少使用独立 process group;Unix 主动脱离 process group 的后代属于明确残余风险。Host 不持有 平台句柄,只能经注入的执行控制端口请求取消、超时、停用和退出时终止整棵树,不能只回收直接子进程。 - 内存、CPU 和子进程数使用平台可执行的 Job Object、cgroup/rlimit 等预算;无法提供硬限制的平台必须显示残余风险,不能仅凭独立进程承诺资源耗尽不会影响其他插件或宿主。 - 初始化、工具、钩子、客户端代理和清理分别设置期限;清理超时不能阻止产品退出或终端恢复。 @@ -288,23 +288,33 @@ OpenCode 来源完成首次激活后,本地默认运行策略以兼容为先 ## 8. 当前实现附录 -当前 P0-C.1/P0-C.2 只验证了 BitFun 原生来源和静态预览链路: +BitFun 受管插件包的 P0-C.1/P0-C.2 链路当前只验证了原生来源和静态预览: 1. 从用户数据目录的 `plugins` 和项目 `.bitfun/plugins` 发现 `bitfun.plugin.json` 包。 2. 校验清单、路径、文件大小和内容摘要,并保存工作区的来源审核与启停状态。 3. CLI 在启用前展示精确内容摘要;内容变化时旧启用状态失效。 -4. OpenCode 适配器只读取固定文件,使用有限字符串规则预览 custom tool 名称。 +4. 该受管包链路中的 OpenCode 投影只读取固定文件,使用有限字符串规则预览 custom tool 名称。 5. 产品组装只能读取带权限要求的工具候选;不加载 JS/TS,不注册或执行工具,也不运行钩子。 -因此当前状态只能表述为“来源可识别、静态候选可预览”,不能表述为“OpenCode 插件可运行”。CLI 已允许 -在包文件缺失或损坏时清理残留启用状态,这是现有链路必须保留的恢复能力。 - -`bitfun.plugin.json` 继续作为 BitFun 原生包格式。目标 OpenCode 路径直接发现用户和项目的 `opencode.json`、 -`.opencode/plugins`、`.opencode/tools` 及软件包配置,自动记录当前执行版本;不得要求作者先复制到 -`.bitfun/plugins` 或维护另一份清单。 +因此,受管包链路只能表述为“来源可识别、静态候选可预览”,不能表述为“OpenCode package plugin 可运行”。 +CLI 已允许在包文件缺失或损坏时清理残留启用状态,这是现有链路必须保留的恢复能力。与此独立的外部来源轨道 +已经接入 prompt-only Command、受支持的单文件 `.js` standalone Tool、Subagent 安全子集和 OpenCode MCP 配置 +安全子集;其中 standalone Tool +通过窄 `ScriptToolRuntime` 真实执行,但这不证明 package plugin、Hook、完整 Client 或 TUI target 已经可运行。 +四个切片当前都只在事实所在 Host 的本地执行域运行;本机 Desktop/CLI 直接使用本机 Host,Peer 控制界面可代理 +Peer Host 的来源发现、审批与冲突决策,并按 Host 身份与工作区隔离结果。该能力不等于 SSH Remote 工作区执行; +SSH Remote 外部来源发现仍明确返回不支持,也不回退读取控制端或 Host 的同名本机来源。MCP +运行实例和工具 route 额外按规范化 workspace 隔离;更新、停用、空闲回收和删除先撤 route,再异步回收连接与进程, +慢启动在发布工具前复核撤销状态。 + +`bitfun.plugin.json` 继续作为 BitFun 原生包格式。现有外部来源轨道已直接发现用户和项目的 Command、standalone +Tool、Subagent 与 MCP 安全子集,包括配置文件中的受支持声明;远程、组织、系统管理员、MDM 和内联内容等完整 +OpenCode 配置来源图尚未接入。后续 package plugin +路径还需直接发现 `.opencode/plugins` 和软件包配置并记录当前执行版本,不得要求作者先复制到 `.bitfun/plugins` +或维护另一份清单。 当前内部契约中的 `PluginRuntimeBinding`、通用派发、状态版本和静态候选可在迁移期保留,但不能据此设计完整 -OpenCode API。真实执行接入后,未被消费的过渡对象应删除或收窄,避免新旧两套调用模型长期并存。 +OpenCode API。package plugin 真实执行接入后,未被消费的过渡对象应删除或收窄,避免新旧两套调用模型长期并存。 ## 9. Remote 与多执行域 diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index e5632c4e18..f2dfb2b35d 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -25,6 +25,13 @@ Do **not** treat cloud session blobs as the Remote data plane. Do **not** merge cloud session metadata into local disk on login or periodic pull — that pollutes A and conflicts with Peer Mode. +Settings sync is continuous on every logged-in host (Desktop, interactive CLI, +and the CLI daemon): local changes upload after a ~5s debounce (content-hash +deduped); cloud changes are pulled at process start and then every ~30s. After +applying or uploading settings, a host fans out `account://settings-applied` +to attached controllers; the controller re-emits it locally so the frontend +config cache and model selectors refresh without reconnecting. + SSH `WorkspaceKind.Remote` remains a separate path (local session mirror + remote FS) and must not be mixed with Peer Device Mode. @@ -42,10 +49,26 @@ FS) and must not be mixed with Peer Device Mode. - Controller: `PeerDeviceTransportAdapter` wraps product `invoke` as `RemoteCommand::HostInvoke` over `account_device_rpc`. -- HostInvoke on the controller is **priority-queued** (max 2 in flight). Session - restore / session-list / dialog / workspace-startup commands outrank background - `git_*` / `ssh_*` / `lsp_*` / `search_*` / FS / canvas / editor RPCs so hydrate - is not starved into relay HTTP 504s. +- HostInvoke on the controller is **priority-queued** with four requests in + flight. Session restore / session-list / dialog / workspace-startup commands + outrank background `git_*` / `ssh_*` / `lsp_*` / `search_*` / FS / canvas / + editor RPCs so hydrate is not starved into relay HTTP 504s. Terminal commands + are always interactive priority, and one slot is kept free from normal and + low-priority work so input cannot be trapped behind slow polling requests. +- Idempotent read HostInvokes use a 10s per-attempt deadline and at most two + exponential-backoff retries. Mutating commands use a 30s deadline and are + not replayed unless both ends share an explicit idempotency contract. + `start_dialog_turn` and `start_acp_dialog_turn` use their stable + `(sessionId, turnId)` identity for bounded retry: the controller reuses the + exact payload, while the host coalesces concurrent attempts and caches the + completed result for the retry window. The controller enables this exception + only when the initial `peer_mode_ping` advertises + `idempotent_dialog_submit`, so mixed-version peers remain single-shot. + Other mutations remain single-shot because a timed-out outcome is unknown. + The desktop `account_device_rpc` command enforces the requested deadline + around the native HTTP future; the controller's Promise deadline is not + merely a UI timer. Failed session-list loads leave the spinner and expose an + explicit retry action. - While Peer Mode is active, background noise is reduced further: - controller-local SSH heartbeats and remote-workspace auto-reconnect pause - Git / FilesPanel window-focus refresh pauses @@ -62,7 +85,20 @@ FS) and must not be mixed with Peer Device Mode. return empty or no-op so hydrate does not fail. - Events: peer agentic projection (and other product events such as terminal / FS / MCP interaction) fan-out as `RemoteCommand::DeviceEvent` to attached - controllers; controller re-emits the same event names locally. + controllers; controller re-emits the same event names locally. This includes + SSH-backed remote PTY Ready / Data / Exit events created on B, not only B's + local terminal service events. +- Because DeviceEvent delivery has no ACK/replay contract, the controller also + reconciles its active chat session from the Peer Host every 3s, immediately + after session/visibility changes, and after detecting a dropped data event. + Realtime events remain the primary path; snapshot reconciliation repairs a + controller that attached after turn/round lifecycle events or crossed a + transient relay gap. The host overlays its authoritative in-memory session + state onto the persisted view so an executing turn is not misclassified as + interrupted history. Continuous host output is checkpointed at least once + per 2s coalescing window. A controller accepts an active snapshot before the + stale-stream deadline only when its rounds, streams, and tools provably move + forward; an older persisted snapshot cannot overwrite newer DeviceEvents. - CLI Peer Host forwards only turns submitted through Peer Host and linked child turns. A background-result follow-up inherits ownership only when its Core-internal metadata identifies the exact tracked parent and source child @@ -84,11 +120,11 @@ FS) and must not be mixed with Peer Device Mode. delivery lease serializes detach or offline removal with the local Relay enqueue attempt. An explicit disconnect still restores the local controller UI, but reports a warning when host cancellation was not confirmed. This - boundary does not change the Relay envelope or add ACK, replay, or reconnect - recovery. -- Relay `POST /api/devices/:id/rpc` waits up to **120s** for the peer response; - reverse proxies in front of the relay must use a matching (or higher) read - timeout or they will return 504 first. + boundary does not change the Relay envelope or add ACK or replay. +- Relay `POST /api/devices/:id/rpc` still permits up to **120s** for generic + callers. Peer controllers normally cancel earlier through their per-command + 10s/30s deadlines; reverse proxies must still accommodate any other caller + that relies on the Relay maximum. ## Workspace directory picking @@ -106,6 +142,17 @@ call `pickWorkspaceDirectory()`: Still use normal `openWorkspace` / create-workspace flows (not SSH `openRemoteWorkspace` / `WorkspaceKind.Remote`). +## File download ownership + +The native save/folder dialog always selects a destination on controller A, +while the workspace source belongs to peer B. A download is therefore a +split-endpoint operation: B returns file bytes through the existing +`GetFileInfo` / `ReadFileChunk` protocol and A writes those chunks through its +local filesystem adapter. Directory downloads enumerate B recursively and +create the corresponding tree on A. Never forward A's selected destination to +B through `export_local_file_to_path`; paths and permissions are host-specific +and may represent a different operating system. + ## Ownership - Desktop host invoke / fan-out: `src/apps/desktop/src/api/peer_host_invoke.rs`, @@ -114,7 +161,21 @@ Still use normal `openWorkspace` / create-workspace flows (not SSH webview bridge). Device routing in `src/apps/cli/src/account.rs` special-cases `HostInvoke` / `DeviceEvent`. Same machine Desktop+CLI share one `device_id`; last `AuthConnect` wins. +- Shared account settings sync engine: + `src/crates/assembly/core/src/service/remote_connect/settings_sync.rs` + (debounced push, 30s pull, persisted cursor); app wiring in + `src/apps/desktop/src/api/remote_connect_api.rs` and + `src/apps/cli/src/account_sync.rs`. - Frontend mode + transport: `src/web-ui/src/infrastructure/peer-device/`, `adapters/peer-device-adapter.ts` - Peer directory picker: `pickWorkspaceDirectory.ts`, `PeerDirectoryBrowser.tsx`, `PeerDirectoryPickerHost.tsx` + +## Regression guards (read before changing session/account paths) + +Frontend invariants and known failure modes: +[`src/web-ui/src/infrastructure/peer-device/README.md`](../../src/web-ui/src/infrastructure/peer-device/README.md). + +Especially: Peer Mode must not call fail-closed `account_fetch_session_turns` +during hydrate; clear stale `currentWorkspacePath` on peer switch; pass live +workspace into `create_session`; keep config HostInvokes high-priority. diff --git a/docs/architecture/platform-portability-design.md b/docs/architecture/platform-portability-design.md index 1d8cd8218f..9bbb7105d9 100644 --- a/docs/architecture/platform-portability-design.md +++ b/docs/architecture/platform-portability-design.md @@ -10,7 +10,7 @@ ## 1. 产品裁决 HarmonyOS PC 是 BitFun CLI/TUI 的目标平台之一,TUI 优先于 HarmonyOS PC GUI。目标产品是用户在 -HarmonyOS PC 真实系统终端中安装并本地执行的原生 `bitfun-cli`: +HarmonyOS PC 真实系统终端中安装并本地执行的原生 `bitfun`(`bitfun-cli` 仅为废弃兼容入口): - TUI 直接使用真实 TTY; - Agent Runtime、模型访问、工作区文件和命令执行均在该 PC 本机运行; @@ -88,7 +88,7 @@ HarmonyOS PC 真实系统终端中安装并本地执行的原生 `bitfun-cli`: ## 5. 当前问题清单 本清单基于上游 `ecad4f843`(2026-07-16)与 -`bitfun-cli` 的 `aarch64-unknown-linux-ohos` 目标依赖解析。依赖解析成功只表示问题已经可见,不表示可以编译、 +Cargo package `bitfun-cli` 的 `aarch64-unknown-linux-ohos` 目标依赖解析。依赖解析成功只表示问题已经可见,不表示可以编译、 运行或交付。 | 问题域 | 当前识别结果 | 主要风险 | 后续专题需要回答 | diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index fd08ce6b9f..5044e37d0b 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -15,7 +15,9 @@ [`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)。详细设计与本文件冲突时,以本文件为准。 +[`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)。详细设计与本文件冲突时,以本文件为准。 本文件只约束稳定边界,不记录单次 PR 进度,也不把未来可能支持的生态能力提前声明为公开接口。 @@ -32,12 +34,15 @@ BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和 4. **OpenCode 是兼容目标,不是内部模型**:适配层尽量保持 OpenCode plugin、hook、custom tool、TUI plugin、 Client、配置和加载顺序的外部可观察行为,但这些类型不能反向成为 BitFun 智能体、配置或界面的内部数据模型。 5. **公开接口有预算**:新增公开 DTO、trait、模块或门面必须同时具备归属模块、真实消费方、版本策略、验证方式和退场条件。 -6. **入口形态受宿主约束**:TUI、GUI、Web 和 SDK 共享能力服务接口和只读视图,不共享渲染句柄、主题键、键位模型或界面状态;插件界面贡献必须先声明目标入口形态,再由对应宿主适配。 +6. **入口形态受宿主约束**:TUI、GUI、Web、Headless CLI 和公开 SDK adapter 共享 Agent Runtime API + 用例、能力服务接口和只读视图,不共享公开语言包、传输、渲染句柄、主题键、键位模型或界面状态; + 插件界面贡献必须先声明目标入口形态,再由对应宿主适配。 7. **产品定制先解析,运行时扩展后加载**:产品身份、能力上限和 GUI/TUI 布局选择在构建/组装期解析;用户配置和插件只能在该上限内扩展,不能反向改写产品事实。 8. **平台差异留在入口和具体能力实现**:target 只选择 ABI,feature 只控制确实可选的依赖;共享内核不按平台 分叉业务语义,也不新增包含所有 OS 方法的总接口。新端口必须有当前调用方。 9. **发现无感,生效按风险分级**:外部用户/项目来源后台发现,不阻塞产品入口;无冲突的低风险声明式内容可自动应用并提供撤销; - 与产品本地能力或独立外部 provider 同名时必须由用户选择,且选择只在候选身份与内容版本不变时复用;可执行来源首次启用或能力扩大时形成非阻塞确认。激活后的本地 OpenCode 扩展默认按当前用户能力 + Command、Tool、Subagent 等可执行来源与产品本地能力或独立外部 provider 同名时必须由用户选择,且选择只在候选身份与内容版本不变时复用。现有 Skill 根继续按已发布顺序解析,并展示来源和默认覆盖项;带模式的管理界面展示应用模式开关后的实际采用项; + 可执行来源首次启用或能力扩大时形成非阻塞确认。激活后的本地 OpenCode 扩展默认按当前用户能力 运行;经 BitFun 能力接口的调用可细分限制,脚本直接文件/网络/进程能力只在真实操作系统或容器边界存在时可 粗粒度收紧,否则停用相应 target。策略降级必须与待确认、解析错误和插件故障分开显示。 10. **开放权限不降低可靠性**:第三方代码始终位于受监督的独立执行进程,具备期限、取消、背压、崩溃回收、 @@ -49,9 +54,12 @@ BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和 直接文件、网络和进程副作用只能依靠真实 OS/容器边界限制。来源/target 的首次选择由产品来源体验保存, 不因此新增对内部准备阶段的重复激活、通用 trusted-folder 模型或独立信任服务。 12. **一个能力核心,多种宿主适配**:Memory、Context、Workflow、Subagent、Tool 等能力只在已有 owner 中按真实 - 第二实现增量开放 Provider/策略装配;对外通过窄能力门面和宿主 adapter 暴露。MCP、Plugin、Hook、SDK 或 - Server 入口不能反向替换状态 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 只有一个行为 + owner;公开 SDK 不成为内部入口的依赖,ACP 和 Headless CLI 也不成为完整 SDK 的别名。 调用路径长度只作为工程成本处理,不作为独立架构目标。允许保留承担兼容隔离、只读视图或能力选择职责的中间层;不允许为了兼容而长期暴露没有消费方的抽象接口。 @@ -61,7 +69,7 @@ BitFun 只保留四个稳定接口切面;工具、事件和权限作为归属 | 切面 | 主要消费方 | 主入口 | 稳定内容 | 禁止暴露 | |---|---|---|---|---| -| 前后端能力服务切面 | GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 客户端 | 能力服务接口 | 命令请求、会话/工作区状态、权限提示、诊断、产物引用、能力状态、事件流、类型化错误、插件状态只读视图 | 内核状态机、执行层内部类型、`PluginRuntimeClient`、主机内部状态、生态原始载荷、Tauri/React/TUI 实现、具体服务提供方、未预算的界面贡献接口 | +| Agent Runtime API / 前后端能力服务切面 | GUI、TUI/CLI、Web、ACP、Server、Remote、公开 SDK adapter | 一组能力 owner 的窄类型化用例接口 | Query/Turn、会话/工作区状态、工具/MCP、权限提示、Hook 结果、诊断、产物引用、能力状态、事件流、用量和类型化错误 | 单一 service locator、公开语言 package、内核状态机、执行层内部类型、`PluginRuntimeClient`、主机内部状态、生态原始载荷、Tauri/React/TUI 实现、具体服务提供方、未预算的界面贡献接口 | | BitFun 与插件切面 | 插件运行时主机、安全控制面、产品组装、生态适配器 | 扩展贡献接口 | 插件来源、启用状态、能力与副作用、真实工具定义、钩子变换、权限要求、界面贡献、诊断和故障事实 | 最终权限结果、最终工具结果、审计写入、内核权威状态、前后端协议 DTO、界面实现代码 | | 插件通用运行时切面 | 智能体内核、执行层、产品组装、插件运行时主机 | 主机内部 ABI | 类型化调用、请求身份、期限、取消、有界队列、健康状态、响应校验和诊断 | SDK 门面、前后端接口、生态适配器对象、worker/subprocess 句柄、产品入口状态 | | 外部生态兼容适配切面 | 来源协调器、能力 owner、插件运行时主机和脚本执行进程内部 | 每生态独立兼容适配层 + 能力专属 provider 契约 | 各生态来源发现、优先级、格式/参数语义、诊断,以及到 Command/Tool/Subagent/Config 等 BitFun 模块的类型化映射 | 跨生态任意 payload、兄弟适配器依赖、生态原始类型泄漏到产品接口、把外部 CLI 作为默认前置依赖 | @@ -110,15 +118,22 @@ client 或未来 CLI/HarmonyOS 计划,不能证明同名 Rust transport adapte ### 2.2 宿主通信契约与 Tauri 薄适配 前后端契约按能力语义归属,不按 Tauri command 名称归属。稳定的请求、响应、状态事实和类型化错误放在对应 -`contracts/*`、Runtime SDK 或能力 owner;Tauri、HTTP/WebSocket、CLI/TUI 与未来平台宿主只负责把各自协议映射到 +`contracts/*`、Agent Runtime API 或能力 owner;Tauri、HTTP/WebSocket、CLI/TUI、ACP 与公开 SDK +Host adapter 只负责把各自协议映射到 这些类型。该规则降低框架耦合,但不要求把每个 Desktop DTO 都搬进共享 crate。 | 层 | 允许 | 禁止 | |---|---|---| -| 能力 owner / Runtime SDK | 类型化请求/响应、状态事实、权限/取消语义、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket envelope | -| Desktop Tauri adapter | 解包宿主状态、构造稳定请求、调用 owner/SDK、把类型化错误映射为 Desktop 协议、投递桌面事件 | 复制业务校验、持有第二份权威状态、把 Tauri 类型传入下层 | +| 能力 owner / Agent Runtime API | 类型化请求/响应、状态事实、权限/取消语义、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket/ACP/SDK Host envelope | +| Desktop Tauri adapter | 解包宿主状态、构造稳定请求、调用对应 Agent Runtime API/owner 接口、把类型化错误映射为 Desktop 协议、投递桌面事件 | 复制业务校验、持有第二份权威状态、把 Tauri 类型传入下层 | | Server / Remote adapter | 路由鉴权、协议 envelope、连接生命周期、背压与取消映射 | 为同一能力另建语义不同的 DTO 或 handler | -| GUI / TUI 消费方 | 依赖入口侧 API interface、稳定读模型或 Runtime SDK;各自保留渲染状态 | UI 组件直接持有平台句柄,或让 React/TUI 状态成为后端契约 | +| GUI / TUI 消费方 | 依赖入口侧 API interface、稳定读模型或 Agent Runtime API;各自保留渲染状态 | 依赖公开 Python/TypeScript SDK、直接持有平台句柄,或让 React/TUI 状态成为后端契约 | + +本文其他章节和历史设计中出现的“Runtime SDK”,如果指 `agent-runtime::sdk`,统一称为 +**Rust Runtime SDK(当前 preview)**;它是共享 **Agent Runtime API** 的当前 Rust 入口。只有 +[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 定义的 Python/TypeScript package 才称为公开 +**BitFun Agent SDK**,其跨进程适配器称为 **SDK Host**。该术语区分不要求机械重命名现有 crate/module,但禁止用 +Rust preview 的存在证明公开 SDK 已交付。 Rust 与 TypeScript 的字段一致性以能力所有者的 DTO 为事实源,不以 Tauri command 参数为事实源。单宿主阶段由 前端基础设施层维护对应接口,并用序列化契约测试锁定字段命名、可选字段和错误形状;达到独立版本化门槛后,才使用 @@ -134,7 +149,7 @@ Desktop command 使用的序列化对象继续留在 `src/apps/desktop`;即使 1. 先确认权威 owner、当前生产消费方、远程/多产品形态语义和现有行为基线。 2. 把稳定事实与请求/响应放到能力所有者的契约模块,并以序列化、错误、取消和行为等价测试锁定。 -3. 让非 Desktop 消费方或第二宿主先通过 Runtime SDK / owner 接口形成真实调用链。 +3. 让非 Desktop 消费方或第二宿主先通过 Agent Runtime API / owner 接口形成真实调用链。 4. 将 Tauri command 收敛为薄 adapter;前端基础设施层负责 `invoke` 映射,UI 组件不直接依赖 Tauri API。 5. 删除重复 DTO、旧 handler 或兼容方法;无法证明等价时保留已标注的兼容边界,不做批量迁移。 @@ -162,11 +177,13 @@ flowchart TB Desktop["Desktop GUI"] Cli["TUI / CLI"] Web["Web / Mobile Web"] - Protocol["ACP / Server / Remote / SDK"] + Protocol["ACP / Server / Remote"] + PublicSdk["BitFun Agent SDK"] end - CapabilitySurface["前后端能力服务切面"] - Projection["入口适配器和只读视图"] + Projection["入口适配器 / 协议投影 / 只读视图"] + SdkHost["SDK Host adapter"] + RuntimeApi["Agent Runtime API\n一组窄 owner / use-case interfaces"] Assembly["产品组装"] subgraph Owners["后端归属模块"] @@ -186,10 +203,15 @@ flowchart TB PlatformPorts["平台端口"] PlatformAdapters["平台和外部系统适配器"] - Entry --> CapabilitySurface - CapabilitySurface --> Projection - Projection --> Owners - Assembly -.-> Projection + Desktop --> Projection + Cli --> Projection + Web --> Projection + Protocol --> Projection + PublicSdk --> SdkHost + Projection --> RuntimeApi + SdkHost --> RuntimeApi + RuntimeApi --> Owners + Assembly -.-> RuntimeApi Assembly -.-> Owners Assembly -.-> Extension Kernel --> Extension @@ -210,10 +232,15 @@ flowchart TB 关键规则: -- 产品入口只消费能力服务接口和只读视图,不直接调用插件主机。 +- 产品入口先经过自己的 adapter/projection,再消费 Agent Runtime API 和只读视图;公开 SDK 只多一层 + SDK Host 跨进程适配。Agent Runtime API 是一组窄 owner/use-case interfaces,不是必须实例化的总门面;adapter + 可以调用对应 owner 的窄接口,但不能访问 manager/内部状态、绕过既有编排或复制业务规则。任何入口都不直接调用插件主机。 - 插件只进入扩展贡献接口,不直接写内核状态、工具结果、权限结果或审计事实。 - 插件运行时主机只负责类型化调用、期限、取消、有界队列、逻辑 target 状态、响应校验和故障状态; 物理进程健康、资源预算与进程树回收属于脚本执行服务。 +- 外部来源的 Command、Tool、Subagent、MCP 仍保留能力专属 DTO 和 owner,但它们的发现调度统一由 + `ExternalSourceControlPlane` 持有;跨 Desktop/TUI/Peer/Server 的控制事实只通过版本化 product-domain 投影共享, + 不复制生态 payload、界面状态机或远端专用 DTO。 - 每个生态适配层独立保留该生态的外部格式、来源顺序和调用语义,并映射到 BitFun 归属模块;它本身不成为新的 业务归属模块,也不能依赖或修改兄弟生态 adapter。通用目录、生命周期协调器和能力 owner 只依赖开放生态 ID、 来源限定身份与能力专属 provider 契约,不按 OpenCode、Codex 或 Claude Code 分支行为。 @@ -232,9 +259,14 @@ Plugin Runtime P0 只验证了 BitFun 专用插件目录中的来源校验、工 不能称为“OpenCode 插件运行时”。详细代码事实集中在 [`plugin-runtime-host-design.md#8-当前实现附录`](extensions/plugin-runtime-host-design.md#8-当前实现附录)。 -与 Plugin Runtime 分离的 Prompt Command 基线已经通过能力专属 provider 契约接入:可发现本地用户/项目 OpenCode -Command,处理跨来源冲突,并在 CLI/TUI 中执行受支持的 prompt-only 模板。该能力不执行 JS/TS,也不能推导 Tool、 -Hook、Subagent 或完整配置兼容已经可用。 +与 Plugin Runtime 分离的四条纵向基线已经通过各自的能力专属 provider 契约接入:Prompt Command 可发现本地 +用户/项目 OpenCode Command、处理跨来源冲突,并在 CLI/TUI 中执行受支持的 prompt-only 模板;standalone Tool +可把受支持的单文件 `.js` 经确认后接入现有 Tool Runtime;Subagent 可把全局/项目声明的安全子集经确认和同名冲突 +选择后接入现有 Task/Subagent owner,并以 generation lease 固定 fresh single-run 调用;MCP 可把受支持的用户/项目 +配置经确认和同名冲突选择后交给现有 MCP owner 运行。四类贡献对象互不复用,主体逻辑不按生态分支。当前仍不表示 +package plugin、Hook、primary agent、外部 agent 续接、SSH Remote 工作区来源发现或完整 +配置兼容已经可用。独立的静态 Hook 目录可以发现并脱敏展示 OpenCode、Claude Code 与 Codex 的本地声明,但不加载 +handler、不授予权限,也不改变这里对 Hook Runtime“尚不可用”的判断。 目标路线不要求 OpenCode 插件作者维护 `bitfun.plugin.json` 或复制到 `.bitfun/plugins`。BitFun 直接发现用户和 项目的 OpenCode 配置、插件目录、工具目录和软件包来源;低风险内容按用户偏好自动应用或先询问,可执行来源在 @@ -257,7 +289,9 @@ flowchart LR 稳定决策如下: - 不启动完整 OpenCode Runtime,也不依赖用户安装 OpenCode CLI;BitFun 实现自己的监督、适配和 Rust 转发层。 - 固定版本 Bun 只负责脚本执行,依赖按冻结 OpenCode 版本的 npm/Arborist 语义准备。 + 当前 standalone Tool 子集通过受监督的 Node.js worker 执行且不安装依赖;未来只有冻结的 package plugin 样例证明 + 确有需要时,才单独裁决 Bun、依赖准备和版本兼容方案。OpenCode v2 当前同时维护 Bun 编译产物与 Node SEA 并行 + 产物,因此 BitFun 不把外部项目尚未收敛的运行时选择提升为 Host ABI 或核心架构约束。 - 用户全局和项目来源自动发现;低风险内容默认无感应用并显示可撤销摘要,可执行来源首次启用或能力扩大时等待 非阻塞确认。确认前不得 import module、启动 worker、读取凭据或产生直接脚本副作用。 - 激活后的本地 target 默认按 OpenCode 语义运行,允许当前用户通常拥有的文件、网络、进程和环境能力;用户、 @@ -280,8 +314,8 @@ flowchart LR 维护,不能因为某一项降级就把整体状态写成“完整覆盖”。 产品内置扩展与用户插件可以复用主机可靠性和最终能力归属,但来源、升级、卸载和产品必要性不同。只有产品 -身份、安全恢复或法律要求等少量明确保护项不可被覆盖;普通内置命令、工具和主题默认可以按 OpenCode 兼容顺序 -被用户扩展替换或关闭。具体规则见 +身份、安全恢复或法律要求等少量明确保护项不可被覆盖;普通内置命令、工具和主题可经用户明确选择被外部扩展 +替换或关闭,不能按注册或适配器顺序静默切换。具体规则见 [`product-customization-blueprint.md#8-产品内置扩展与用户插件`](product-customization-blueprint.md#8-产品内置扩展与用户插件)。 完整能力状态、设计细节和阶段顺序分别见 @@ -319,17 +353,17 @@ flowchart LR 产品形态由产品组装决定,不由插件配置、单个 Cargo feature 或生态适配器临时决定。 -| 产品形态 | 当前 P0 插件能力 | 入口行为 | +| 产品形态 | 当前扩展能力 | 入口行为 | |---|---|---| -| Desktop / product-full | 生产入口仍直接依赖 `bitfun-core/product-full`;当前没有 managed-plugin 管理或 OpenCode 静态预览的生产 UI/调用方 | 共享代码可编译不等于 Desktop 已消费插件能力 | -| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | 本地 Agent 与 Peer Host 路径选择 `DeliveryProfile::Cli`,校验必需 Runtime Service 注册并消费同一 Runtime Parts/SDK;SDK 缺口由单一 Core 兼容门面转发。Peer Host 不再构造第二套调度、持久化或事件 owner。部分注册仍是 compatibility marker,不代表实时探活;插件 binding 明确禁用,不执行 OpenCode 插件代码 | +| Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;Beta“外部 AI 应用”设置消费版本化 control/catalog 同代快照,显示正交生命周期、审批、冲突、诊断和 Host 能力,并可切换易失 Safe Mode;MCP 原生清单优先展示,外部候选保持独立来源、作用域和覆盖状态;Skills 场景显示已发现 Skill 的生态来源、用户/项目作用域与覆盖结果 | 当前四条可执行纵向切片由事实所在 Host 执行;本机 Desktop 使用本机 Host,Peer 控制界面代理 Peer Host,不在控制端回退发现或执行。Safe Mode 只撤下外部 Tool/Subagent/MCP 新调用,不改写来源偏好、不停止发现也不取消在途调用;状态仅在当前 Host 进程/执行域有效,重启或切换 Host 后关闭,所有产品面必须明确提示。Skill 仍使用独立 Registry;受管 package plugin 仍只有静态预览 | +| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,`/extensions` 的 status、refresh、Safe Mode 与 source enable/disable 消费同一 v1 控制 DTO,`/hooks` 异步读取与 Desktop 相同的只读 Hook 快照并遵循既有命令冲突策略,`/tools` 与 `/agents` 继续承载能力专属查看和操作;命令结果复用现有异步事件循环和 Discovery Lane,不新增调度器。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;Hook 的 coverage mapping 只表示契约覆盖,不表示外部 handler 已加载、激活或执行。Safe Mode 操作后重读权威 catalog 再更新命令路由。非交互入口和 SSH Remote 未接入时不得借本机 TUI 路径代执行;其余 Runtime Parts/Rust Runtime SDK、会话和工作区 owner 边界保持不变 | | HarmonyOS PC 原生 CLI/TUI | 未来平台目标,当前未实现 | 目标、问题和风险见平台规约;具体适配另立专题,HAP、手机 Remote App 与远端代执行均不替代 | | HarmonyOS PC GUI | 完整 HarmonyOS PC 支持的另一目标形态,当前未实现 | 与 CLI/TUI 共享稳定能力和 Runtime 语义,但独立设计宿主、界面与发布验证;Web、Remote 或现有 Tauri Desktop 均不能替代 | | HarmonyOS 手机 Remote App | `src/apps/mobile/harmonyos` 是 phone-only ArkTS 远程入口,不持有本地 Rust Agent Runtime | 保持当前能力并按移动端专题独立演进;本轮不提前设计移动 Runtime/TUI/GUI,也不能据此宣称 HarmonyOS PC 本地能力 | -| ACP | CLI 托管的服务端仍以 `bitfun-core/product-full` 作为兼容执行层 | 入口已选择 `DeliveryProfile::Acp` 并消费 Runtime Parts;组装层在入队前原子拒绝忙碌会话,不改变其他产品入口的排队行为;活动会话模型写入走 SDK,会话恢复、模式、模型目录/提供方配置、MCP、客户端与协议生命周期仍留在现有 Core/ACP 归属,不据此宣称完整解耦 | -| Server / Remote | 当前生产路由没有插件状态消费闭环;Remote 插件执行未实现 | 不在本地替远端项目发现、准备或执行插件;未接入时返回明确不支持 | +| ACP | CLI 托管的服务端仍以 `bitfun-core/product-full` 作为兼容执行层 | 入口已选择 `DeliveryProfile::Acp` 并消费 Runtime Parts;组装层在入队前原子拒绝忙碌会话,不改变其他产品入口的排队行为;活动会话模型与模式写入走 Agent Runtime API。`session/load` 先校验和建立临时 MCP,再恢复 Core,在历史回放成功后才发布活动状态;失败会卸载本次内存状态而不删除历史。同 ID 的重叠打开/关闭被明确拒绝。成功的 `session/close` 阻止新轮次、排空队列和后台子会话,再卸载临时 Core 状态并回收 MCP 与连接;失败保留会话所有权和历史,返回可重试阶段。持久化历史仍可重新加载。完整历史、模型/模式目录与配置读取仍留在现有 Core/ACP 归属,不据此宣称完整解耦 | +| Server / Remote | Server 可返回共享 v1 control/catalog 快照,`hostCapabilities` 明确为只读,并在解析 mutation payload 前 fail closed;Peer Host 代理同一读写控制动作与既有能力专属操作,连接旧 Peer 时读路径降级到 legacy catalog、来源启停降级到既有 mutation,Safe Mode 明确要求升级 Host;SSH Remote 工作区的外部来源发现与执行仍未实现 | 控制端用 Host 身份与工作区共同隔离异步结果。Peer Host 只处理 Host 上的真实工作区,不在控制端替远端发现;SSH Remote 未接入时返回明确不支持且不回退本机来源 | | Web / Mobile Web | 依赖现有后端入口,不持有插件执行单元 | 对应 profile 当前为空计划或未接入生产,不能据枚举值宣称独立产品能力 | -| SDK | 仅有 preview 门面、空 profile 计划和测试替身 | 不牵引 `product-full`、具体服务管理器或插件 host ABI;未满足独立嵌入验证前不宣称可发布 | +| Public Agent SDK | Python/TypeScript 产品尚未交付;Rust Runtime SDK 是内部 preview。本地 SDK Host 协议与独立 `bitfun-sdk-host` 进程只有实现候选,在跨内部调用、Headless CLI 与 Host 的同一 Query golden fixture 通过前不称为已交付 preview | SDK Host 独立选择 SDK profile/source;能力集合从共享产品事实生成,不依赖或冒充 CLI。目标为 Python/TypeScript `query()`、client/session 和 callback;未建立语言包、仓库外消费者与 Claude 等价矩阵前不得宣称公开 SDK preview 或可发布 | 对外一级状态统一使用[外部 AI 工作内容设计](extensions/external-ai-work-sources-design.md#7-状态与提示规则)定义的 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用和不可用,并附带 @@ -351,14 +385,16 @@ flowchart LR 事件因果和审计;Slot 的 exclusive、ordered-chain、namespace-union、fallback 或 fan-out 语义必须由能力 owner 明确。 - TUI 与 GUI 不共享内部主题键、键位模型或界面状态;OpenCode TUI 原始键和组件只存在于适配层,转换后由 TUI 宿主消费,不能用构建期布局选择冒充运行时插件兼容。 -- 只有产品身份、安全恢复和法律要求等明确保护项不能被用户扩展覆盖;普通内置工具、命令和主题默认遵循 - OpenCode 兼容优先级。产品内置扩展不能复用用户来源批准或启用记录,产品签名也不能绕过运行时 +- 只有产品身份、安全恢复和法律要求等明确保护项不能被用户扩展覆盖;普通内置工具、命令和主题作为 BitFun + 来源候选保留,跨生态同名时由用户选择,不能按注册顺序静默决胜。冲突界面固定先展示 BitFun 候选,但展示顺序 + 不等于自动选择。产品内置扩展不能复用用户来源批准或启用记录,产品签名也不能绕过运行时 权限、审计和故障隔离。 - GUI/TUI 布局选择不复制主题 schema,不固化动态能力状态,也不携带可执行 UI 或任意构建脚本。 - 新 profile 只有在真实入口消费组装结果、能力可用性和类型化降级后才算接入;仅有枚举、空计划、re-export 或单测不构成产品支持。 - assembly 不得依赖 app crate。relay 的 room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router - 归属 `services/relay-service`,Cargo metadata 实际解析图检查阻止同类依赖回流。embedded TCP bind、静态 fallback - 和任务生命周期暂留 assembly 兼容路径,仍需迁往具体宿主;这项边界修复不构成 HarmonyOS 本地产品支持。 + 归属 `services/relay-service`,Cargo metadata 实际解析图检查阻止同类依赖回流。Desktop embedded relay 的 TCP bind、 + 静态 fallback 和任务生命周期由 `src/apps/desktop` 通过窄 `EmbeddedRelayHost` 端口持有;assembly 只保留连接方式选择、 + 启停顺序和失败回滚。这项宿主接入不构成 CLI、Server、ACP 或 HarmonyOS 本地产品支持。 - HarmonyOS PC 的完整目标同时包含本地 CLI/TUI 与 GUI,当前均不能标记可用;两种宿主分别验收,具体支持证据和禁止替代项以平台规约及各自专题为准。 - 文档、边界脚本和 focused 测试能说明本次变更保护了哪个稳定接口切面,或删除/降级了哪个过宽接口。 diff --git a/docs/architecture/product-customization-blueprint.md b/docs/architecture/product-customization-blueprint.md index 27c314376e..fc6d1d6bfc 100644 --- a/docs/architecture/product-customization-blueprint.md +++ b/docs/architecture/product-customization-blueprint.md @@ -167,7 +167,7 @@ GUI 主题由 Web/TS 主题模块定义,TUI 主题由 CLI/TUI 宿主定义,I | 更新 | 随产品升级和回滚 | 用户或组织独立更新、停用和卸载 | 来源身份/完整性和更新策略允许时自动准备普通候选;软件包版本/完整性未获更新策略覆盖或能力扩大时等待确认;失败时只保留仍合规的健康旧进程,精确旧物化目录可校验时才重建 | | 权限 | 使用同一有效策略;直接脚本副作用受真实 OS/容器边界限制 | 同左 | 同左 | | 执行 | 与其他插件走同一进程隔离、期限、取消和恢复路径 | 同左 | 同左 | -| 冲突 | 普通贡献遵循目标生态顺序;少量产品保护项除外 | 按对应 BitFun 规则 | 按 OpenCode 顺序,可覆盖普通内置贡献 | +| 冲突 | 作为 BitFun 候选保留并在选择界面优先展示;少量产品保护项除外 | 与其他 BitFun 候选一并优先展示 | 生态内按 OpenCode 顺序;跨生态同名时由用户选择,不静默覆盖 | 管理、停用和更新使用包含生态、来源类型、规范化来源地址和 target 的来源限定运行实例身份;声明 `id` 只参与 生态识别和贡献覆盖,不能单独作为管理键。因此同名产品内置、BitFun 原生和 OpenCode 来源可以共存,且状态与 @@ -177,8 +177,8 @@ GUI 主题由 Web/TS 主题模块定义,TUI 主题由 CLI/TUI 宿主定义,I 命令、主题和 Agent 不能仅因“随产品携带”成为保护项。发生保护冲突时,状态页必须显示被保护项、插件来源、 最终结果和替代入口,不能静默丢弃插件贡献。 -用户插件覆盖普通内置贡献只改变当前运行时的名称解析结果,不修改产品组装结果、已签名字节或内置扩展摘要。 -状态页必须同时保留内置来源、覆盖来源和恢复动作;停用覆盖来源后按同一生态顺序恢复内置贡献。保护清单只能 +用户明确选择插件覆盖普通内置贡献时,只改变当前运行时的名称解析结果,不修改产品组装结果、已签名字节或内置扩展摘要。 +状态页必须同时保留所有候选、选择结果和恢复动作;候选集合或行为版本变化后重新选择,不静默切换。保护清单只能 包含上段列出的具体系统项,不能用“产品已签名”把所有内置工具、命令、主题或 Agent 变成不可覆盖项。 必需内置扩展缺失或摘要不匹配时构建失败;运行时不可用时明确报告产品无法启动或功能降级。可选扩展失败不 @@ -211,5 +211,5 @@ GUI 主题由 Web/TS 主题模块定义,TUI 主题由 CLI/TUI 宿主定义,I 3. GUI/TUI 只消费各自布局和主题字段,一端字段不会进入另一端。 4. 主应用、CLI、安装器和 updater 使用一致的产品身份、更新渠道和签名公钥引用。 5. 两个产品的配置、日志、凭据引用、插件状态和更新状态保持隔离。 -6. 普通内置扩展遵循 OpenCode 覆盖顺序;保护冲突、停用、失败和回退可解释。 +6. 同名候选先展示 BitFun、再稳定展示其他生态;跨生态选择、保护冲突、停用、失败和恢复均可解释。 7. 用户配置和插件不能提高产品能力上限、改变产品身份或继承产品签名信任。 diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 9e0054eec8..01c4f720a9 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -29,7 +29,7 @@ - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO、framework-neutral Agentic frontend event projection 和 platform-neutral `EventEmitter` trait;当前 Tauri transport 只负责 delivery。 - `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、remote chat image metadata / display helper projection、remote image lifecycle attachment mapping、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、debug-log 文件追加 / 脱敏 / HTTP dispatch、review-platform provider service / token store / HTTP transport / Git provider integration,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper、lifecycle status policy 和 MCP OAuth credential vault / store / authorization bootstrap;core 仍保留 debug-log HTTP ingest server、persisted turn adapter 和 MCP auth 的产品 data-dir 注入、授权入口、错误映射与 deprecated 兼容 wrapper。 -- `relay-service` 已承接 room/device 状态、account/sync 存储、asset store 和 HTTP/WebSocket router;standalone app 保留 bind、环境配置、静态 fallback、进程生命周期和管理 CLI,embedded 入口复用同一 router。embedded 的 bind、静态 fallback 和任务生命周期仍在 assembly 兼容路径,尚未完成宿主归位。 +- `relay-service` 已承接 room/device 状态、account/sync 存储、asset store 和 HTTP/WebSocket router;standalone app 保留 bind、环境配置、静态 fallback、进程生命周期和管理 CLI,embedded 入口复用同一 router。embedded 的 bind、静态 fallback 和任务生命周期已由窄 `EmbeddedRelayHost` 端口迁至 Desktop,assembly 只保留产品启停顺序和失败补偿。 - `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。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index d048b4de12..d4600bc5f5 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -27,8 +27,8 @@ | Server / Remote / Web / Mobile Web / SDK profile | 当前为空计划、未接入入口或仅有 preview 测试 | 不得据枚举值宣称产品能力已交付 | | Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v1 preview 门面和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用 envelope 或静态候选扩张稳定 ABI | -| Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded 宿主逻辑仍在 assembly 兼容路径 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位是独立后续工作 | -| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期进程冒烟,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;resize 静默期已有状态单测,活动流式内容窄屏 reflow 已有 TestBackend 回归,真实模型/PTY 活动 turn、终端故障注入与 Patch I/O 失败仍需补齐 | +| 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 级终端故障注入仍需补齐 | ## 3. 目标依赖与归属 @@ -51,17 +51,20 @@ Relay 已按该规则完成首个修复;后续共享实现仍不得以 app cra 2. standalone relay app 和嵌入式入口共同依赖该 owner,`assembly/core -> apps/relay-server` 已删除。 3. crate 层级依赖已增加 Cargo metadata 通用检查和反向用例,不再只保护已知 crate 名称。 -共享 owner 与 Cargo 方向的退出条件已满足:standalone 与 embedded 入口共用同一已测试 router,Cargo 图不再包含 -assembly → apps。embedded 的 bind、静态 fallback 和任务生命周期移出 assembly 是独立后续工作。 +共享 owner、Cargo 方向和宿主归位的退出条件已满足:standalone 与 embedded 入口共用同一已测试 router,Cargo 图不再包含 +assembly → apps;embedded 的 bind、静态 fallback 和任务生命周期由 Desktop 通过 `EmbeddedRelayHost` 持有。 ### 4.2 切换 CLI 纵向路径 CLI 是首个入口迁移对象,因为它已有独立产品诉求、显式设计和最小 CI 命令。 当前纵向切片已经完成:入口只提交一次 `DeliveryProfile::Cli`,通过现有 `ProductAssembler` 获得计划、服务可用性、 -Harness 和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。会话创建(包括 -`exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/ -删除、会话模型更新、轮次提交和取消走 Agent Runtime SDK;SDK v1 缺口集中在一个 Core 兼容门面。Agentic Event Queue 仍是唯一 +Harness 和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。主会话客户端的创建(包括 +`exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、本地分支、用量生成、 +会话模型更新、ACP 活动会话模式更新、轮次提交/取消和精确轮次结算均走 Agent Runtime SDK;Desktop 与 Peer Host 的本地工作区准备、 +会话文件清单、类型化快照统计和工作区文件回滚通过不属于 SDK 的窄 owner port 复用现有 Core 快照实现;Desktop 保留既有远程空结果, +Peer Host 返回明确不支持错误,历史维护仍留在宿主; +富历史及 Peer Host/ACP 的其余维护等未迁移能力继续集中在一个 Core 兼容门面。Agentic Event Queue 仍是唯一 owner,各入口只建立独立广播订阅,有界兼容队列满载不再阻断广播。TUI 与 Exec 审批均为调用级策略,不写全局 配置;CLI 本地路径不获取具体 PersistenceManager。交互、执行和管理入口分别控制 Peer Host/MCP 生命周期,管理查询不启动 这两类外部服务。结构化输出复用现有 Agentic envelope;会话 ID 与 @@ -72,13 +75,15 @@ Peer Host 的 Runtime 接入和跨 Relay/Desktop/Web 的协议切换保持独立 下一步按独立纵向切片推进: -1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小分支、用量、快照和持久化维护兼容面;会话模型更新已迁移,模型目录与配置仍保留在产品入口。 -2. 继续迁移 ACP 尚未接入 SDK 的持久化历史、模型目录/模式和 MCP 操作;ACP stdio 与协议投影生命周期保留在接口入口。 -3. 继续按真实故障样例拆分 TUI 副作用边界,不以大规模重写替代现有回归保护。 +1. 继续以真实调用方和行为等价测试逐项缩小 Peer Host 持久化维护兼容面;本地快照窄端口不扩张为远程快照、完整 checkpoint/rewind 或 Runtime SDK 能力,远程分支另行定义身份和存储语义,模型目录与配置仍保留在产品入口。 +2. ACP 完整历史继续使用单次 Core 兼容恢复;在出现第二个真实消费者及经过授权的附件读取能力前,不为协议回放扩张通用 transcript。模型/模式目录与配置读取、MCP、ACP stdio 和协议投影生命周期继续留在各自现有 owner。 +3. 继续按真实故障样例拆分 TUI 副作用边界;当前切片已覆盖本地确定性流式模型夹具驱动的 Linux PTY/Windows ConPTY Chat resize/取消、 + `exec` Ctrl+C、本地模型 HTTP 403 授权拒绝、流中断后的重试失败、`stream-json` Patch 写入失败和终端恢复错误聚合, + 不以大规模重写替代现有回归保护。 当前 assembly 切换条件已经满足:CLI 生产入口消费真实组装结果,目标链路没有第二套状态,独立测试与三平台 -编译门禁存在,启动页 PTY/ConPTY 生命周期与发布归档冒烟测试已接入门禁。CLI-P0 整体退出条件尚未满足; -真实模型交互、真实 PTY 活动 turn 的 resize、终端故障注入、兼容门面退出以及 ACP/Desktop 切换仍需分别验收。 +编译门禁存在,启动页 PTY/ConPTY 生命周期、Chat resize/取消、`exec` Ctrl+C、本地模型 403/断流失败终态、Patch I/O 失败与发布归档冒烟测试已接入门禁。 +CLI-P0 整体退出条件尚未满足;真实供应商审批流、OS 级终端初始化故障注入、兼容门面退出以及 ACP/Desktop 切换仍需分别验收。 ### 4.3 依次切换 ACP 与 Desktop @@ -123,7 +128,7 @@ Core 只为插件兼容提供已有 owner 的窄接口:真实工具、类型 |---|---| | 文档与仓库边界 | `pnpm run check:repo-hygiene`,`node --test scripts/check-core-boundaries.test.mjs`,`node scripts/check-core-boundaries.mjs` | | 入口 profile 迁移 | 对应 app 的 check/test、入口级 smoke、profile/服务可用性断言、旧路径等价用例 | -| Relay 共享 owner / Cargo 方向 | standalone 与 embedded focused tests、Cargo 依赖方向失败用例;embedded 宿主归位另行验证生命周期等价 | +| Relay 共享 owner / Cargo 方向 | standalone 与 embedded focused tests、Cargo 依赖方向失败用例、Desktop 宿主启停/失败回滚/静态缓存行为测试 | | Agent Runtime / SDK | `cargo test -p bitfun-agent-runtime`,最小 no-`bitfun-core` 嵌入测试 | | 插件首个执行切片 | runtime ports、Host、adapter、Tool Runtime 与真实冻结 fixture 的端到端调用 | | CLI | `cargo check -p bitfun-cli`,`cargo test -p bitfun-cli`,结构化协议和 package smoke | diff --git a/docs/plans/edit-constraint-guard-plan.md b/docs/plans/edit-constraint-guard-plan.md new file mode 100644 index 0000000000..dc8b5c4212 --- /dev/null +++ b/docs/plans/edit-constraint-guard-plan.md @@ -0,0 +1,118 @@ +# Edit Constraint Guard(编辑约束守卫)设计 + +## 目标 + +当用户明确要求“不修改某些文件、目录或文件类型”时,产品应在文件真正写入前执行该约束,并向 +agent 返回可解释的拒绝原因。约束属于用户会话状态,必须在恢复、fork 和回滚后保持一致。 + +该能力是通用产品行为,不承担评测环境隔离职责。 + +## 边界 + +产品内负责: + +- 从每条新的用户指令中抽取“哪些文件不可修改”的约束; +- 将约束持久化为会话状态,并支持显式追加和撤销; +- 在直接文件工具、shell/WriteStdin、Git 工作树操作和递归删除前执行确定性检查; +- 按显式 opt-in 记录决策和成功文件操作,便于诊断误判与遗漏。 + +产品内不负责: + +- 屏蔽 WebFetch、WebSearch、GitHub、Sourcegraph 或任意代码托管域名; +- 禁用 Git 网络、远端引用、commit hash 或历史读取; +- 根据 benchmark、job 名称、环境变量或分支切换工具能力; +- 最终 patch 过滤、基准泄漏判定或评分。 + +网络出口、仓库历史净化和评测审计属于评测管道。产品工具保持普通用户预期的 Web 与 Git +能力,guard 代码不得出现 benchmark 名称或站点黑名单。 + +## 模块与职责 + +| 模块 | 职责 | +|---|---| +| `edit_constraint_guard.rs` | 对外兼容入口;抽取编排、确定性决策、递归删除检查、telemetry | +| `edit_constraint_guard/model.rs` | 可序列化约束、抽取记录、会话状态合并与回滚 | +| `edit_constraint_guard/shell_targets.rs` | 从常见 shell 命令中识别显式文件变更目标 | +| `session_manager.rs` | 状态持久化、恢复、fork 继承与按存活 turn 回滚 | +| 文件与 shell 工具的 `validate_input` | 在执行前调用 guard;不复制约束策略 | + +`edit_constraint_guard.rs` 保留既有 public re-export,避免状态类型的存储路径因内部拆分而 +改变。模型、解析器和执行编排之间只传递结构化约束或路径列表。 + +## 状态模型 + +每条约束包含稳定 id、人类可读描述、来源、操作范围和一个受限 matcher: + +- `test_files`:常见测试文件与测试目录约定; +- `path_contains`:路径包含明确字面值; +- `path_under_dir`:路径位于明确目录下; +- `extension`:文件扩展名匹配; +- `unmatched`:只记录,不执行。 + +操作范围为 `all` 或 `delete_only`。自由正则和自由 glob 不进入持久化契约,避免模型生成 +不可预测的匹配规则。 + +状态同时保存: + +- fork 时继承的 active constraints 与 agent-created paths 基线; +- 每次抽取的输入摘要、状态、耗时、模型输出摘要和失败原因; +- 生效、撤销及无法匹配的撤销 id; +- agent 创建文件的路径和创建它的 dialog turn。 + +父会话状态在 fork 时固化为子会话基线。子会话回滚从该基线开始,只重放仍然存活的子会话 +turn 抽取与文件来源记录;旧格式中缺少 turn id 的来源记录不授予豁免。 + +## 生命周期 + +1. execution engine 对每个不同的用户 turn 处理一次约束抽取。 +2. 明确的测试文件禁改措辞先由确定性句法规则兜底;规则要求禁止词、变更动作与测试文件目标 + 形成直接关系,不以关键词共现推断约束。fast 模型补充其他 matcher,并返回显式撤销。 +3. 有约束、发生模型尝试或抽取失败时才将结果写入 session metadata;普通无信号 turn 不产生 + guard 状态,也不触发 metadata 重写。抽取失败单独记录,执行阶段 fail open。 +4. 文件工具在 `validate_input` 中调用统一检查;shell、WriteStdin 和 Git 工具先将可静态识别的 + 变更目标解析为路径。存在 active constraint 时,无法解析目标的高风险变更命令 fail closed。 +5. 命中约束时返回 403 和结构化 `edit_constraint_guard` 元数据,不执行写入。 +6. 设置 `BITFUN_EDIT_CONSTRAINT_TELEMETRY=1` 后,guard 决策和成功的直接文件工具操作写入 + session-scoped JSONL,用于产品诊断;默认不创建 JSONL。该开关不影响 AI provider 请求审计。 + +撤销只接受当前 active constraint id,且只有真实用户提交的 turn 可以授权撤销。含糊表达、 +未知 id 或模型解析失败都不会放宽既有约束。 + +## 执行规则 + +- matcher 执行是确定性的,不在每次工具调用时请求模型。 +- 普通无约束会话在工具热路径上不解析 shell、不解析路径、不查询远程文件是否存在,也不写 + session metadata 或 JSONL;只有 TestFiles 全操作约束需要维护 agent-created 文件来源。 +- `force` 不是模型可用的逃生口;旧调用携带 `force` 时拒绝并记录。 +- “不要修改测试”允许新建 agent 自己的测试辅助文件,也允许后续修改或删除该辅助文件。 +- `delete_only` 约束不因 agent-created provenance 放宽。 +- 递归删除先检查目标和所有非符号链接后代;存在生效约束时,无法完成检查则 fail closed。 +- shell 解析优先检查明确目标;存在 active constraint 时,变量/glob 目标、交互式 shell、未知 + archive/patch 内容及其他无法确定影响范围的高风险变更命令在执行前拒绝。无约束会话不改变 + 普通 shell、WriteStdin 或 Git 行为。 + +## 关键不变量 + +- 无 active constraint 时,guard 不改变普通编辑、Web 或 Git 行为,也不执行路径/远程存在性检查。 +- 同一 dialog turn 和消息 hash 不重复抽取。 +- 未知撤销 id 永远不删除约束。 +- session restore 与 fork 后的 active constraints 与父会话一致。 +- rollback 后不能保留来自已删除 turn 的约束或 agent-created 豁免。 +- 拒绝发生在写入前,递归删除不会出现部分删除后才发现受保护文件。 +- telemetry 默认关闭;启用后的写入失败不能导致普通工具操作失败。 + +## 验证策略 + +最小测试集覆盖: + +- matcher 规则和 operation scope; +- 确定性抽取、模型解析失败、合法与非法撤销; +- 新建测试辅助文件、已有测试文件和 delete-only 的差异; +- shell 重定向、`tee`、`cp`、`mv`、`rm`、in-place `sed/perl`、Python、Node、WriteStdin、 + Git pathspec 与无法解析目标的高风险命令; +- 递归删除、符号链接、远程工作区检查失败; +- session 持久化、fork、回滚和旧 schema 兼容; +- 普通 Web/Git 命令不被评测策略拦截。 + +Rust 变更至少运行该 crate 的 focused tests 和 `cargo check --workspace`。行为边界变化必须先 +更新本文,再改实现;大提交的说明应记录动机、边界、失败策略和验证结果。 diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index f47d89d1f7..4dabe513d2 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -6,10 +6,12 @@ [能力装配与宿主集成设计](../architecture/extensions/capability-runtime-integration-design.md)。本计划只覆盖外部 OpenCode 能力进入 BitFun 的渐进导入轨道,不代表 BitFun 能力导出到 OpenCode 已经完成。兼容矩阵是审计库存,不是默认路线图。 -PR1 已将基线推进到通用外部来源目录、生命周期协调器和 OpenCode Prompt Command 纵向切片;BitFun 原有受管 -插件包来源确认和 custom tool 静态预览继续保留。后续不沿用“先做一个大而全的 OpenCode Plugin Runtime”路线, -而是沿用稳定的跨生态来源契约,按 Tool、Subagent 分别交给真实能力 owner。每个 PR 都必须产生用户可直接验证的 -结果,同时不提前承诺尚未执行的生态能力。 +PR1 已建立通用外部来源目录、生命周期协调器和 OpenCode Prompt Command 纵向切片,PR2 已把受支持的单文件 +`.js` standalone Tool 接入现有 Tool Runtime,PR3 已把 Subagent 安全子集交给现有 Subagent owner,PR4 补齐现有 +Skill 多来源的身份与覆盖状态展示。BitFun 原有 +受管插件包来源确认和 custom tool 静态预览继续保留,但不等同于 OpenCode package plugin 可执行。Command、Tool、Subagent +三个可执行切片均沿用 +稳定的跨生态来源契约,不建设“大而全的 OpenCode Plugin Runtime”,也不提前承诺尚未执行的生态能力。 ## 1. 稳定架构基线 @@ -21,7 +23,7 @@ Product surfaces (Desktop / CLI / TUI) -> consumes capability-specific provider contracts -> Prompt Command provider contract -> Tool provider contract + provider-neutral script runtime port - -> future Subagent provider contract + -> Subagent provider contract Same-level ecosystem adapters implement those provider contracts -> OpenCode adapter @@ -35,7 +37,7 @@ Product Assembly registers adapter implementations with the coordinator OpenCode/Codex/Claude Code 分支业务行为。 - 每个生态适配器独立维护自己的路径发现、优先级、格式、参数展开和版本兼容语义;兄弟适配器之间不得依赖、复用 私有类型或借用对方身份。 -- Product Assembly 是唯一选择具体适配器的地方。Desktop、CLI/TUI、来源目录和能力 owner 只依赖稳定契约。 +- Product Assembly 是唯一选择具体适配器的地方。Desktop、交互式 TUI(ChatMode)、来源目录和能力 owner 只依赖稳定契约。 - 不建立携带任意 payload 的 `ExtensionAsset`、通用脚本 SDK 或跨生态配置对象。Command、Tool、Subagent 分别走 类型化贡献接口;新增一种能力不能迫使既有能力改写公共对象。 - 来源目录按“提供者 + 来源限定身份”协调代次,provider discovery 独立并发且有期限;某个适配器解析失败、升级 @@ -59,9 +61,10 @@ Product Assembly registers adapter implementations with the coordinator | PR | 用户可观察结果 | 新增能力 owner | 明确不包含 | |---|---|---|---| -| PR1:来源目录 + OpenCode Command | Desktop 可查看、抑制/恢复并刷新全局/项目 OpenCode 来源;CLI/TUI 可列出并执行支持的 `/command`;运行中修改、删除、恢复后自动刷新 | 通用来源目录与生命周期协调器;Prompt Command 契约;OpenCode Command adapter | JS/TS Tool 执行、Hook、MCP、OpenCode Client/Server、Subagent 执行、复制式导入 | -| PR2:OpenCode standalone Tool | 一个真实、受支持的单文件 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | -| PR3:OpenCode Subagent | 全局/项目 agent 定义进入现有 Subagent owner,可选择、调用、更新和撤下;unsupported 字段有明确诊断 | 现有 Subagent owner + 独立 Subagent 兼容接口 | 原始 OpenCode 会话内核、完整 primary-agent 替换、跨产品通用 agent JSON | +| PR1:来源目录 + OpenCode Command(已实现) | Desktop 可查看、抑制/恢复并刷新全局/项目 OpenCode 来源;交互式 TUI(ChatMode)可列出并执行支持的 `/command`;运行中修改、删除、恢复后自动刷新 | 通用来源目录与生命周期协调器;Prompt Command 契约;OpenCode Command adapter | JS/TS Tool 执行、Hook、MCP、OpenCode Client/Server、Subagent 执行、复制式导入 | +| PR2:OpenCode standalone Tool(已实现) | 一个真实、受支持的单文件 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | +| PR3:OpenCode Subagent(已实现) | 全局/项目 agent 定义经一次非阻塞确认后进入现有 Subagent owner,可选择、单次调用、更新和撤下;同名冲突由用户选择,unsupported 字段有明确诊断 | 现有 Subagent owner + 独立 Subagent 兼容接口 + generation lease | 原始 OpenCode 会话内核、完整 primary-agent 替换、外部 agent 续接、跨产品通用 agent JSON | +| PR4:Skill 来源与覆盖状态(本轮) | GUI/TUI 对已发现 Skill 显示生态来源;被现有优先级覆盖的同名项继续可见,并标明较高优先级来源;模式配置按实际选择结果标明当前覆盖来源 | 现有 Skill Registry;只补 provider-neutral 来源元数据和宿主展示 | 修改 Skill 优先级、引入审批弹窗、复制导入、URL/脚本执行、把 Skill 并入外部来源协调器 | Tool 与 Subagent 不复用 Command 的贡献对象,只复用来源身份、状态、代次、诊断和观察生命周期。未来接入 Codex 或 Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修改 OpenCode adapter 来容纳其他生态。 @@ -78,7 +81,7 @@ Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修 `template`、`description`。Markdown 已知字段按当前 OpenCode schema 校验,类型错误不得静默丢弃;同时保留 OpenCode 对未引用冒号值的兼容重试。 - 保留 OpenCode 生态内部的名称和覆盖顺序;独立 provider 之间或与 BitFun 本地能力同名时不得按适配器优先级静默决胜, - 必须生成版本敏感的冲突指纹并等待用户选择。候选版本不变时只询问一次,更新后重新询问。CLI/TUI 将跨 provider + 必须生成版本敏感的冲突指纹并等待用户选择。候选版本不变时只询问一次,更新后重新询问。交互式 TUI(ChatMode)将跨 provider 候选投影为 `/external::` 明确选择项;一次显式选择同时解决同名 BitFun 本地命令,不连续确认。 - 支持 `$ARGUMENTS` 与 `$1`、`$2` 等位置参数展开。显式选择或输入 `/command ...` 本身就是本次 prompt-only 命令的用户确认;发现阶段不自动向会话发送内容。 @@ -130,7 +133,7 @@ Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修 - OpenCode fixture 固定 XDG 全局/项目、`config.json`、单复数目录、JSON/JSONC、Markdown、路径去重、覆盖、参数展开、 大小/数量上限和受限字段。 - watcher 覆盖创建、连续写入、原子替换、稳定删除与重新出现;目录切换不阻塞 TUI 输入。 -- CLI/TUI 覆盖列表、跨 provider 候选选择与直接输入、本地同名冲突只询问一次、版本变化后重新询问、受限命令提示 +- 交互式 TUI(ChatMode)覆盖列表、跨 provider 候选选择与直接输入、本地同名冲突只询问一次、版本变化后重新询问、受限命令提示 和刷新后撤下;首次发现期间未限定别名不误路由,候选删除后不静默切换到剩余外部或内建实现。 - Desktop 覆盖空状态、部分失败、刷新、抑制/恢复、敏感路径缩略显示及 IPC 摘要不包含模板正文。 - 通过相关 crate tests、Web focused tests、`type-check:web`、仓库 hygiene 与 core boundary 检查。 @@ -157,8 +160,9 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo ### 4.2 产品与决策语义 - Desktop 在“外部 AI 应用”中显示来源、文件、工作目录、工具名和直接文件/网络/环境/进程能力,并明确提示当前 worker - 不是 OS 沙箱。CLI/TUI 使用同一快照:状态栏只做一次非阻塞提醒,`/external-tools` 提供静态预览以及 - `enable`、`disable`、`choose`、`refresh` 操作;等待处理不阻塞输入或普通会话。 + 不是 OS 沙箱。交互式 TUI(ChatMode)使用同一快照:`/extensions` 提供共享状态、刷新和 Safe Mode,状态栏只做一次非阻塞提醒,通用 `/tools` 入口以“外部 AI 应用” + 分组提供静态预览以及 `enable`、`disable`、`choose`、`refresh` 操作;等待处理不阻塞输入或普通会话,不再注册平行的 + `external-*` 命令。 - 首次启用键由“来源限定 target + 执行域 + runtime + 能力集合”组成。纯内容更新且能力集合不变时复用已批准 结果;能力、runtime 或执行域扩大时重新确认。用户选择保持停用后,同一内容版本不再主动询问;来源内容更新后 才形成新 decision key。Desktop 仍允许用户主动重新审核,避免“一次拒绝后永久不可恢复”。 @@ -166,7 +170,7 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo 身份与内容版本;候选来自静态识别定义而不是成功加载集合。选择前保留已有本地实现,选择后只在候选集合和版本 不变时复用;任一候选更新、删除或暂不可用后重新询问,已选 external 失效期间不回退同名内置/MCP。 - Desktop IPC 和 TUI 快照只包含静态摘要与决策 key,不传输模块源码。用户选择落盘使用 PR1 的跨进程锁、锁内 - 合并和原子替换;Desktop 与 CLI/TUI 不分别维护偏好。 + 合并和原子替换;Desktop 与交互式 TUI(ChatMode)不分别维护偏好。 ### 4.3 更新、删除与降级 @@ -180,7 +184,8 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo 4. 每 target 持久 worker 的普通请求 30 秒无响应时终止;合作取消在 500 ms 后硬终止,输出限制 1 MiB、协议帧 限制 8 MiB;产品配置的更短 Tool 期限丢弃调用 future 时也会终止 worker,并在终止完成前保持串行许可。调用不 自动重放。Node 进程仍以当前用户权限运行;VM realm 和隐藏响应令牌不是安全 - 沙箱,本阶段也没有进程树/Job Object,直接创建的后代进程和系统资源不保证被回收,产品必须持续显示残余风险。 + 沙箱。当前脚本 worker 与 local stdio MCP 已共享 Unix process group / Windows kill-on-close Job Object 进程树回收; + 它不限制文件、网络、CPU、内存或逃逸行为,产品必须持续显示残余风险。 worker 丢失由带独立加载代次的 runtime health 事件立即撤下路由并显示失败;旧 worker 的迟到事件不能撤下同内容 新实例。下一次 catalog 暴露前仅恢复一次,失败后等待显式刷新或来源变化;不回退 同名内置/MCP 实现,也不形成自动重启循环。单个来源目录不可读只降级该目录,其他健康目录继续生效。 @@ -195,7 +200,7 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo - 脚本运行时覆盖 load/invoke、内容更新、失败更新撤下、合作式取消、阻塞事件循环硬终止和 dispose。 - Tool 路由覆盖内置/MCP/多外部候选冲突、按工作区选择、候选更新重问、稳定删除、源级隔离、零 route mux 并发注册 和 worker-lost 撤路由/单次恢复;首次后台刷新与 catalog 竞态覆盖等待、成功复用和失败重试。 -- Desktop 覆盖非阻塞发现、首次审批、主动重新审核、停用、冲突选择和不可用原因;CLI/TUI 覆盖提示去重、编号到 +- Desktop 覆盖非阻塞发现、首次审批、主动重新审核、停用、冲突选择和不可用原因;交互式 TUI(ChatMode)覆盖提示去重、编号到 稳定 key 的映射、过期选择拒绝和刷新。通过相关 Rust tests、CLI check/tests、Web focused tests、 `type-check:web`、i18n audit、repo hygiene 与 desktop/core checks。 @@ -208,19 +213,141 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo - 为来源目录瞬时不可读增加类型化 `unknown/last-good` 状态;只有精确物化内容仍可校验时才继续服务,明确删除、 停用和权限收紧仍立即撤下。 - 为保留的零 route mux 增加数量/命中指标;只有证明长期积累后再设计不重新引入注册竞态的安全回收。 -- 统一 Desktop 与 CLI/TUI 的 `load_failed` 恢复文案,并让手动刷新产生的新 diagnostics 保持非阻塞可见;不因此 +- 统一 Desktop 与交互式 TUI(ChatMode)的 `load_failed` 恢复文案,并让手动刷新产生的新 diagnostics 保持非阻塞可见;不因此 引入跨 GUI/TUI 组件协议。 ## 5. PR3:OpenCode Subagent -PR3 为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 映射 agent Markdown/JSON 定义。它不复用 Tool +PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 映射 agent Markdown/JSON 定义。它不复用 Tool 运行时,也不把 OpenCode agent 类型提升为跨生态 DTO。 -- 支持的 prompt、description、模式和工具选择进入现有 Subagent 定义;未支持字段显示明确诊断。 -- 全局/项目覆盖、来源抑制、更新和删除复用通用来源生命周期。 -- 选择与执行仍由现有会话/Subagent owner 决定;adapter 不能替换 BitFun Agent Kernel。 - -## 6. 暂停条件 +- 支持用户全局、显式配置目录和项目 JSON/JSONC `agent`,以及单复数 agent Markdown 目录;按 OpenCode 稳定 + 顺序深合并并保留有序 provenance。legacy mode 与 primary-only 定义可诊断但不激活。 +- 安全子集为 description、prompt、subagent/all、disable、hidden、可精确解析的 model 和 tool 选择。permission、 + variant/options、采样参数、steps/maxSteps 等不能等价执行的字段 fail closed,不做“忽略后继续运行”。 +- OpenCode adapter 把 model 解析为类型化的 provider 提示和模型名;Subagent owner 在审批前将其或固定默认项物化为 + 唯一、已启用的 BitFun 模型。继承、歧义、缺失或已停用模型保持不可用,不使用动态回退替代明确审批。 +- 首次启用绑定当前 behavior、provenance、具体模型和工具范围;只有影响展示的 catalog 文案更新不重复询问, + 行为或能力变化重新确认。与 builtin/user/project 或其他 provider 同名时保持不可用,直到用户选择;候选变化后 + 旧选择失效,即使只剩一个候选也不静默回退。 +- BitFun 模型配置变化会非阻塞重建后续调用的 generation;审批绑定具体配置 ID 和运行配置指纹,同一 ID 下的 provider、 + 模型名或 endpoint 变化也要求重新确认。已物化 ID 不再解释为 `inherit/primary/fast/auto/default` 选择器;旧 lease 保留 + 旧绑定事实,若执行时配置已漂移则安全失败,不静默切换模型。 +- fresh Task 在进入调度前固定不可变 runtime generation,并以 lease 保持到完成、取消、超时或提交失败;来源稳定 + 删除、显式停用或抑制会立即阻止新调用,短暂读取失败最多在有界窗口内沿用 exact last-valid。 +- Desktop 的“外部 AI 应用”设置和 TUI `/agents` 中的“外部 AI 应用”选项使用同一 generation/revision 校验的 + 非阻塞审批与冲突操作;同一工作区的 Agent 决策串行提交,完成后刷新权威状态并说明具体对象。冲突候选在选择前原位展示模型、 + 工具、执行域、安全来源标签、兼容影响和恢复动作,一次点击才原子完成“选择并批准”。Agents 场景只显示已激活的 + 只读 `External · provider`、`Single-run` 投影,并可跳转到统一管理入口;外部 prompt、绝对用户路径和内部行为摘要 + 不进入 IPC 或普通列表摘要。 +- GUI/TUI 只按通用资源类型路由诊断,不识别 `opencode.*` 前缀;产品快照统一生成安全来源位置,界面不解析 `.opencode` + 等生态私有目录。已启用 Agent 因更新、删除或冲突变为不可用时,Desktop 与 TUI 都给出去重的非阻塞提示。 +- 冲突谱系覆盖 0/1/N 个参与者;参与集合缩减后继续保持逻辑名不可用,等待用户重新选择。自动观察到的新冲突指纹 + 与用户决策使用同一跨进程锁并推进 `preference_revision`,因此其他进程基于旧 revision 的审批或选择必然被拒绝。 +- 当前 Remote 工作区明确不支持外部来源发现与决策,不读取本机同名配置;静态 system prompt 未修改,只有审批后 + 的通用 AgentInfo 动态投影进入现有可用 agent 上下文,且该投影使用 BitFun 自有的稳定描述,不注入来源 catalog 文案。 + +选择与执行仍由现有会话/Subagent owner 决定;adapter 不能替换 BitFun Agent Kernel。外部 agent 当前只支持 fresh +单次调用,前台结果不返回续接入口,历史 external runtime session 的 follow-up 会被类型化拒绝。 + +### 5.1 已实现路径的稳定性收敛 + +本轮不新增扩展类型,只修复会误导用户或掩盖真实故障的两处问题,并校正文档事实: + +- `NodeScriptToolRuntime` 在共享外部 Tool 运行时首次初始化时解析 Node.js 可执行文件,并在当前进程内持有成功结果; + 只有缓存仍为空时,后续可用性检查或加载才重新执行当前进程可见范围内的 `which(node)`;可用性还会在 2 秒期限内 + 实际执行 `node --version`,并从探测和 worker 环境移除 `NODE_OPTIONS/NODE_PATH`。该过程不会替换已持有路径、worker + 或活动调用。首次未找到 Node.js 后,Desktop 与 TUI 只提示安装或修复、刷新,以及可继续使用其他功能;当前宿主未记录 + “安装后已经刷新但仍不可见”的独立证据,因此不主动建议重启,也不提供自动重启或“立即重启”动作,更不能中断活动 session。 + 刷新会重新发现来源并触发运行环境可用性检查,但不能承诺继承父进程后续收到的环境变量变化;`node --version` + 探测通过前,运行时状态不得写成“可用”。 +- Subagent owner 读取 BitFun 模型配置失败时必须 fail closed,并生成与“模型不存在”不同的通用诊断。GUI/TUI + 提示用户先确认 BitFun 模型设置能够正常读取和保存,再刷新;日志只保留经过脱敏的失败阶段和错误类别。不得用 `AIConfig::default()` + 把配置服务异常转换成候选模型不匹配,也不得把原始错误或绝对配置路径投影到普通快照。临时故障期间不得改写已持久化的 + 审批或同名冲突选择,配置恢复且扩展行为未变化时继续复用原决定。 +- 外部 Tool 的首次确认继续明确展示代码来源、工作目录、文件/网络/进程/环境访问,以及“当前用户权限、无 OS + 沙箱、子进程可能继续运行”的残余风险。该提示是知情确认,不等于沙箱实现。 +- Desktop 与交互式 TUI 使用相同诊断 code 和激活阻断事实,各自负责适合宿主的文案;Remote 继续返回明确不支持, + 不读取或执行本机同名来源。上述变化不修改 system prompt,也不新增跨界面渲染契约。 + +明确延期到独立、由证据驱动的后续工作:OS/容器沙箱和 CPU/内存等硬资源限制、worker/prompt 全局预算、运行中 Tool +代次租约、通用 watcher 事件限流、偏好记录压缩、完整 metrics/打点平台。它们分别涉及平台执行、安全控制面、 +通用服务或数据保留策略,不能以“稳定性修复”为名并入本轮。 + +## 6. PR4:Skill 来源与覆盖状态 + +PR4 只解释现有 Skill Registry 已经执行的选择结果,不改变选择本身。Skill 是历史上已经无感发现并按固定根顺序 +解析的声明式内容,与 Command、Tool、Subagent 等可执行扩展的首次接入和冲突确认不同;本轮不把两种策略强行合并。 + +### 6.1 优先级回归契约 + +- 项目根保持 `.bitfun`、`.claude`、`.codex`、`.cursor`、`.opencode`、`.agents` 的现有顺序;项目根整体先于 + 用户根。用户根、BitFun 用户目录、BitFun 内置目录、OpenCode config/home 延迟根继续沿用当前实现顺序。 +- “BitFun 优先”只适用于当前已经如此定义的项目级 `.bitfun/skills`,不得误写成所有 BitFun 用户或内置 Skill + 都高于其他生态。PR4 用精确顺序测试冻结这一事实,来源元数据不参与排序或决胜。 +- `source_slot` 继续标识具体发现槽位;新增开放的 `source_id` 和稳定产品名只负责把多个槽位归为 BitFun、 + Claude Code、Codex、Cursor、OpenCode 或 Agent Skills 来源。能力 owner 和界面不得根据 `source_id` 另算优先级。 +- 本地与远程项目继续消费同一项目根契约。远程工作区只扫描真实远程项目根,同时保留当前本机用户级 Skill 行为; + PR4 不借来源展示改变远程执行域或回退规则。 + +### 6.2 精简的 GUI/TUI 体验 + +- Skills 场景和设置列表沿用现有卡片/列表,在作用域旁显示来源。普通项不增加确认步骤;按默认优先关系被覆盖的项 + 保留在原位置,使用弱化名称、删除线和“已覆盖”状态,并标明较高优先级来源,不把无模式页面误写成某一模式 + “当前一定不会使用”。内部 stable key 不作为主要解释文案。 +- 交互式 TUI 的可用列表显示来源;配置列表继续使用既有勾选框,并把 `shadowed` 改为 + `covered by `。模式配置中的覆盖关系由 Skill owner 在应用模式禁用规则后输出;高优先级项在该模式被禁用时, + 实际采用的低优先级项不得仍显示为被前者覆盖。未保存的勾选变化只标为待保存,不预判新的运行时赢家。 +- GUI 与 TUI 都消费 Registry 返回的来源和覆盖事实,不解析路径猜测生态,不新增 system prompt 文本,也不建立 + 跨宿主渲染协议。来源展示元数据缺失时只用已知 `source_id`/`source_slot` 映射产品名,最终显示本地化的“其他来源”, + 不泄露内部槽位,也不能因为展示元数据异常隐藏可用 Skill。远程工作区同时标明“此设备 · 用户级”或 + “远程工作区 · 项目级”,不改变现有扫描与执行域。 +- 当前 Skill 刷新、删除和模式开关生命周期保持不变;本轮不增加 watcher、通知、持久化选择或重启提示。 + +### 6.3 后续可执行扩展的统一原则 + +Command、Tool、Subagent、MCP 以及未来可执行扩展默认把 BitFun 原生/内置实现作为安全候选,但不允许外部候选 +通过注册顺序静默覆盖。出现同名参与者时由用户选择;选择绑定参与者集合与行为版本,集合或行为更新后才重新询问。 +冲突选择列表固定先展示 BitFun 候选,其余生态按稳定 `provider_id` 排序,同一生态内部保留该 adapter 的正式来源顺序; +展示顺序只帮助用户理解,不代替选择,也不把 Skill 的固定根优先级复制到可执行扩展。 +被 BitFun/其他候选覆盖、被用户拒绝或尚未选择的外部项必须继续出现在统一管理入口,并以“已覆盖”“未启用”或 +“等待选择”及原因展示,不能从列表消失。该原则由各能力 owner 的独立冲突契约实现,不复用 Skill 的固定优先级, +也不在 PR4 修改 PR1—PR3 已有执行路径。 + +## 7. PR5:同名扩展的选择可见性与统一入口 + +PR5 不新增扩展类型或运行能力,只补齐 Command、Tool、Subagent 已有冲突决策的管理闭环,并纠正 TUI 信息架构: + +- 三类 owner 继续维护各自 DTO、冲突指纹和选择持久化,主体逻辑不按 OpenCode 等生态 ID 分支。候选列表由 owner 输出; + 存在 BitFun 原生/本地候选时固定在首位,其余外部候选使用 adapter 已确定的稳定顺序。Skill Registry 的发现顺序和覆盖 + 关系完全不变,不能复用这里的交互式选择规则。 +- Desktop 同时显示待选择和已处理冲突,待选择项排在前面。每个候选明确标识“当前使用”“已选择但当前不可用”“未使用”或“可选”;已选择、 + 被覆盖以及 Subagent 的“保持不可用”状态都不会因决策完成而消失,用户可直接改选。候选或行为版本变化使冲突指纹变化后, + 旧选择失效并重新要求选择;没有变化时不重复打断。 +- 交互式 TUI 不提供 `/external-tools`、`/external-agents` 等按能力分裂的命令。共享控制使用 `/extensions`,工具使用通用 `/tools` 入口,并在内容中以 + “外部 AI 应用”分组;Agent 相关能力统一进入 `/agents`,同一列表以文字区分主 Agent、Subagent 和外部 AI 应用, + 删除历史 `/subagents` 命令。待选择与当前选择分区显示,只有待选择项进入 + 状态栏提醒计数;编号操作绑定产生当前视图的快照和稳定 key,改选仍由 owner 做 generation/revision 校验。 + 活动 turn 期间 `/agents` 仍可查看和管理 Subagent/外部来源,只禁用主 Agent 切换,并在对应行和状态栏解释原因。 +- GUI/TUI 都保持原生异常语义:读取失败不假定变更成功,刷新失败保留已知可用内容并提示状态可能过期,存储异常使受影响 + 名称 fail closed,Remote 明确不支持且不回退本机。模型设置读取失败只要求修复设置并刷新,不把重启当作通用恢复动作。 +- 本轮不新增重启编排。Node.js 是当前唯一可能受宿主进程环境影响的扩展依赖,但现有宿主没有可靠的刷新前后证据, + 因此只建议安装或修复后刷新,并说明用户可以继续使用其他能力;扩展入口不得建议或触发重启,也不得打断执行中的 session。 + 未来若产品增加统一重启动作,必须由 session owner + 提供活动任务保护与恢复契约,默认允许“稍后”,不能由扩展模块自行判断。 +- SSH Remote 工作区继续显示明确不支持;不加载本机配置代替远程配置。Peer 控制界面代理 Peer Host 的真实状态和操作, + 不把控制端状态混入 Host。provider discovery deadline 仍为 5 秒;仅发现进行中使用请求完成后再调度的 750 ms、1.5 s、3 s、5 s 退避轮询; + 本轮不增加 watcher、全局扫描、遥测平台或磁盘缓存,不改变 system prompt。 + +PR5 的退出条件是:三类已处理冲突在 GUI 中可见可改选;Tool/Subagent 在 TUI 中使用通用能力入口;原生候选顺序、 +待处理提醒去重、选择指纹失效、Remote 无回退和非强迫恢复文案均有聚焦测试。该切片不以统一入口为由构造通用 `ExtensionAsset` +或第二套选择状态。 + +TUI 命令命名属于产品契约。新增命令前必须先核对 BitFun 既有入口以及至少一个同类竞品的用户可见命令;能力已有对应 +入口时优先在原列表中通过分组、状态或二级选项表达来源差异。只有对象、生命周期或权限边界确实不同,并且复用入口会产生 +歧义时,才允许新增命令,并须在设计与测试中写明理由。adapter 名、provider ID 或“external”来源标签都不能单独构成新命令依据。 + +## 8. 暂停条件 出现以下情况时停止扩面并先修复架构: @@ -228,6 +355,6 @@ PR3 为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 映 - 为未来可能需求新增任意 payload 资产、通用脚本 SDK、第二套 Tool Runtime/Agent Runtime; - 只有静态解析却把 Tool、Hook、Subagent 或受限 Command 标为可用; - watcher 更新能绕过用户抑制,或一个 provider 的失败清空其他 provider; -- 同名候选仍由固定优先级静默选中,或候选内容版本变化后继续沿用旧冲突选择; +- Command、Tool、Subagent 等可执行扩展的同名候选仍由固定优先级静默选中,或候选内容版本变化后继续沿用旧冲突选择; - 为完整兼容一次性引入 package manager、Hook、renderer、Server 和权限系统; - 本地可用被直接推导为 Remote/HarmonyOS PC 可用,缺少同一 fixture 的真实运行证据。 diff --git a/docs/plans/product-architecture-evolution-plan.md b/docs/plans/product-architecture-evolution-plan.md index 68b8698407..06ec1f374d 100644 --- a/docs/plans/product-architecture-evolution-plan.md +++ b/docs/plans/product-architecture-evolution-plan.md @@ -7,8 +7,8 @@ [OpenCode 兼容](opencode-extension-compatibility-plan.md);能力 Provider、SDK 和外部宿主双向集成边界见 [能力装配与宿主集成](../architecture/extensions/capability-runtime-integration-design.md)。专项文档不能用自己的阶段编号扩大本计划范围。 -本轮对照的上游基线为 `b22bbdda7`(2026-07-17);本文所在提交记录 -本轮实现事实。后续事实变化必须随代码显式更新,只有代码、入口消费和对应验证同时成立的项目才标记为完成。 +本文所在提交记录本轮实现事实。后续事实变化必须随代码显式更新,只有代码、入口消费和对应验证同时成立的项目才标记为完成; +不在长期计划中固定易失效的上游提交号。 ## 1. 裁决原则 @@ -28,9 +28,9 @@ | 范围 | 当前事实 | 近期结论 | |---|---|---| | 编译依赖 | `assembly/core -> apps/relay-server` 已移除;通用检查覆盖 normal/build/dev 依赖及 optional/target 变体 | 后续反向依赖和未知 crate 层级直接失败 | -| 公开面 | `bitfun-core` 仍有迁移期 re-export;CLI 只完成部分 Runtime SDK 接入 | 按入口逐项迁移,不做全仓逐 symbol 台账或批量删除 | -| CLI/TUI | `ShortcutsConfig` 已加载但真实按键分发仍硬编码;Slash、Palette、帮助和执行不是同一来源 | 先统一宿主 action 声明和键位解析,不重写 renderer | -| OpenCode | 只有来源确认和静态工具名预览,没有 JS/TS `execute` 或真实工具注册 | 先做一个无外部依赖、遵循官方公开契约的 standalone custom tool 端到端样例 | +| 公开面 | `bitfun-core` 仍有迁移期 re-export;CLI 主会话客户端已仅消费 Runtime SDK,其他产品入口仍保留兼容路径 | 按入口逐项迁移,不做全仓逐 symbol 台账或批量删除 | +| CLI/TUI | 宿主 `ACTION_SPECS` 已统一 Slash、Palette、Help、Keymap 与 dispatch;启动页及活动 turn 的 Linux PTY / Windows ConPTY 行为由进程级契约保护 | 保持现有 renderer 与交互规格,只按真实故障样例补可靠性契约;macOS 活动 PTY 另行验收 | +| OpenCode | Prompt Command、受支持的单文件 JavaScript Tool 和 Subagent 安全子集已分别通过能力专属 provider 接入;受管 package plugin 仍只有静态预览 | 先收敛三条已交付路径的诊断、运行时提示和配置失败语义,再按真实阻塞样例评估下一能力切片 | | HarmonyOS PC | 未来平台目标,当前未实现 | 目标、问题、风险和旧设计闭环见平台规约;具体工作后续分别立项 | | 入口迁移 | CLI 已消费 Runtime Parts;Desktop 主交互消费由现有 owner 构造的窄口径 Runtime SDK 门面,完整 Desktop Runtime Parts 尚未组装;CLI/ACP/Desktop 仍按需保留 `bitfun-core/product-full` 兼容 owner | 保持单一 owner,按真实端口逐项迁移,不批量删除兼容门面或用桩服务提前声明能力 | @@ -42,8 +42,8 @@ 未知层级或新增反向依赖直接失败。 - relay 的 room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone relay app 和嵌入式入口共同消费。 - standalone 的 TCP bind、静态 fallback 和进程生命周期留在 app;embedded 的对应宿主逻辑暂留 assembly 兼容路径, - 其迁移是独立后续工作,不构成 HarmonyOS 支持。 + standalone 的 TCP bind、静态 fallback 和进程生命周期留在 app;embedded 的对应宿主逻辑由窄 + `EmbeddedRelayHost` 端口迁至 Desktop,不构成 HarmonyOS 支持。 - 把 contracts/Product Domain 中的环境、路径或进程探测迁到 service。现有 Agent Runtime helper 只处理多生态 Skill 根,不是 custom tool resolver,应留在 Skill owner;`.opencode/tools/` 发现由 OpenCode adapter 新增并由 当前静态预览与后续执行共同消费。旧路径删除前保持生产行为等价。 @@ -52,16 +52,18 @@ 退出条件: -- `assembly -> apps` 反向边消失,standalone/embedded relay 共用同一已测试 router;(共享 owner 与 Cargo 方向已满足, - embedded 宿主逻辑归位待后续) +- `assembly -> apps` 反向边消失,standalone/embedded relay 共用同一已测试 router,embedded 宿主逻辑由 Desktop 持有;(已满足) - 边界检查能命中 normal/build/dev 依赖及 optional/target 反向 fixture;(已满足) - contracts 和 Agent Runtime 不再新增环境或生态来源探测; - 本工作流没有新增无调用方端口、空 registry 或第二个 Runtime owner。 -## 4. 工作流二:CLI action 与快捷键一致 +## 4. 工作流二:CLI action 与快捷键一致(已完成) 用户结果:持久化快捷键真正生效,Slash、命令面板、帮助、快捷键展示和执行不会互相漂移。 +当前状态:CLI 宿主已建立统一 action registry,Slash、Palette、Help、Keymap 与 dispatch 共用同一组稳定条目; +显式旧快捷键、冲突配置、宿主安全 fallback 和帮助尺寸均有回归保护。后续终端可靠性工作不再重复设计 action 层。 + 交付: - 在 CLI 宿主内建立一个 action registry。条目只包含稳定 action id、名称/别名、适用上下文、可用性、处理器、 @@ -82,16 +84,20 @@ Help/dispatch 元数据;终端异常路径仍能恢复。 [HarmonyOS PC 平台规约](../architecture/platform-portability-design.md);具体工作后续分别立项,现有手机 Remote App 保持不变。 -## 6. 工作流四:OpenCode 从一个真实工具开始 +## 6. 工作流四:OpenCode 纵向切片与稳定性收敛 + +执行顺序由[OpenCode 兼容计划](opencode-extension-compatibility-plan.md)定义。当前已经完成三个互相隔离的纵向切片: -执行顺序由[OpenCode 兼容计划](opencode-extension-compatibility-plan.md)定义: +1. Prompt Command 直接发现标准用户/项目来源,通过统一冲突决策后在交互式 TUI 中提交展开后的 prompt; +2. standalone Tool 只加载受支持的单文件 JavaScript 子集,经来源确认后接入现有 Tool Runtime; +3. Subagent 安全子集经模型、工具和同名冲突确认后接入现有 Subagent owner,仅支持 fresh single-run。 -1. 固定稳定版本、官方 custom tool 文档/源码和当前静态预览事实,并增加一个无外部依赖的契约样例; -2. 直接发现官方 `.opencode/tools/` 来源,真实加载该样例的 `execute`,接入现有 Tool Runtime,并验证参数、结果、 - ToolContext 身份/路径字段、`abort` 取消、期限、异常和诊断;`metadata`/`ask` 在定义现有 owner 映射前明确不支持; -3. 官方 import 型 tool 或真实 package plugin 首次受阻时,才增加样例需要的依赖解析、loader 和最小 client; -4. Hook 和 TUI contribution 只按真实阻塞样例逐项加入。command/slash/key 复用工作流二的 action registry;toast - 必须等待 CLI 拥有类型化状态/通知 owner,不能借用 GUI 本地服务;原始 OpenTUI/Solid renderer 保持不支持。 +已实现路径已经收敛运行时依赖与配置失败:Node.js 在当前进程首次检查时不可用时,只提示修复、刷新和可继续使用其他功能; +当前宿主没有可靠的刷新前后证据,因此不主动建议或触发重启。读取 BitFun 模型配置失败时阻止外部 Subagent 激活并显示独立原因,不能伪装成 +“请求模型不存在”。同名冲突的待选择和当前选择保持可见,TUI 通过通用 `/tools` 与 `/agents` 入口按能力和来源分组; +`/agents` 同时容纳主 Agent、Subagent 和外部来源管理,不再创建 `/subagents` 或 `external-*` 平行命令。随后只有官方 import 型 tool、 +package plugin、Hook 或 TUI contribution 的真实样例证明当前 owner/契约不足时,才增加对应的最小切片。原始 +OpenTUI/Solid renderer、完整 package runtime 和 Remote 执行仍保持不支持。 工具实际加载并取得有效定义和 `execute` 后才能显示为可用。静态名称、可解析模块或进程启动成功都不等于工具 可调用。Remote 和 HarmonyOS PC 原生 CLI/TUI 未通过同一冻结样例前必须明确不支持,不能借 Desktop 代执行; @@ -99,11 +105,11 @@ HarmonyOS 手机 Remote App 不在该平台执行范围内。 ## 7. 工作流五:入口逐项迁移 -- CLI:会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、轮次 - 提交/取消、活动会话模型更新和 TUI 工具确认/拒绝/用户问题回答已由真实入口消费 Runtime SDK;交互模式下的 Peer Host - 也通过同一 SDK 提交/精确取消 turn、更新活动会话模型并处理工具确认/拒绝。分支、用量、快照和持久化维护等未覆盖操作 - 继续由现有单一兼容路径转发。 -- ACP:CLI 托管服务端的会话创建/列举、轮次提交/取消、活动会话模型更新、工具确认/拒绝、用户问题回答和事件订阅 +- CLI:主会话客户端的会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、 + 类型化转录、本地分支、用量生成、轮次提交/取消与精确结算、活动会话模型更新和 TUI 工具确认/拒绝/用户问题回答 + 已由真实入口消费 Runtime SDK;交互模式下的 Peer Host 也通过同一 SDK 提交/精确取消 turn、更新活动会话模型并处理工具 + 确认/拒绝。远程分支保持不支持;TUI 用量卡片持久化、快照和 Peer Host/ACP 维护等产品操作继续由现有单一兼容路径转发。 +- ACP:CLI 托管服务端的会话创建/列举、轮次提交/取消、活动会话模型/模式更新、工具确认/拒绝、用户问题回答和事件订阅 已复用同一 SDK 语义;ACP stdio、连接、权限 RPC 与通知生命周期仍留在接口入口。 - Desktop:主界面轮次提交/取消、活动会话模型更新、工具确认/拒绝和用户问题回答已通过现有协调器与调度器端口构造的窄口径 SDK 门面; 完整产品组装需等待真实必需服务与事件消费路径。会话 CRUD/恢复视图、模型目录/提供方配置、MCP、MiniApp、Cron、远程连接、 @@ -137,9 +143,9 @@ native/translated/degraded;未实现能力保持 unsupported/experimental; | 工作 | 必须等待 | 可以并行 | |---|---|---| -| Relay 共享 owner / 反向边修复 | 已完成;embedded 宿主归位待后续 | OpenCode fixture | -| CLI action/快捷键 | 当前 CLI 行为和配置 fixture | OpenCode standalone tool、入口 API 迁移 | -| OpenCode standalone tool | OpenCode adapter 内的单一 source resolver、冻结版本/样例 | CLI action | +| Relay 共享 owner / 反向边修复 | 已完成;embedded 宿主已归位 Desktop | OpenCode fixture | +| CLI action/快捷键 | 当前 CLI 行为和配置 fixture | OpenCode 已实现切片收敛、入口 API 迁移 | +| OpenCode 已实现切片收敛 | Command/Tool/Subagent 三条生产路径和聚焦 fixture | CLI action、入口迁移 | | OpenCode package/Hook/TUI | 前一切片稳定且有真实阻塞样例;TUI action 另等 action registry | 入口迁移 | | Desktop 主交互迁移 | 已完成窄口径 SDK 门面接入;完整 Desktop profile 与剩余入口需分别证明服务可用和行为等价 | ACP 与其他非扩展架构工作 | | 一个能力对外复用 | 现有能力 owner、具名试点/用例/验收 owner、冻结宿主版本和最小权限/取消语义 | OpenCode standalone tool、单入口迁移 | @@ -154,7 +160,7 @@ native/translated/degraded;未实现能力保持 unsupported/experimental; | Cargo 方向 | metadata fixture 覆盖各 dependency kind;已知债务只能减少 | | Relay | standalone/embedded 启动、路由、关闭和错误等价 | | CLI action | 无配置/旧配置/冲突配置、真实输入 dispatch、Help/Palette/Slash 一致、终端恢复 | -| OpenCode tool | 冻结无外部依赖契约样例的 load/execute/context/cancel/timeout/error 端到端;静态预览不会进入工具集合 | +| OpenCode 扩展 | Command 展开与冲突、Tool 的 load/execute/cancel/timeout、Subagent 的配置/模型/工具/Generation 失败语义;静态预览不会进入可调用集合 | | HarmonyOS PC | 本计划只检查平台规约没有被实现文档提前展开;各专题启动后独立定义验证。HAP、`hdc shell`、移动 Remote App 与远端代执行不替代 | | 入口迁移 | 单入口生产消费、行为等价、旧转发删除和 focused test | | 能力对外复用 | 一个外部消费者的注册/安装、启停、请求/结果、权限、取消、Generation、事件损失、宿主降级、卸载和恢复端到端验证 | diff --git a/docs/sdlc-harness/product-requirements-agent-workflow-adjustment.md b/docs/sdlc-harness/product-requirements-agent-workflow-adjustment.md index 5bd01edc80..01c2c0df33 100644 --- a/docs/sdlc-harness/product-requirements-agent-workflow-adjustment.md +++ b/docs/sdlc-harness/product-requirements-agent-workflow-adjustment.md @@ -71,7 +71,7 @@ BitFun 后续不应把 dynamic workflow 理解成一个需要用户学习的新 用户可以用自然语言表达“更快”“更稳”或“只看安全”等关注点。当前严格审查只识别 `/review strict`、历史 `/DeepReview` alias 和内部显式 strict follow-up;自然语言 strict 映射若未来接入,必须仍由用户明确表达严格意图,不能由风险启发式规则代替。 -上述渐进升级适用于批量执行、失败队列和验证策略。普通 Review 不因风险启发式增加专家 reviewer;仅当目标超过单 reviewer 边界或 provider 证据不完整时,才自动启用有界 `ReviewGeneral` 工作包。团队策略当前只能提示严格审查,不能自动启动。 +上述渐进升级适用于批量执行、失败队列和验证策略。普通 Review 不因风险启发式增加专家 reviewer;仅当目标超过单 reviewer 边界或 provider 证据不完整时,才自动启用有界 `ReviewWorker` 工作包。严格主审按实际变更或用户指定关注点动态生成 worker 的具体 lens、问题和范围,不再通过固定 reviewer 身份表达审核维度。团队策略当前只能提示严格审查,不能自动启动。 ### 4.2 DeepReview 收敛为显式 Strict Review @@ -82,7 +82,7 @@ BitFun 后续不应把 dynamic workflow 理解成一个需要用户学习的新 | L1 | 普通 `Review`;小目标单 reviewer,大目标内部受管分批 | 一个聚合后的问题清单、证据状态、实际覆盖和残余风险 | | L3 | `/review strict`、历史 `/DeepReview` alias 或内部显式 strict follow-up | 一个严格主审直接检查;必要时最多一个专家和一个条件质量检查;无需例行启动确认 | -L2 只保留历史 manifest 的读取与运行时校验兼容,不产生新的 Review 启动。安全、性能、架构、前端体验、跨模块或验证缺口等信号交给主审决定调查重点,不自动增加专家 reviewer。新 Strict Review 不预生成 work packets、不做同角色文件分片、不默认运行 Judge。普通 L1 仅在目标超过单 reviewer 边界或 provider 证据不完整时生成受时长、批次数和并发约束的 `ReviewGeneral` 工作包;所有 worker 都由所属 Review 回合前台等待并聚合,未纳入本轮预算的范围必须标为 deferred coverage。 +L2 只保留历史 manifest 的读取与运行时校验兼容,不产生新的 Review 启动。安全、性能、架构、前端体验、跨模块或验证缺口等信号交给主审决定调查重点,不自动增加专家 reviewer。新 Strict Review 不预生成 work packets、不做同角色文件分片、不默认运行 Judge。普通 L1 仅在目标超过单 reviewer 边界或 provider 证据不完整时生成受时长、批次数和并发约束的 `ReviewWorker` 工作包;所有 worker 都由所属 Review 回合前台等待并聚合,未纳入本轮预算的范围必须标为 deferred coverage。固定的业务逻辑、性能、安全、架构和前端 reviewer id 仅作为历史配置与会话兼容别名保留,不再进入新 manifest 或 Agent 列表。 迁移/兼容规则: diff --git a/docs/superpowers/plans/2026-07-21-subscription-auth-as-model-api.md b/docs/superpowers/plans/2026-07-21-subscription-auth-as-model-api.md new file mode 100644 index 0000000000..e691a3fd4b --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-subscription-auth-as-model-api.md @@ -0,0 +1,17 @@ +# Subscription Auth as Model API Implementation Plan + +> **For agentic workers:** Implement task-by-task. No CLI-disk-scan compatibility. + +**Goal:** Let BitFun sign in to Codex / Antigravity / OpenCode subscriptions and use them as model API auth, aligned with OpenCode. + +**Architecture:** `subscription_auth` store + OAuth login sessions in `bitfun-ai-adapters`; `AuthConfig::Subscription`; desktop Tauri + AIModelConfig UI. + +**Tech Stack:** Rust (tokio, reqwest, sha2), Tauri commands, React settings UI. + +## Tasks + +- [x] Spec: `docs/superpowers/specs/2026-07-21-subscription-auth-as-model-api-design.md` +- [x] Replace `cli_credentials` with `subscription_auth` (store, oauth, providers) +- [x] Wire `AuthConfig`, client_factory, desktop commands, remote policy +- [x] Replace Web UI CLI scan section with subscription login panel + i18n +- [x] Verify: cargo check/tests + type-check:web + i18n:audit diff --git a/docs/superpowers/plans/2026-07-22-cargo-target-latest-only-gc.md b/docs/superpowers/plans/2026-07-22-cargo-target-latest-only-gc.md new file mode 100644 index 0000000000..c3152e58fd --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-cargo-target-latest-only-gc.md @@ -0,0 +1,39 @@ +# Cargo Target Latest-Only GC Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On desktop:dev exit and desktop:build end, prune Cargo target caches so only the latest useful artifacts remain for the active profile. + +**Architecture:** A shared Node module `scripts/cargo-target-gc.mjs` owns prune logic and CLI. `scripts/dev.cjs` and `scripts/desktop-tauri-build.mjs` call it best-effort after the session/build ends. + +**Tech Stack:** Node.js (`node:test`, `fs`, `path`, `child_process`) + +## File map + +| File | Responsibility | +|---|---| +| `scripts/cargo-target-gc.mjs` | Prune incremental / fingerprint / deps; CLI + exported API | +| `scripts/cargo-target-gc.test.mjs` | Fixture tests for keep-latest rules | +| `scripts/dev.cjs` | Call GC after desktop / preview exit | +| `scripts/desktop-tauri-build.mjs` | Call GC after tauri build | +| `package.json` | `target:gc` script | +| `src/apps/desktop/AGENTS.md` (+ CN) | One short note on GC behavior | + +## Tasks + +### Task 1: Core GC module + tests + +- [x] Write failing tests for incremental keep-latest and fingerprint/deps orphan cleanup +- [x] Implement `scripts/cargo-target-gc.mjs` +- [x] Pass `node --test scripts/cargo-target-gc.test.mjs` + +### Task 2: Wire desktop entrypoints + +- [x] Hook `dev.cjs` desktop + preview shutdown/`finally` +- [x] Hook `desktop-tauri-build.mjs` after build +- [x] Add `pnpm run target:gc` + +### Task 3: Docs + verify + +- [x] Brief AGENTS note +- [x] Re-run unit tests; dry-run against real target if present diff --git a/docs/superpowers/specs/2026-07-19-relay-deploy-log-and-docker-cache-design.md b/docs/superpowers/specs/2026-07-19-relay-deploy-log-and-docker-cache-design.md new file mode 100644 index 0000000000..444e102e6e --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-relay-deploy-log-and-docker-cache-design.md @@ -0,0 +1,38 @@ +# Relay Deploy: Live Logs + Docker Cache Invalidation + +**Date:** 2026-07-19 +**Status:** Approved for implementation + +## Problems + +1. Deploy wizard log pane stays empty until the remote task finishes or fails. +2. Docker image rebuild fails with `cannot find db/admin in bitfun_relay_service`. + +## Root Causes + +1. Detached `nohup` redirects stdout to a file (full buffering); poll splitter is fragile on CRLF; frontend `setInterval` delays the first poll and can overlap. +2. Dockerfile dependency-cache cleanup uses `deps/bitfun_relay_service*`, which does not match Cargo artifacts `libbitfun_relay_service-*`; the second build links the empty placeholder crate. + +## Design + +### Docker + +In `src/apps/relay-server/Dockerfile`, invalidate placeholder artifacts with globs `*bitfun_relay_service*`, `*bitfun_relay_server*`, `*relay_admin*`, remove matching `.fingerprint` dirs, and `touch` real sources before the second `cargo build`. + +### Live logs + +- Launch detached tasks with `stdbuf -oL -eL` when available. +- Set `BUILDKIT_PROGRESS=plain` (and compose `--progress=plain` when supported). +- Split poll stdout on `---\n`, `---\r\n`, or a trimmed `---` line. +- Frontend: immediate first poll + serial `setTimeout` chain; seed a waiting line via i18n. + +### Out of scope + +- WebSocket log push / PTY +- Configurable deploy git ref (default remains GitHub `main`) + +## Verification + +- Focused Rust check for `bitfun-services-integrations` +- Web type-check +- Local `docker compose build` for relay-server when feasible diff --git a/docs/superpowers/specs/2026-07-20-desktop-streaming-typewriter-design.md b/docs/superpowers/specs/2026-07-20-desktop-streaming-typewriter-design.md new file mode 100644 index 0000000000..fc6d5f429a --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-desktop-streaming-typewriter-design.md @@ -0,0 +1,76 @@ +# Desktop Streaming Typewriter Smoothness + +**Date:** 2026-07-20 +**Status:** Approved for implementation (user authorized direct design + implement) + +## Problem + +bitfun-desktop session streaming text feels stepped / bursty compared with a smooth typewriter feel (mobile-web cited as a reference, not a target to copy). + +## Root Cause + +1. `EventBatcher` flushes all events on a fixed **100ms** cadence, so text arrives in stair steps. +2. `useTypewriter` uses `setInterval(50ms)` and **forces catch-up within 800ms**, so large batches raise `chars/tick` sharply and produce visible jumps. +3. Full Markdown re-parse on each display update makes oversized ticks more expensive and more visible. + +## Design Goals + +- Smooth, even visual velocity (not “true realtime at all costs”). +- Avoid feast/famine catch-up bursts. +- Keep tool-event batching protective (do not spam UI with ParamsPartial/Progress). +- Stay inside `src/web-ui` streaming path; no Rust / mobile-web changes. + +## Solution + +### 1. Adaptive rAF typewriter (`useTypewriter`) + +Replace fixed interval + 800ms catch-up with: + +- `requestAnimationFrame` loop +- Fractional character accumulator (sub-frame smoothness) +- Soft acceleration from backlog depth +- Hard cap on characters revealed per paint +- Minimum paint interval (~32ms) so Markdown is not forced to 60Hz full re-parse +- When the model stream ends (`animate === false`) while characters remain, + keep revealing with a finish-speed boost — never snap the remainder +- History / never-animated mounts still start at full text + +Tunable constants (exported for tests): + +| Constant | Intent | +|---|---| +| Base ~90 chars/s | Comfortable live reveal | +| Soft accel from backlog | Catch up without dumping | +| Live max ~720 chars/s / 18 chars/paint | Fast but stepped while streaming | +| Finish max ~2400 chars/s / 64 chars/paint | Fastest stepped drain after model ends | +| Min paint ~16ms live / ~8ms finish | Balance smoothness vs Markdown cost | + +Footer / round chrome wait for typewriter reveal completion via +`TypewriterRevealGate`, so copy/export actions do not appear (and reflow) +while characters are still being revealed. + +`ModelRoundItem` must not replay CSS `fadeIn` when flipping +`--streaming` → `--complete` after typewriter drain (that reset opacity to 0 +and looked like a full chat refresh). Enter animation is opt-in via +`--enter` only for freshly mounted non-streaming rounds. + +### 2. Dual-latency `EventBatcher` + +- Text chunk keys: **maxLatencyMs = 32** (steadier inflow for the typewriter) +- Tool / default events: **maxLatencyMs = 100** (unchanged protection) +- If a more urgent event arrives while a flush is already scheduled, **reschedule** to the earlier deadline + +`handleTextChunk` passes the text latency when calling `eventBatcher.add`. + +### 3. Out of scope (follow-ups) + +- Incremental / frozen-block Markdown parsing +- VirtualMessageList observer frequency changes +- mobile-web typewriter changes + +## Verification + +- Unit tests for reveal-step math and EventBatcher dual-latency / reschedule +- `pnpm run type-check:web` +- Focused vitest for touched modules +- Manual: stream a long mixed CJK/Latin reply in desktop and confirm even pacing without stair-step bursts diff --git a/docs/superpowers/specs/2026-07-20-mobile-web-account-pairing-and-device-refresh-design.md b/docs/superpowers/specs/2026-07-20-mobile-web-account-pairing-and-device-refresh-design.md new file mode 100644 index 0000000000..5e722fedab --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-mobile-web-account-pairing-and-device-refresh-design.md @@ -0,0 +1,51 @@ +# Mobile-Web 账号配对门槛与设备列表跟随设计 + +Date: 2026-07-20 +Scope: `src/mobile-web`, `src/crates/services/services-integrations` (pairing/QR/account), `src/crates/assembly/core` (remote_connect), `src/apps/desktop` (account verifier wiring) + +## 背景 + +1. Mobile-web 设备页在桌面换号后展示「账号授权已过期」死胡同:委派 token 失效后未向配对桌面重新拉取身份。 +2. 扫码配对的「用户 ID」原用于 URL 防盗用;桌面已登录 BitFun 账号时,应改为校验与桌面相同的账号密码,且必须是被扫码桌面当前账号。 + +## 已确认产品规则 + +| 场景 | Mobile 表单 | 校验 | +|---|---|---| +| 桌面未登录账号 | 仅用户 ID(现状) | `TrustedMobileIdentity`(install_id + user_id) | +| 桌面已登录账号 | 用户名 + 密码 | 与桌面登录同源(challenge + unwrap master_key);`master_key` 必须等于当前桌面会话;密码不落盘;用户名可预填(含首次) | + +设备列表:与桌面登录后列表一致;401 时透明刷新委派身份并重试,不再展示授权过期终态。 + +## 方案 + +### A. 配对(账号模式) + +1. **QR URL**:桌面已登录时追加 `auth=account&user=`(username 来自 credential hint,非机密)。未登录不带这些参数。 +2. **Mobile**:解析到 `auth=account` 时显示密码框;用户名优先 URL,其次本地缓存;**禁用无密码自动重连**;密码仅经 ECDH 加密房间通道提交。 +3. **`PairingResponse`**:新增可选 `password`;`user_id` 在账号模式下承载提交的 username。 +4. **Desktop 校验(verify-only)**: + - 注册 `account_pairing_verifier(username, password) -> Result`。 + - 实现:`AccountClient` challenge + unwrap master_key,与当前 `AccountSession.master_key` 比较;一致则返回 `session.user_id`。 + - **不调用** `/api/auth/login`,避免刷新/污染桌面 token。 + - 账号不一致或密码错误 → 统一拒绝文案(不泄露哪一项失败)。 +5. **信任绑定**:校验成功后用 **canonical `user_id`**(非明文 username)写入 `TrustedMobileIdentity`,保证重连时 username 提交仍能对齐。 + +### B. 设备列表跟随 + +1. `RelayHttpClient`:`clearDelegatedIdentity()`;`requestDelegatedIdentity({ force })`;`listDevices` / `sendDeviceRpc` 遇 HTTP 401 时清身份 → 强制重拉 → **重试一次**。 +2. `DevicesPage`:移除 `tokenExpired` 终态;失败走可重试错误或 `noDelegatedIdentity`。 +3. 清理或降级 `devices.tokenExpired` i18n(无引用则删)。 + +## 错误处理 + +- 账号模式缺密码 / 校验失败:配对失败 + 既有失败次数锁定。 +- 桌面在出码后登出:verifier 不可用 → 拒绝账号模式配对(提示桌面重新登录并刷新二维码)。 +- 委派刷新后桌面仍未登录:设备页 `noDelegatedIdentity` + 重试。 + +## 验证 + +- `cargo test -p bitfun-services-integrations --features remote-connect --test remote_connect_contracts`(不带 `--features remote-connect` 时整个测试文件被 cfg 门掉,0 测试静默通过) +- `cargo check -p bitfun-desktop` +- `pnpm --dir src/mobile-web run type-check` +- 手工:未登录扫码;已登录扫码(预填用户名+密码);错密码;换号后设备列表刷新无过期页 diff --git a/docs/superpowers/specs/2026-07-21-subscription-auth-as-model-api-design.md b/docs/superpowers/specs/2026-07-21-subscription-auth-as-model-api-design.md new file mode 100644 index 0000000000..7029b3feb1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-subscription-auth-as-model-api-design.md @@ -0,0 +1,67 @@ +# Subscription Auth as Model API Design + +Date: 2026-07-21 + +## Goal + +Align BitFun with OpenCode’s “use another product’s subscription as a model API” +capability. Users sign in to Codex (ChatGPT), Antigravity (Google), or OpenCode +Zen inside BitFun; the resulting OAuth tokens authenticate AI requests. No +upgrade path for the previous Codex/Gemini CLI disk-scan import. + +## Non-goals + +- Reusing `~/.codex/auth.json` / `~/.gemini/*` +- Migrating `AuthConfig::{CodexCli, GeminiCli}` +- Embedding the OpenCode Node/Effect runtime +- GitHub Copilot or other OpenCode auth plugins in this round + +## Architecture + +``` +UI (AIModelConfig) + -> Tauri: list / start_login / cancel_login / logout / status + -> bitfun-ai-adapters::subscription_auth + store: {user_data_dir}/subscription_auth.json (0600) + providers: codex | antigravity | opencode + -> apply_subscription_auth(AuthConfig, &mut AIConfig) + -> existing provider adapters (codex_chatgpt / gemini-code-assist / opencode zen) +``` + +### AuthConfig + +```rust +enum AuthConfig { + ApiKey, + Subscription { provider: SubscriptionProvider }, // codex | antigravity | opencode +} +``` + +Model configs store only the subscription reference. Tokens live only in the +subscription auth store and are refreshed on resolve. + +### Providers + +| ID | Login | Runtime | +|---|---|---| +| `codex` | ChatGPT PKCE on `localhost:1455/auth/callback` | Bearer + ChatGPT-Account-Id → `chatgpt.com/backend-api/codex/responses` | +| `antigravity` | Google OAuth PKCE on `localhost:51121/oauth-callback` | Bearer + Antigravity Client-Metadata → `cloudcode-pa` (daily→prod fallback) | +| `opencode` | Device code against `console.opencode.ai` | Bearer → Zen `/api/config` models + provider API | + +### UI + +Replace the “local CLI accounts” section with a “Subscription accounts” panel: +status, Login, Logout, and “Use as model” which creates an `AIModelConfig` with +`auth: { type: subscription, provider }`, suggested format/base_url, empty +`api_key`. + +### Remote policy + +Login/logout/list commands are local-machine only (`LocalOnly`). + +## Verification + +- `cargo test -p bitfun-ai-adapters --features subscription-auth subscription_auth` +- `cargo check -p bitfun-desktop` +- `pnpm run type-check:web` +- Manual: login each provider, import model, run a short chat turn diff --git a/docs/superpowers/specs/2026-07-22-cargo-target-latest-only-gc-design.md b/docs/superpowers/specs/2026-07-22-cargo-target-latest-only-gc-design.md new file mode 100644 index 0000000000..0f5efeb266 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-cargo-target-latest-only-gc-design.md @@ -0,0 +1,56 @@ +# Cargo Target Latest-Only GC + +**Date:** 2026-07-22 +**Status:** Approved (timing: exit of `desktop:dev` / end of `desktop:build`) + +## Problem + +`target/debug` grows without bound across `pnpm run desktop:dev` / build sessions (observed ~139GB). Growth is dominated by: + +1. Multiple `incremental/-/` roots for the same crate (rustc only GCs sessions *inside* one hash root). +2. Stale `.fingerprint` / `deps` artifacts left after feature or unit-graph changes. + +## Goal + +After a desktop dev session exits or a desktop build finishes, keep only the latest *useful* cache for the active profile so disk usage stops ratcheting upward, without `cargo clean` and without disabling incremental compilation. + +## Non-goals + +- Changing default `profile.dev` debuginfo (optional later). +- GC on every incremental rebuild during a live `tauri dev` session. +- Pruning `.fingerprint` directories by keep-newest-N (unsafe; caused full cold rebuilds). + +## Design + +### Trigger (option B) + +| Entry | When GC runs | +|---|---| +| `pnpm run desktop:dev` | After `tauri dev` exits (including Ctrl+C), in a `finally` path | +| `pnpm run desktop:preview:debug` | On preview shutdown | +| `pnpm run desktop:build*` (`scripts/desktop-tauri-build.mjs`) | After `tauri build` returns (success or fail; GC is best-effort) | +| `pnpm run target:gc` | Manual | + +Skip GC when `BITFUN_TARGET_GC=0`. Dry-run when `BITFUN_TARGET_GC_DRY_RUN=1`. + +Skip when another `cargo` / `rustc` process still appears active (avoid deleting in-use artifacts). + +### What is pruned + +For `target///` (default host triple omitted; profile `debug` for dev, build profile from argv): + +1. **incremental** — group directories by crate prefix (name before final `-`); keep the newest mtime; delete older roots. Inside a kept root, keep the newest finalized `s-*` session when multiple remain. +2. **.fingerprint** — **do not mtime-prune**. Cargo may keep multiple concurrent units per package (lib / build-script / feature variants). Deleting a still-referenced fingerprint forces a cold rebuild on the next `desktop:dev`. +3. **deps** — delete artifacts whose trailing hash no longer appears in **any existing** fingerprint directory name (true orphans only). Never delete deps solely because another fingerprint for the same stem is newer. + +### Safety + +- Never delete the profile root or final binaries by name (`bitfun-desktop`, `.app`, etc.) except via normal cargo replacement. +- GC failures must not fail the user command (log and continue). +- No dependency on `cargo-sweep` (macOS atime is unreliable). + +## Verification + +- Unit tests for grouping / keep-latest / deps orphan deletion on a temp fixture. +- `node --test scripts/cargo-target-gc.test.mjs` +- Manual: run GC dry-run against real `target/debug`, confirm incremental crate counts drop to 1 per prefix without requiring a full clean rebuild afterward for desktop:dev. diff --git a/docs/superpowers/specs/2026-07-22-mobile-web-resume-card-and-autoreconnect-design.md b/docs/superpowers/specs/2026-07-22-mobile-web-resume-card-and-autoreconnect-design.md new file mode 100644 index 0000000000..155cc8c55c --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-mobile-web-resume-card-and-autoreconnect-design.md @@ -0,0 +1,62 @@ +# Mobile-Web Resume Card 错位与重开卡在配对页设计 + +Date: 2026-07-22 +Scope: `src/mobile-web`(`PairingPage.tsx`, `sessions.scss`);合同测试可落在 `src/web-ui` 既有 mobile source contract 套件 + +## 背景 + +1. 会话列表顶部「继续上次会话」卡片文案居中、meta 左对齐,视觉错位。 +2. 关闭 mobile-web 后再打开,页面长期停在「正在连接并配对...」转圈,无法进入会话或重试。 + +## 根因 + +### A. Resume Card 错位 + +- 卡片是 ` + + + + + +

密码只在此浏览器中用于 Argon2id 派生,不会以明文发送给 Relay。

+ + + + +"#, + PAGE_ACCESS_HTML_STYLE + ), + ) +} + +fn page_login_required_response( + state: &AppState, + page: &PageWithUsername, + visibility: PageVisibility, + username: &str, + version_id: Option<&str>, +) -> Result { + let page_path = match version_id { + Some(version_id) => format!("/p/{username}/{}/@v/{version_id}", page.slug), + None => format!("/p/{username}/{}", page.slug), + }; + let Some(config) = state.page_browser_auth.as_ref() else { + let client_script = if version_id.is_some() { + "../../../../api/page-auth/client.js" + } else { + "../../api/page-auth/client.js" + }; + return Ok(page_login_form_response(visibility, None, client_script)); + }; + let cookie_path = external_page_cookie_path(state, &page_path); + let login_state = state.page_access_manager.issue_login_request( + page.user_id.clone(), + page.slug.clone(), + page.generation.clone(), + version_id.map(str::to_string), + page_path, + cookie_path, + )?; + let mut response = Redirect::temporary(&format!( + "{}/api/page-auth/sign-in?state={login_state}", + config.auth_base_url + )) + .into_response(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + Ok(response) +} + +fn secure_page_html_response(status: StatusCode, html: String) -> Response { + let mut response = html.into_response(); + *response.status_mut() = status; + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, max-age=0"), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert("x-frame-options", HeaderValue::from_static("DENY")); + response +} + +const PAGE_ACCESS_HTML_STYLE: &str = r#" +:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +*{box-sizing:border-box} +body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:#101114;color:#f4f5f7} +main{width:min(100%,460px);padding:32px;border:1px solid #32353c;border-radius:16px;background:#1a1c21;box-shadow:0 18px 60px rgba(0,0,0,.32)} +.mark{display:grid;place-items:center;width:44px;height:44px;margin-bottom:24px;border-radius:12px;background:#4f8cff;color:white;font-size:20px;font-weight:700;box-shadow:0 10px 28px rgba(79,140,255,.28)} +.eyebrow{margin:0 0 8px;color:#77a7ff;font-size:12px;font-weight:700;letter-spacing:.12em} +h1{margin:0 0 14px;font-size:24px;line-height:1.3} +p{margin:0;color:#b5b8c0;font-size:14px;line-height:1.7} +.secondary{margin-top:8px;color:#838791;font-size:13px} +.access{margin-top:18px;padding:10px 12px;border:1px solid #343a46;border-radius:8px;background:#20242c;color:#cbd6eb;font-size:12px} +code{padding:2px 6px;border:1px solid #3a3e47;border-radius:6px;background:#22252b;color:#dce7ff} +form{margin-top:26px} +.field{display:block;margin-top:16px;color:#d7d9df;font-size:13px;font-weight:600} +.field:first-child{margin-top:0} +.field small{margin-left:5px;color:#777c87;font-size:11px;font-weight:500} +input{width:100%;height:42px;margin-top:7px;padding:0 12px;border:1px solid #3a3e47;border-radius:8px;outline:none;background:#15171b;color:#f4f5f7;font:inherit} +input:focus{border-color:#5d96ff;box-shadow:0 0 0 3px rgba(79,140,255,.18)} +.password{position:relative;display:block} +.password input{padding-right:60px} +button{border:0;font:inherit;cursor:pointer} +.toggle{position:absolute;right:6px;bottom:5px;height:32px;padding:0 8px;background:transparent;color:#8fb6ff;font-size:12px} +.submit{width:100%;min-height:44px;margin-top:20px;border-radius:9px;background:#4f8cff;color:white;font-weight:650} +.submit:hover{background:#68a0ff} +.submit:disabled{cursor:wait;opacity:.72} +button:focus-visible{outline:3px solid rgba(104,160,255,.4);outline-offset:3px} +button span{margin-left:6px;font-size:12px;font-weight:500;opacity:.78} +.error{margin-top:12px;color:#ff8c8c;font-size:12px} +.note{margin-top:14px;color:#737782;font-size:12px;text-align:center} +"#; + +fn request_used_https(headers: &HeaderMap) -> bool { + headers + .get("forwarded") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(';') + .any(|part| part.trim().eq_ignore_ascii_case("proto=https")) + }) + || headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .next() + .is_some_and(|proto| proto.trim().eq_ignore_ascii_case("https")) + }) +} + +fn is_safe_page_username(username: &str) -> bool { + !username.is_empty() + && username.len() <= 128 + && username + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +async fn list_pages( + State(state): State, + headers: HeaderMap, +) -> Result>, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + let pages = PageRow::list_for_user(db, &auth.user_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let username = UserRow::find_by_username_for_user_id(db, &auth.user_id) + .await + .ok() + .flatten() + .unwrap_or_default(); + + let mut infos = Vec::new(); + for p in pages { + // One-time legacy migration: old room without versions. + maybe_migrate_legacy_page(&state, &p).await; + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_read(&p.user_id, &p.slug) + .await; + let page = PageRow::get(db, &p.user_id, &p.slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // A row deleted after the initial list query must not be resurrected + // in the response from the stale in-memory `p` snapshot. + if let Some(page) = page { + infos.push(page_to_info(&state, &page, &username)); + } + } + Ok(Json(infos)) +} + +async fn check_page_files( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + if !is_valid_slug(&body.slug) + || body + .upload_id + .as_deref() + .is_some_and(|upload_id| !is_valid_page_upload_id(upload_id)) + || body + .expected_generation + .as_deref() + .is_some_and(|generation| !is_valid_page_generation(generation)) + { + return Err(StatusCode::BAD_REQUEST); + } + + let mut total_bytes: u64 = 0; + let mut manifest = HashMap::with_capacity(body.files.len()); + if body.files.is_empty() || body.files.len() > MAX_PAGE_FILES { + return Err(StatusCode::BAD_REQUEST); + } + for entry in &body.files { + if crate::validated_asset_relative_path(&entry.path).is_err() + || !crate::is_valid_content_hash(&entry.hash) + { + return Err(StatusCode::BAD_REQUEST); + } + if entry.size > MAX_FILE_BYTES { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + total_bytes = total_bytes.saturating_add(entry.size); + if manifest + .insert(entry.path.clone(), entry.hash.clone()) + .is_some() + { + return Err(StatusCode::BAD_REQUEST); + } + } + if total_bytes > MAX_PAGE_BYTES { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + + let existing = PageRow::get(db, &auth.user_id, &body.slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let (expected_generation, create) = resolve_upload_generation_intent( + existing.as_ref(), + body.expected_generation.as_deref(), + body.create, + ) + .ok_or(StatusCode::CONFLICT)?; + if existing.is_none() { + let count = PageRow::count_for_user(db, &auth.user_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if count >= MAX_PAGES_PER_USER { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + } + + for stale_draft in state + .page_upload_manager + .prune_expired(Instant::now()) + .await + { + state.asset_store.cleanup_room(&stale_draft); + } + + let page_key = page_upload_session_key(&auth.user_id, &body.slug); + let _page_guard = state.page_upload_manager.lock_for(&page_key).lock().await; + let current_page = PageRow::get(db, &auth.user_id, &body.slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !generation_intent_matches( + current_page.as_ref(), + expected_generation.as_deref(), + create, + ) { + return Err(StatusCode::CONFLICT); + } + let draft_key = body.upload_id.as_deref().map_or_else( + || page_draft_asset_key(&auth.user_id, &body.slug), + |upload_id| page_upload_draft_key(&auth.user_id, &body.slug, upload_id), + ); + if let Some((_, previous)) = state.page_upload_manager.sessions.remove(&page_key) { + state.asset_store.cleanup_room(&previous.draft_key); + } + // Reclaim upload-specific drafts left by a prior process crash. The + // in-memory store uses exact namespaces (the active one was removed + // above); the disk store recursively clears this Page's draft subtree. + state + .asset_store + .cleanup_room(&page_draft_asset_key(&auth.user_id, &body.slug)); + // A retry using the same upload id must also start from the submitted + // manifest, never from partial mappings left by the failed attempt. + state.asset_store.cleanup_room(&draft_key); + let asset_store = Arc::clone(&state.asset_store); + let processing_draft_key = draft_key.clone(); + let response_upload_id = body.upload_id.clone(); + let response_generation = expected_generation.clone(); + let response_create = create; + let response = tokio::task::spawn_blocking(move || { + process_check_page_files( + asset_store, + &processing_draft_key, + response_upload_id, + response_generation, + response_create, + body.files, + ) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)??; + + let _capacity_guard = state + .page_upload_manager + .capacity_lock + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let user_sessions = state + .page_upload_manager + .sessions + .iter() + .filter(|entry| entry.user_id == auth.user_id) + .count(); + if state.page_upload_manager.sessions.len() >= MAX_PAGE_UPLOAD_SESSIONS + || user_sessions >= MAX_PAGE_UPLOAD_SESSIONS_PER_USER + { + state.asset_store.cleanup_room(&draft_key); + return Err(StatusCode::TOO_MANY_REQUESTS); + } + state.page_upload_manager.sessions.insert( + page_key, + PageUploadSession { + user_id: auth.user_id, + upload_id: body.upload_id.clone(), + draft_key, + manifest, + finalized: false, + title: None, + visibility: None, + expected_generation, + create, + expires_at: Instant::now() + PAGE_UPLOAD_SESSION_TTL, + }, + ); + Ok(Json(response)) +} + +async fn upload_page_files( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + if !is_valid_slug(&body.slug) + || body + .upload_id + .as_deref() + .is_some_and(|upload_id| !is_valid_page_upload_id(upload_id)) + || body + .expected_generation + .as_deref() + .is_some_and(|generation| !is_valid_page_generation(generation)) + { + return Err(StatusCode::BAD_REQUEST); + } + if !body.title.is_empty() && !is_valid_page_title(&body.title) { + return Err(StatusCode::BAD_REQUEST); + } + let visibility = PageVisibility::parse(&body.visibility).ok_or(StatusCode::BAD_REQUEST)?; + + for (rel_path, entry) in &body.files { + if crate::validated_asset_relative_path(rel_path).is_err() + || !crate::is_valid_content_hash(&entry.hash) + { + return Err(StatusCode::BAD_REQUEST); + } + let max_encoded_file_bytes = MAX_FILE_BYTES.div_ceil(3).saturating_mul(4); + if entry.content.len() as u64 > max_encoded_file_bytes { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + } + + let slug = body.slug.clone(); + let upload_id = body.upload_id.clone(); + let finalize = body.finalize; + let page_key = page_upload_session_key(&auth.user_id, &slug); + let _page_guard = state.page_upload_manager.lock_for(&page_key).lock().await; + let session = state + .page_upload_manager + .sessions + .get(&page_key) + .map(|entry| entry.value().clone()) + .ok_or(StatusCode::CONFLICT)?; + if session.upload_id != upload_id + || !upload_request_matches_session_intent( + body.expected_generation.as_deref(), + body.create, + &session, + ) + { + return Err(StatusCode::CONFLICT); + } + if session.expires_at <= Instant::now() { + state.page_upload_manager.sessions.remove(&page_key); + state.asset_store.cleanup_room(&session.draft_key); + return Err(StatusCode::GONE); + } + for (path, entry) in &body.files { + if session.manifest.get(path) != Some(&entry.hash) { + return Err(StatusCode::CONFLICT); + } + } + + let existing = PageRow::get(db, &auth.user_id, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !generation_intent_matches( + existing.as_ref(), + session.expected_generation.as_deref(), + session.create, + ) { + return Err(StatusCode::CONFLICT); + } + if finalize && existing.is_none() { + let count = PageRow::count_for_user(db, &auth.user_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if count >= MAX_PAGES_PER_USER { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + } + + let title = if body.title.is_empty() { + existing + .as_ref() + .map(|p| p.title.clone()) + .unwrap_or_else(|| slug.clone()) + } else { + body.title.clone() + }; + let draft_key = session.draft_key.clone(); + let asset_store = Arc::clone(&state.asset_store); + let files = body.files; + let stored = tokio::task::spawn_blocking(move || { + process_upload_page_files(asset_store, &draft_key, files) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)??; + + let mut active = state + .page_upload_manager + .sessions + .get_mut(&page_key) + .ok_or(StatusCode::CONFLICT)?; + if active.upload_id != upload_id + || active.expected_generation != session.expected_generation + || active.create != session.create + { + return Err(StatusCode::CONFLICT); + } + // Every accepted batch renews the upload lease. Long but progressing + // uploads must not expire relative to the initial manifest check. + active.expires_at = Instant::now() + PAGE_UPLOAD_SESSION_TTL; + + if finalize { + let actual_manifest = state + .asset_store + .list_room_entries(&session.draft_key) + .into_iter() + .collect::>(); + if actual_manifest != session.manifest { + return Err(StatusCode::CONFLICT); + } + active.finalized = true; + active.title = Some(title); + active.visibility = Some(visibility); + } + drop(active); + + Ok(Json(serde_json::json!({ + "status": "ok", + "files_stored": stored, + "slug": slug, + "upload_id": upload_id, + "draft": true, + "finalize": finalize, + }))) +} + +async fn freeze_version( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(body): Json, +) -> Result, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + if !is_valid_slug(&slug) + || body + .upload_id + .as_deref() + .is_some_and(|upload_id| !is_valid_page_upload_id(upload_id)) + || body + .expected_generation + .as_deref() + .is_some_and(|generation| !is_valid_page_generation(generation)) + { + return Err(StatusCode::BAD_REQUEST); + } + if (!body.title.is_empty() && !is_valid_page_title(&body.title)) + || !is_valid_page_note(&body.note) + { + return Err(StatusCode::BAD_REQUEST); + } + + let page_key = page_upload_session_key(&auth.user_id, &slug); + let _page_guard = state.page_upload_manager.lock_for(&page_key).lock().await; + // A completed freeze consumes its in-memory upload session. Persisting the + // source upload id with the immutable version lets the exact same request + // recover its original response after a lost HTTP response or relay restart. + if let Some(upload_id) = body.upload_id.as_deref() { + if let Some(version) = + PageVersionRow::get_by_source_upload_id(db, &auth.user_id, &slug, upload_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + let page = PageRow::get(db, &auth.user_id, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::CONFLICT)?; + let username = UserRow::find_by_username_for_user_id(db, &auth.user_id) + .await + .ok() + .flatten() + .unwrap_or_default(); + return Ok(Json(VersionInfo { + generation: page.generation, + version_id: version.version_id.clone(), + title: version.title, + file_count: version.file_count, + total_bytes: version.total_bytes, + has_worker: version.has_worker != 0, + note: version.note, + created_at: version.created_at, + deployed: page.deployed_version_id.as_deref() == Some(version.version_id.as_str()), + preview_url_path: external_page_url( + &state, + &format!("/p/{username}/{slug}/@v/{}", version.version_id), + ), + })); + } + } + let session = state + .page_upload_manager + .sessions + .get(&page_key) + .map(|entry| entry.value().clone()) + .ok_or(StatusCode::CONFLICT)?; + if session.upload_id != body.upload_id + || !upload_request_matches_session_intent( + body.expected_generation.as_deref(), + body.create, + &session, + ) + || !session.finalized + { + return Err(StatusCode::CONFLICT); + } + if session.expires_at <= Instant::now() { + state.page_upload_manager.sessions.remove(&page_key); + state.asset_store.cleanup_room(&session.draft_key); + return Err(StatusCode::GONE); + } + + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(&auth.user_id, &slug) + .await; + + let current_page = PageRow::get(db, &auth.user_id, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !generation_intent_matches( + current_page.as_ref(), + session.expected_generation.as_deref(), + session.create, + ) { + return Err(StatusCode::CONFLICT); + } + + let count = PageVersionRow::count_for_page(db, &auth.user_id, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if count >= MAX_VERSIONS_PER_PAGE { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + + let draft_key = session.draft_key.clone(); + if !state.asset_store.has_room_files(&draft_key) { + return Err(StatusCode::BAD_REQUEST); + } + + let version_id = new_page_version_id(); + let version_key = page_version_asset_key(&auth.user_id, &slug, &version_id); + let asset_store = Arc::clone(&state.asset_store); + let draft_key_c = draft_key.clone(); + let version_key_c = version_key.clone(); + let copied = + tokio::task::spawn_blocking(move || asset_store.copy_room(&draft_key_c, &version_key_c)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if copied.is_err() { + state.asset_store.cleanup_room(&version_key); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let entries = state.asset_store.list_room_entries(&version_key); + let has_worker = entries.iter().any(|(p, _)| p == WORKER_ENTRY_PATH); + let has_index = entries.iter().any(|(p, _)| p == "index.html"); + if !has_worker && !has_index { + state.asset_store.cleanup_room(&version_key); + return Err(StatusCode::BAD_REQUEST); + } + + // Derive version stats from the actual stored files; never trust + // client-reported file_count/total_bytes for quota accounting. + let file_count = entries.len() as i64; + let total_bytes = state.asset_store.room_total_bytes(&version_key); + if total_bytes > MAX_PAGE_BYTES { + state.asset_store.cleanup_room(&version_key); + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + let total_bytes = total_bytes as i64; + let session_title = session.title.clone().ok_or(StatusCode::CONFLICT)?; + let visibility = session.visibility.ok_or(StatusCode::CONFLICT)?; + let title = if body.title.is_empty() { + session_title + } else { + body.title.clone() + }; + + let save_outcome = PageRow::save_version_with_meta( + db, + &auth.user_id, + &slug, + visibility, + &title, + &version_id, + file_count, + total_bytes, + has_worker, + &body.note, + body.upload_id.as_deref(), + MAX_PAGES_PER_USER, + MAX_VERSIONS_PER_PAGE, + session.expected_generation.as_deref(), + session.create, + ) + .await; + match save_outcome { + Ok(SavePageVersionOutcome::Saved) => {} + Ok( + SavePageVersionOutcome::PageLimitReached | SavePageVersionOutcome::VersionLimitReached, + ) => { + state.asset_store.cleanup_room(&version_key); + return Err(StatusCode::TOO_MANY_REQUESTS); + } + Ok(SavePageVersionOutcome::GenerationMismatch) => { + state.asset_store.cleanup_room(&version_key); + return Err(StatusCode::CONFLICT); + } + Err(_) => { + state.asset_store.cleanup_room(&version_key); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + // Consume the exact finalized upload session after a successful freeze. + state.page_upload_manager.sessions.remove(&page_key); + state.asset_store.cleanup_room(&draft_key); + + let username = UserRow::find_by_username_for_user_id(db, &auth.user_id) + .await + .ok() + .flatten() + .unwrap_or_default(); + let generation = PageRow::get(db, &auth.user_id, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::CONFLICT)? + .generation; + + Ok(Json(VersionInfo { + generation, + version_id: version_id.clone(), + title, + file_count, + total_bytes, + has_worker, + note: body.note, + created_at: chrono::Utc::now().timestamp(), + deployed: false, + preview_url_path: external_page_url( + &state, + &format!("/p/{username}/{slug}/@v/{version_id}"), + ), + })) +} + +async fn list_versions( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Query(query): Query, +) -> Result>, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + if !is_valid_slug(&slug) { + return Err(StatusCode::BAD_REQUEST); + } + let expected_generation = + validate_optional_expected_generation(query.expected_generation.as_deref())?; + // Legacy migration itself needs the write lease. Keep it through the + // Page/version snapshot so an omitted generation is bound exactly once + // and cannot drift across a local delete/recreate transition. + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(&auth.user_id, &slug) + .await; + let page = bind_page_generation_locked(db, &auth.user_id, &slug, expected_generation).await?; + let bound_generation = page.generation.clone(); + maybe_migrate_legacy_page_locked(&state, &auth.user_id, &slug, &bound_generation).await; + // The migration is generation-fenced in SQLite. Re-read before assembling + // the response as a second guard against another relay process replacing + // the Page while the migration performed storage work. + let page = + bind_page_generation_locked(db, &auth.user_id, &slug, Some(&bound_generation)).await?; + let versions = PageVersionRow::list_for_page(db, &auth.user_id, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let username = UserRow::find_by_username_for_user_id(db, &auth.user_id) + .await + .ok() + .flatten() + .unwrap_or_default(); + let deployed = page.deployed_version_id.clone(); + + Ok(Json( + versions + .into_iter() + .map(|v| VersionInfo { + generation: page.generation.clone(), + preview_url_path: external_page_url( + &state, + &format!("/p/{username}/{slug}/@v/{}", v.version_id), + ), + deployed: deployed.as_deref() == Some(v.version_id.as_str()), + version_id: v.version_id, + title: v.title, + file_count: v.file_count, + total_bytes: v.total_bytes, + has_worker: v.has_worker != 0, + note: v.note, + created_at: v.created_at, + }) + .collect(), + )) +} + +async fn deploy_version( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(body): Json, +) -> Result, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + let expected_generation = + validate_optional_expected_generation(body.expected_generation.as_deref())?; + if !is_valid_slug(&slug) { + return Err(StatusCode::BAD_REQUEST); + } + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(&auth.user_id, &slug) + .await; + let page = bind_page_generation_locked(db, &auth.user_id, &slug, expected_generation).await?; + let page = + match PageRow::deploy_version(db, &auth.user_id, &slug, &body.version_id, &page.generation) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + PageMutationOutcome::Applied(page) => page, + PageMutationOutcome::NotFound => return Err(StatusCode::NOT_FOUND), + PageMutationOutcome::GenerationMismatch => return Err(StatusCode::CONFLICT), + }; + let username = UserRow::find_by_username_for_user_id(db, &auth.user_id) + .await + .ok() + .flatten() + .unwrap_or_default(); + Ok(Json(page_to_info(&state, &page, &username))) +} + +async fn unpublish_page( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Query(query): Query, +) -> Result { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + let expected_generation = + validate_optional_expected_generation(query.expected_generation.as_deref())?; + if !is_valid_slug(&slug) { + return Err(StatusCode::BAD_REQUEST); + } + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(&auth.user_id, &slug) + .await; + let page = bind_page_generation_locked(db, &auth.user_id, &slug, expected_generation).await?; + let outcome = PageRow::clear_deployed_version(db, &auth.user_id, &slug, &page.generation) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + match outcome { + PageMutationOutcome::Applied(()) => {} + PageMutationOutcome::NotFound => return Err(StatusCode::NOT_FOUND), + PageMutationOutcome::GenerationMismatch => return Err(StatusCode::CONFLICT), + } + Ok(StatusCode::NO_CONTENT) +} + +async fn delete_version( + State(state): State, + headers: HeaderMap, + Path((slug, version_id)): Path<(String, String)>, + Query(query): Query, +) -> Result, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + let expected_generation = + validate_optional_expected_generation(query.expected_generation.as_deref())?; + if !is_valid_slug(&slug) { + return Err(StatusCode::BAD_REQUEST); + } + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(&auth.user_id, &slug) + .await; + let page = bind_page_generation_locked(db, &auth.user_id, &slug, expected_generation).await?; + match PageVersionRow::delete_if_not_deployed( + db, + &auth.user_id, + &slug, + &version_id, + &page.generation, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + DeletePageVersionOutcome::Deleted => {} + DeletePageVersionOutcome::Deployed => return Err(StatusCode::BAD_REQUEST), + DeletePageVersionOutcome::NotFound => return Err(StatusCode::NOT_FOUND), + DeletePageVersionOutcome::GenerationMismatch => return Err(StatusCode::CONFLICT), + } + let key = page_version_asset_key(&auth.user_id, &slug, &version_id); + state.asset_store.cleanup_room(&key); + Ok(Json( + serde_json::json!({ "status": "ok", "version_id": version_id }), + )) +} + +async fn update_page( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(body): Json, +) -> Result, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + let expected_generation = + validate_optional_expected_generation(body.expected_generation.as_deref())?; + if !is_valid_slug(&slug) { + return Err(StatusCode::BAD_REQUEST); + } + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(&auth.user_id, &slug) + .await; + let page = bind_page_generation_locked(db, &auth.user_id, &slug, expected_generation).await?; + let previous_visibility = page.visibility.clone(); + let visibility = match body.visibility.as_deref() { + Some(v) => Some(PageVisibility::parse(v).ok_or(StatusCode::BAD_REQUEST)?), + None => None, + }; + if body + .title + .as_deref() + .is_some_and(|title| !is_valid_page_title(title)) + { + return Err(StatusCode::BAD_REQUEST); + } + let page = match PageRow::update_meta( + db, + &auth.user_id, + &slug, + &page.generation, + visibility, + body.title.as_deref(), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + PageMutationOutcome::Applied(page) => page, + PageMutationOutcome::NotFound => return Err(StatusCode::NOT_FOUND), + PageMutationOutcome::GenerationMismatch => return Err(StatusCode::CONFLICT), + }; + if page.visibility != previous_visibility { + state.page_access_manager.revoke_page(&auth.user_id, &slug); + } + let username = UserRow::find_by_username_for_user_id(db, &auth.user_id) + .await + .ok() + .flatten() + .unwrap_or_default(); + Ok(Json(page_to_info(&state, &page, &username))) +} + +async fn delete_page( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Query(query): Query, +) -> Result, StatusCode> { + let auth = validate_auth(&state, &headers).await?; + let db = require_db(&state)?; + let expected_generation = + validate_optional_expected_generation(query.expected_generation.as_deref())?; + if !is_valid_slug(&slug) { + return Err(StatusCode::BAD_REQUEST); + } + let page_key = page_upload_session_key(&auth.user_id, &slug); + let _page_guard = state.page_upload_manager.lock_for(&page_key).lock().await; + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(&auth.user_id, &slug) + .await; + let page = bind_page_generation_locked(db, &auth.user_id, &slug, expected_generation).await?; + let versions = PageVersionRow::list_for_page(db, &auth.user_id, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if let Some(store) = &state.page_data { + store + .prepare_generation(&auth.user_id, &slug, &page.generation) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + // Commit the relational deletion first. Filesystem/object cleanup is + // idempotent, whereas deleting assets before a failed multi-table DB + // mutation could leave a partially present Page with missing content. + // Mutable PageData has already been assigned to this Page generation, so a + // crash after this commit cannot expose it to a recreated Page generation. + let deleted = PageRow::delete(db, &auth.user_id, &slug, &page.generation) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + match deleted { + PageMutationOutcome::Applied(()) => {} + PageMutationOutcome::NotFound => return Err(StatusCode::NOT_FOUND), + PageMutationOutcome::GenerationMismatch => return Err(StatusCode::CONFLICT), + } + state.page_access_manager.revoke_page(&auth.user_id, &slug); + if let Some((_, active_upload)) = state.page_upload_manager.sessions.remove(&page_key) { + state.asset_store.cleanup_room(&active_upload.draft_key); + } + for v in &versions { + state.asset_store.cleanup_room(&page_version_asset_key( + &auth.user_id, + &slug, + &v.version_id, + )); + } + state + .asset_store + .cleanup_room(&page_draft_asset_key(&auth.user_id, &slug)); + state + .asset_store + .cleanup_room(&page_legacy_asset_key(&auth.user_id, &slug)); + if let Some(store) = &state.page_data { + store.cleanup_page(&auth.user_id, &slug); + } + Ok(Json(serde_json::json!({ "status": "ok", "slug": slug }))) +} + +// ── Serve ─────────────────────────────────────────────────────────────── + +async fn serve_prod_root( + State(state): State, + method: Method, + headers: HeaderMap, + Path((username, slug)): Path<(String, String)>, + body: axum::body::Bytes, +) -> Result { + serve_page(state, method, headers, &username, &slug, None, "", body).await +} + +async fn serve_prod_path( + State(state): State, + method: Method, + headers: HeaderMap, + Path((username, slug, path)): Path<(String, String, String)>, + body: axum::body::Bytes, +) -> Result { + // Guard against treating `@v/...` as production path when catch-all matches wrongly. + if path.starts_with("@v/") || path == "@v" { + return Err(StatusCode::NOT_FOUND); + } + serve_page(state, method, headers, &username, &slug, None, &path, body).await +} + +async fn serve_preview_root( + State(state): State, + method: Method, + headers: HeaderMap, + Path((username, slug, version_id)): Path<(String, String, String)>, + body: axum::body::Bytes, +) -> Result { + serve_page( + state, + method, + headers, + &username, + &slug, + Some(version_id.as_str()), + "", + body, + ) + .await +} + +async fn serve_preview_path( + State(state): State, + method: Method, + headers: HeaderMap, + Path((username, slug, version_id, path)): Path<(String, String, String, String)>, + body: axum::body::Bytes, +) -> Result { + serve_page( + state, + method, + headers, + &username, + &slug, + Some(version_id.as_str()), + &path, + body, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn serve_page( + state: AppState, + method: Method, + headers: HeaderMap, + username: &str, + slug: &str, + version_override: Option<&str>, + path: &str, + body: axum::body::Bytes, +) -> Result { + if state + .page_browser_auth + .as_ref() + .is_some_and(|config| !request_host_matches_origin(&headers, &config.public_origin)) + { + return Err(StatusCode::NOT_FOUND); + } + let db = require_db(&state)?; + if !is_valid_slug(slug) || username.is_empty() || username.contains("..") { + return Err(StatusCode::NOT_FOUND); + } + if path.contains("..") { + return Err(StatusCode::NOT_FOUND); + } + + let initial_page = PageRow::get_by_username(db, username, slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + // Legacy migration needs the lifecycle write lock. Modern Pages must stay + // on the shared serving path: taking that write lock for every production + // request would serialize traffic behind any running Page Function. Only + // a row with no deployment, no version, and legacy assets is a migration + // candidate; the locked helper revalidates the generation and all of these + // facts before committing. + let should_migrate_legacy = version_override.is_none() + && initial_page.deployed_version_id.is_none() + && PageVersionRow::count_for_page(db, &initial_page.user_id, slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + == 0 + && state + .asset_store + .has_room_files(&page_legacy_asset_key(&initial_page.user_id, slug)); + if should_migrate_legacy { + maybe_migrate_legacy_page_by_ids( + &state, + &initial_page.user_id, + slug, + &initial_page.generation, + ) + .await; + } + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_read(&initial_page.user_id, slug) + .await; + // A request may have resolved the Page just before a delete obtained the + // write lock. Re-resolve under the read lock so it cannot execute stale + // worker code or write mutable data after deletion/recreation. + let page = PageRow::get_by_username(db, username, slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + if let Err(status) = enforce_visibility(&state, &headers, &page, version_override).await { + if status == StatusCode::UNAUTHORIZED && (method == Method::GET || method == Method::HEAD) { + let visibility = page + .visibility_enum() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + return page_login_required_response( + &state, + &page, + visibility, + username, + version_override, + ); + } + return Err(status); + } + + // Resolve version. + let version_id = if let Some(v) = version_override { + v.to_string() + } else { + page.deployed_version_id + .clone() + .ok_or(StatusCode::NOT_FOUND)? + }; + + let version = PageVersionRow::get(db, &page.user_id, slug, &version_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let asset_key = page_version_asset_key(&page.user_id, slug, &version_id); + let lookup_path = if path.is_empty() || path.ends_with('/') { + if path.is_empty() { + "index.html".to_string() + } else { + format!("{path}index.html") + } + } else { + path.to_string() + }; + + if version.has_worker != 0 { + return serve_with_worker( + state, + method, + headers, + &page, + &version, + &asset_key, + &lookup_path, + path, + body, + ) + .await; + } + + // Static-only version. + if method != Method::GET && method != Method::HEAD { + return Err(StatusCode::METHOD_NOT_ALLOWED); + } + let asset_store = Arc::clone(&state.asset_store); + let asset_key_io = asset_key.clone(); + let lookup_io = lookup_path.clone(); + let content = + tokio::task::spawn_blocking(move || asset_store.get_file(&asset_key_io, &lookup_io)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let mime = mime_from_path(&lookup_path); + Ok(( + [ + (header::CONTENT_TYPE, mime), + (header::CACHE_CONTROL, "no-store"), + ( + header::HeaderName::from_static("x-content-type-options"), + "nosniff", + ), + ], + axum::body::Body::from(content), + ) + .into_response()) +} + +#[allow(clippy::too_many_arguments)] +async fn serve_with_worker( + state: AppState, + method: Method, + headers: HeaderMap, + page: &PageWithUsername, + version: &PageVersionRow, + asset_key: &str, + lookup_path: &str, + raw_path: &str, + body: axum::body::Bytes, +) -> Result { + if body.len() > crate::page_execution::MAX_PAGE_FUNCTION_REQUEST_BODY_BYTES { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + let _execution_permit = state + .page_execution_guard + .try_acquire(&page.user_id, &page.slug) + .map_err(|_| StatusCode::TOO_MANY_REQUESTS)?; + let page_data = state.page_data.clone().ok_or(StatusCode::NOT_IMPLEMENTED)?; + let db = state.db.clone().ok_or(StatusCode::NOT_IMPLEMENTED)?; + let asset_store = Arc::clone(&state.asset_store); + let asset_store_fallback = Arc::clone(&state.asset_store); + let asset_key = asset_key.to_string(); + let lookup_path_owned = lookup_path.to_string(); + let worker_source = { + let store = Arc::clone(&asset_store); + let key = asset_key.clone(); + tokio::task::spawn_blocking(move || store.get_file_exact(&key, WORKER_ENTRY_PATH)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)? + }; + let worker_source = String::from_utf8(worker_source).map_err(|_| StatusCode::BAD_GATEWAY)?; + + // Never forward credential headers into the page worker: a malicious page + // owner could persist viewer tokens via env.KV/BLOBS and harvest them. + let mut req_headers = HashMap::new(); + for (k, v) in headers.iter() { + if matches!( + k.as_str(), + "authorization" | "cookie" | "proxy-authorization" + ) { + continue; + } + if let Ok(val) = v.to_str() { + req_headers.insert(k.as_str().to_string(), val.to_string()); + } + } + let path_for_req = if raw_path.is_empty() { + "/".to_string() + } else { + format!("/{raw_path}") + }; + let url = format!( + "/p/{}/{}/@v/{}{}", + page.username, + page.slug, + version.version_id, + if raw_path.is_empty() { + String::new() + } else { + format!("/{raw_path}") + } + ); + + let host = Arc::new(RelayPageHost { + db, + page_data, + user_id: page.user_id.clone(), + slug: page.slug.clone(), + generation: page.generation.clone(), + meta: PageMeta { + username: page.username.clone(), + slug: page.slug.clone(), + version_id: version.version_id.clone(), + visibility: page.visibility.clone(), + }, + asset_store, + asset_key: asset_key.clone(), + }); + + let fetch_req = FetchRequest { + method: method.as_str().to_string(), + url, + path: path_for_req, + headers: req_headers, + body: if body.is_empty() { + None + } else { + Some(String::from_utf8_lossy(&body).into_owned()) + }, + }; + + let response = tokio::task::spawn_blocking(move || { + run_fetch(&worker_source, &fetch_req, host, DEFAULT_TIMEOUT) + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|e| { + tracing::warn!("Page function error: {e}"); + if matches!(e, PageFunctionError::Timeout(_)) { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + } + })?; + + // If worker returns 404 for document GET, fall back to static assets. + if response.status == 404 && (method == Method::GET || method == Method::HEAD) { + let key = asset_key.clone(); + let lookup = lookup_path_owned.clone(); + if let Ok(Some(bytes)) = + tokio::task::spawn_blocking(move || asset_store_fallback.get_file(&key, &lookup)).await + { + let mime = mime_from_path(&lookup_path_owned); + return Ok(( + [ + (header::CONTENT_TYPE, mime), + (header::CACHE_CONTROL, "no-store"), + ( + header::HeaderName::from_static("x-content-type-options"), + "nosniff", + ), + ], + axum::body::Body::from(bytes), + ) + .into_response()); + } + } + + let mut builder = axum::http::Response::builder() + .status(response.status) + .header("x-content-type-options", "nosniff") + // Visibility is mutable while a Page version is immutable. Reusing a + // previously public cached document after switching to protected + // visibility would bypass the Relay check entirely. + .header(header::CACHE_CONTROL, "no-store"); + for (k, v) in response.headers { + if let (Ok(name), Ok(val)) = ( + axum::http::HeaderName::try_from(k), + axum::http::HeaderValue::try_from(v), + ) { + if should_forward_page_worker_response_header(&name) { + builder = builder.header(name, val); + } + } + } + builder + .body(axum::body::Body::from(response.body)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +/// Filter worker-controlled response headers that can mutate origin-wide +/// browser policy/state or connection framing. This is defense in depth: Page +/// documents still share an origin with the relay until hosting moves to a +/// dedicated Page origin. Ordinary representation and CORS headers remain +/// available to Page authors. +fn should_forward_page_worker_response_header(name: &axum::http::HeaderName) -> bool { + !matches!( + name.as_str(), + "accept-ch" + | "alt-svc" + | "cache-control" + | "clear-site-data" + | "connection" + | "content-length" + | "critical-ch" + | "keep-alive" + | "nel" + | "proxy-authenticate" + | "proxy-authorization" + | "report-to" + | "reporting-endpoints" + | "service-worker-allowed" + | "set-cookie" + | "set-cookie2" + | "strict-transport-security" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "x-content-type-options" + ) +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +fn page_to_info(state: &AppState, page: &PageRow, username: &str) -> PageInfo { + let url_path = if username.is_empty() || page.deployed_version_id.is_none() { + String::new() + } else { + external_page_url(state, &format!("/p/{username}/{}", page.slug)) + }; + let preview_url_path = page.deployed_version_id.as_ref().map(|v| { + if username.is_empty() { + String::new() + } else { + external_page_url(state, &format!("/p/{username}/{}/@v/{v}", page.slug)) + } + }); + PageInfo { + slug: page.slug.clone(), + generation: page.generation.clone(), + visibility: page.visibility.clone(), + title: page.title.clone(), + file_count: page.file_count, + total_bytes: page.total_bytes, + created_at: page.created_at, + updated_at: page.updated_at, + url_path, + preview_url_path, + deployed_version_id: page.deployed_version_id.clone(), + } +} + +async fn maybe_migrate_legacy_page(state: &AppState, page: &PageRow) { + maybe_migrate_legacy_page_by_ids(state, &page.user_id, &page.slug, &page.generation).await; +} + +async fn maybe_migrate_legacy_page_by_ids( + state: &AppState, + user_id: &str, + slug: &str, + expected_generation: &str, +) { + let _lifecycle_guard = state + .page_execution_guard + .acquire_page_write(user_id, slug) + .await; + maybe_migrate_legacy_page_locked(state, user_id, slug, expected_generation).await; +} + +async fn maybe_migrate_legacy_page_locked( + state: &AppState, + user_id: &str, + slug: &str, + expected_generation: &str, +) { + let Some(db) = state.db.as_ref() else { + return; + }; + let Ok(Some(page)) = PageRow::get(db, user_id, slug).await else { + return; + }; + if page.generation != expected_generation { + return; + } + if page.deployed_version_id.is_some() { + return; + } + let Ok(count) = PageVersionRow::count_for_page(db, user_id, slug).await else { + return; + }; + if count > 0 { + return; + } + let legacy = page_legacy_asset_key(user_id, slug); + if !state.asset_store.has_room_files(&legacy) { + return; + } + let version_id = "v1".to_string(); + let version_key = page_version_asset_key(user_id, slug, &version_id); + let legacy_entries = state + .asset_store + .list_room_entries(&legacy) + .into_iter() + .collect::>(); + let target_preexisted = state.asset_store.has_room_files(&version_key); + if target_preexisted { + let target_entries = state + .asset_store + .list_room_entries(&version_key) + .into_iter() + .collect::>(); + if target_entries != legacy_entries { + tracing::warn!( + "Skipped legacy Page migration because synthetic v1 assets already differ: {user_id}/{slug}" + ); + return; + } + } else if state.asset_store.copy_room(&legacy, &version_key).is_err() { + return; + } + let entries = state.asset_store.list_room_entries(&version_key); + let has_worker = entries.iter().any(|(p, _)| p == WORKER_ENTRY_PATH); + let migration = PageRow::migrate_legacy_version( + db, + user_id, + slug, + expected_generation, + &version_id, + &page.title, + page.file_count, + page.total_bytes, + has_worker, + ) + .await; + if matches!(migration, Ok(PageMutationOutcome::Applied(true))) { + tracing::info!("Migrated legacy page {user_id}/{slug} to version v1"); + return; + } + + if !target_preexisted { + // A concurrent process may have committed the same v1 between the + // asset copy and our fenced transaction. Preserve the room only when + // the database now references it; otherwise this attempt owns and + // cleans the uncommitted synthetic target. + let target_is_committed = match PageRow::get(db, user_id, slug).await { + Ok(Some(current)) if current.deployed_version_id.as_deref() == Some(&version_id) => { + PageVersionRow::get(db, user_id, slug, &version_id) + .await + .ok() + .flatten() + .is_some() + } + _ => false, + }; + if !target_is_committed { + state.asset_store.cleanup_room(&version_key); + } + } +} + +async fn enforce_visibility( + state: &AppState, + headers: &HeaderMap, + page: &PageWithUsername, + version_id: Option<&str>, +) -> Result<(), StatusCode> { + let visibility = page + .visibility_enum() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + match visibility { + PageVisibility::Public => Ok(()), + PageVisibility::Relay => { + if state.page_access_manager.authorizes_page( + headers, + &page.user_id, + &page.slug, + &page.generation, + version_id, + ) || resolve_viewer(state, headers).await.is_ok() + { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } + PageVisibility::Private => { + if state.page_access_manager.authorizes_page( + headers, + &page.user_id, + &page.slug, + &page.generation, + version_id, + ) { + return Ok(()); + } + match resolve_viewer(state, headers).await { + Ok(viewer) if viewer.user_id == page.user_id => Ok(()), + Ok(_) => Err(StatusCode::NOT_FOUND), + Err(StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) => { + Err(StatusCode::UNAUTHORIZED) + } + Err(status) => Err(status), + } + } + } +} + +async fn resolve_viewer(state: &AppState, headers: &HeaderMap) -> Result { + // Only the Authorization header is accepted for viewer auth. Query-string + // tokens are deliberately unsupported: published pages serve arbitrary + // same-origin user JS, which could read tokens from `location.search`. + let token = extract_bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; + validate_token(state, &token).await +} + +fn process_check_page_files( + asset_store: Arc, + asset_key: &str, + upload_id: Option, + expected_generation: Option, + create: bool, + files: Vec, +) -> Result { + let mut needed = Vec::new(); + let mut existing_count = 0usize; + let total_count = files.len(); + for entry in files { + if crate::validated_asset_relative_path(&entry.path).is_err() + || !crate::is_valid_content_hash(&entry.hash) + { + needed.push(entry.path); + continue; + } + if asset_store.has_content(&entry.hash) { + existing_count += 1; + asset_store + .map_to_room(asset_key, &entry.path, &entry.hash) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } else { + needed.push(entry.path); + } + } + Ok(CheckPageFilesResponse { + upload_id, + expected_generation, + create, + needed, + existing_count, + total_count, + freeze_idempotency_supported: true, + }) +} + +fn process_upload_page_files( + asset_store: Arc, + asset_key: &str, + files: HashMap, +) -> Result { + // Decode and verify the whole batch before writing anything, then enforce + // the cumulative draft quota up front. Checking only after storing would + // let repeated rejected batches bloat the content-addressed store. + let mut decoded_files: Vec<(String, String, Vec)> = Vec::with_capacity(files.len()); + let mut batch_bytes: u64 = 0; + for (rel_path, entry) in files { + crate::validated_asset_relative_path(&rel_path).map_err(|_| StatusCode::BAD_REQUEST)?; + if !crate::is_valid_content_hash(&entry.hash) { + return Err(StatusCode::BAD_REQUEST); + } + let decoded = B64 + .decode(&entry.content) + .map_err(|_| StatusCode::BAD_REQUEST)?; + if decoded.len() as u64 > MAX_FILE_BYTES { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + let actual_hash = hex_sha256(&decoded); + if actual_hash != entry.hash { + return Err(StatusCode::BAD_REQUEST); + } + batch_bytes = batch_bytes.saturating_add(decoded.len() as u64); + decoded_files.push((rel_path, actual_hash, decoded)); + } + if asset_store + .room_total_bytes(asset_key) + .saturating_add(batch_bytes) + > MAX_PAGE_BYTES + { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + + let mut stored = 0usize; + for (rel_path, actual_hash, decoded) in decoded_files { + if !asset_store.has_content(&actual_hash) { + asset_store + .store_content(&actual_hash, decoded) + .map_err(crate::asset_store_error_status)?; + stored += 1; + } + asset_store + .map_to_room(asset_key, &rel_path, &actual_hash) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + Ok(stored) +} + +fn hex_sha256(data: &[u8]) -> String { + let digest = Sha256::digest(data); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +fn mime_from_path(p: &str) -> &'static str { + match p.rsplit('.').next() { + Some("html") => "text/html; charset=utf-8", + Some("js") => "application/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("json") => "application/json", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("svg") => "image/svg+xml", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{connect, page_kv, AuthToken, DeviceRow, PageRow, UserRow}; + use crate::relay::RoomManager; + use crate::MemoryAssetStore; + use axum::body::to_bytes; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + #[test] + fn management_generation_validation_only_normalizes_the_legacy_empty_value() { + let valid = "0123456789abcdef0123456789abcdef"; + assert_eq!(validate_optional_expected_generation(None).unwrap(), None); + assert_eq!( + validate_optional_expected_generation(Some("")).unwrap(), + None + ); + assert_eq!( + validate_optional_expected_generation(Some(valid)).unwrap(), + Some(valid) + ); + assert_eq!( + validate_optional_expected_generation(Some(" ")).unwrap_err(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + validate_optional_expected_generation(Some("not-a-generation")).unwrap_err(), + StatusCode::BAD_REQUEST + ); + assert_eq!(parse_page_path_prefix(Some("/relay/")).unwrap(), "/relay"); + assert_eq!( + parse_page_path_prefix(Some("/relay/../admin")).unwrap_err(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + parse_page_path_prefix(Some("/relay; Domain=attacker.invalid")).unwrap_err(), + StatusCode::BAD_REQUEST + ); + } + + #[test] + fn worker_response_headers_cannot_mutate_origin_wide_browser_state() { + for name in [ + "service-worker-allowed", + "set-cookie", + "cache-control", + "clear-site-data", + "strict-transport-security", + "reporting-endpoints", + "transfer-encoding", + ] { + let name = axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(); + assert!(!should_forward_page_worker_response_header(&name), "{name}"); + } + + for name in ["content-type", "access-control-allow-origin"] { + let name = axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(); + assert!(should_forward_page_worker_response_header(&name), "{name}"); + } + } + + async fn setup_app_with_page_auth( + page_browser_auth: Option, + ) -> (axum::Router, String, String, Arc) { + let pool = connect(":memory:").await.unwrap(); + let pool = Arc::new(pool); + UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + UserRow::create(&pool, "u2", "bob", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&pool, "d1", "u1", "Laptop", None) + .await + .unwrap(); + DeviceRow::upsert(&pool, "d2", "u2", "Phone", None) + .await + .unwrap(); + let tok_alice = AuthToken::create(&pool, "u1", "d1").await.unwrap(); + let tok_bob = AuthToken::create(&pool, "u2", "d2").await.unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let page_data_dir = tmp.path().join("page-data"); + std::mem::forget(tmp); + + let app = crate::build_relay_router_with_page_data_origins_and_page_auth( + RoomManager::new(), + Arc::new(MemoryAssetStore::new()), + std::time::Instant::now(), + Some(Arc::clone(&pool)), + "test", + Some(page_data_dir), + Vec::new(), + page_browser_auth, + ); + (app, tok_alice.token, tok_bob.token, pool) + } + + async fn setup_app_with_pool() -> (axum::Router, String, String, Arc) { + setup_app_with_page_auth(None).await + } + + async fn setup_app() -> (axum::Router, String, String) { + let (app, alice, bob, _) = setup_app_with_pool().await; + (app, alice, bob) + } + + async fn test_page_generation(app: &axum::Router, token: &str, slug: &str) -> Option { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/pages") + .header("Authorization", format!("Bearer {token}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let pages: Vec = serde_json::from_slice(&body).unwrap(); + pages.into_iter().find_map(|page| { + (page["slug"].as_str() == Some(slug)) + .then(|| page["generation"].as_str().map(str::to_string)) + .flatten() + }) + } + + async fn begin_test_upload( + app: &axum::Router, + token: &str, + slug: &str, + files: &[(&str, &[u8])], + ) -> (String, serde_json::Map) { + let upload_id = uuid::Uuid::new_v4().simple().to_string(); + let expected_generation = test_page_generation(app, token, slug).await; + let create = expected_generation.is_none(); + let mut manifest = Vec::new(); + let mut upload_files = serde_json::Map::new(); + for (path, content) in files { + let hash = hex_sha256(content); + manifest.push(serde_json::json!({ + "path": path, + "hash": hash, + "size": content.len(), + })); + upload_files.insert( + (*path).to_string(), + serde_json::json!({ "content": B64.encode(content), "hash": hash }), + ); + } + let check = serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "files": manifest, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(check.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + (upload_id, upload_files) + } + + async fn finish_test_upload( + app: &axum::Router, + token: &str, + slug: &str, + visibility: &str, + upload_id: &str, + files: serde_json::Map, + ) -> String { + let expected_generation = test_page_generation(app, token, slug).await; + let create = expected_generation.is_none(); + let upload = serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "title": slug, + "visibility": visibility, + "files": files, + "finalize": true, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let freeze = serde_json::json!({ + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "title": slug, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/pages/{slug}/versions")) + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(freeze.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let version: serde_json::Value = serde_json::from_slice(&body).unwrap(); + version["version_id"].as_str().unwrap().to_string() + } + + #[tokio::test] + async fn completed_freeze_replays_the_original_version() { + let (app, alice, _) = setup_app().await; + let files = [("index.html", b"

retry

".as_slice())]; + let (upload_id, upload_files) = + begin_test_upload(&app, &alice, "freeze-retry", &files).await; + let first_version = finish_test_upload( + &app, + &alice, + "freeze-retry", + "private", + &upload_id, + upload_files, + ) + .await; + let replay = serde_json::json!({ + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "title": "freeze-retry", + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/freeze-retry/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(replay.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let replayed: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + replayed["version_id"].as_str(), + Some(first_version.as_str()) + ); + + let versions = app + .oneshot( + Request::builder() + .uri(format!( + "/api/pages/freeze-retry/versions?expected_generation={}", + replayed["generation"].as_str().unwrap() + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = to_bytes(versions.into_body(), usize::MAX).await.unwrap(); + let versions: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(versions.len(), 1); + } + + async fn save_version_with_legacy_generation_intent( + app: &axum::Router, + token: &str, + slug: &str, + html: &[u8], + ) -> (String, String) { + let upload_id = uuid::Uuid::new_v4().simple().to_string(); + let generation_before_check = test_page_generation(app, token, slug).await; + let hash = hex_sha256(html); + let check = serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "files": [{ "path": "index.html", "hash": hash, "size": html.len() }], + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(check.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let checked: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(checked["upload_id"].as_str(), Some(upload_id.as_str())); + assert_eq!( + checked["expected_generation"].as_str(), + generation_before_check.as_deref(), + ); + assert_eq!( + checked["create"].as_bool(), + Some(generation_before_check.is_none()) + ); + + // This is the pre-generation client wire shape: upload and freeze do + // not echo the new fields returned by check-files. + let upload = serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "title": slug, + "visibility": "private", + "files": { + "index.html": { "content": B64.encode(html), "hash": hash } + }, + "finalize": true, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let freeze = serde_json::json!({ + "upload_id": upload_id, + "title": slug, + "note": "legacy client", + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/pages/{slug}/versions")) + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(freeze.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let frozen: serde_json::Value = serde_json::from_slice(&body).unwrap(); + ( + frozen["generation"].as_str().unwrap().to_string(), + frozen["version_id"].as_str().unwrap().to_string(), + ) + } + + async fn save_and_deploy(app: &axum::Router, token: &str, slug: &str, html: &str) -> String { + save_and_deploy_with_visibility(app, token, slug, html, "public").await + } + + async fn save_and_deploy_with_visibility( + app: &axum::Router, + token: &str, + slug: &str, + html: &str, + visibility: &str, + ) -> String { + let hash = hex_sha256(html.as_bytes()); + let b64 = B64.encode(html.as_bytes()); + let upload_id = uuid::Uuid::new_v4().simple().to_string(); + let expected_generation = test_page_generation(app, token, slug).await; + let create = expected_generation.is_none(); + let check = serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "files": [{ "path": "index.html", "hash": hash, "size": html.len() }] + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(check.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let upload = serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "title": slug, + "visibility": visibility, + "finalize": true, + "files": { "index.html": { "content": b64, "hash": hash } } + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let freeze = serde_json::json!({ + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "title": slug, + "note": "test" + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/pages/{slug}/versions")) + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(freeze.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let version_id = v["version_id"].as_str().unwrap().to_string(); + let generation = v["generation"].as_str().unwrap(); + + let deploy = serde_json::json!({ + "version_id": version_id, + "expected_generation": generation, + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/pages/{slug}/deploy")) + .header("Authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(deploy.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + version_id + } + + #[tokio::test] + async fn save_does_not_publish_until_deploy() { + let (app, alice, _) = setup_app().await; + let hash = hex_sha256(b"draft"); + let b64 = B64.encode(b"draft"); + let upload_id = uuid::Uuid::new_v4().simple().to_string(); + let check = serde_json::json!({ + "slug": "staged", + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "files": [{ + "path": "index.html", + "hash": hash, + "size": b"draft".len() + }] + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(check.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let upload = serde_json::json!({ + "slug": "staged", + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "title": "staged", + "visibility": "public", + "files": { "index.html": { "content": b64, "hash": hash } } + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/staged") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + let version_id = save_and_deploy(&app, &alice, "staged2", "live").await; + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/staged2") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store" + ); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/p/alice/staged2/@v/{version_id}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn republish_manifest_removes_files_not_present_in_the_new_version() { + let (app, alice, _) = setup_app().await; + let first_files = [ + ("index.html", b"first".as_slice()), + ("removed.txt", b"must not survive".as_slice()), + ]; + let (first_upload, first_map) = + begin_test_upload(&app, &alice, "clean-republish", &first_files).await; + let first_version = finish_test_upload( + &app, + &alice, + "clean-republish", + "public", + &first_upload, + first_map, + ) + .await; + assert_eq!( + get_page( + &app, + &format!("/p/alice/clean-republish/@v/{first_version}/removed.txt"), + None, + ) + .await, + StatusCode::OK + ); + + let second_files = [("index.html", b"second".as_slice())]; + let (second_upload, second_map) = + begin_test_upload(&app, &alice, "clean-republish", &second_files).await; + let second_version = finish_test_upload( + &app, + &alice, + "clean-republish", + "public", + &second_upload, + second_map, + ) + .await; + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/p/alice/clean-republish/@v/{second_version}/removed.txt" + )) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Static Page routing falls back to the new index.html for unknown + // paths. The old file content must not survive in the new manifest. + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(String::from_utf8_lossy(&body), "second"); + } + + #[tokio::test] + async fn superseded_upload_session_cannot_mix_or_freeze_files() { + let (app, alice, _) = setup_app().await; + let first_files = [("index.html", b"first".as_slice())]; + let second_files = [("index.html", b"second".as_slice())]; + let (first_upload, first_map) = + begin_test_upload(&app, &alice, "concurrent", &first_files).await; + let (second_upload, second_map) = + begin_test_upload(&app, &alice, "concurrent", &second_files).await; + + let stale_upload = serde_json::json!({ + "slug": "concurrent", + "upload_id": first_upload, + "visibility": "private", + "files": first_map, + "finalize": true, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(stale_upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + + let _ = finish_test_upload( + &app, + &alice, + "concurrent", + "private", + &second_upload, + second_map, + ) + .await; + let stale_freeze = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/concurrent/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ "upload_id": first_upload }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_freeze.status(), StatusCode::CONFLICT); + } + + #[tokio::test] + async fn legacy_upload_requests_without_upload_id_remain_compatible() { + let (app, alice, _) = setup_app().await; + let html = b"legacy"; + let hash = hex_sha256(html); + let check = serde_json::json!({ + "slug": "legacy-client", + "expected_generation": null, + "create": true, + "files": [{ "path": "index.html", "hash": hash, "size": html.len() }] + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(check.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let check_response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(check_response.get("upload_id").is_none()); + + let upload = serde_json::json!({ + "slug": "legacy-client", + "expected_generation": null, + "create": true, + "title": "Legacy client", + "visibility": "private", + "files": { + "index.html": { "content": B64.encode(html), "hash": hash } + }, + "finalize": true + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/legacy-client/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "expected_generation": null, + "create": true, + "title": "Legacy client", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn pre_generation_upload_client_is_bound_to_new_and_existing_page_generations() { + let (app, alice, _) = setup_app().await; + let (created_generation, first_version) = save_version_with_legacy_generation_intent( + &app, + &alice, + "rolling-upgrade", + b"first", + ) + .await; + let (updated_generation, second_version) = save_version_with_legacy_generation_intent( + &app, + &alice, + "rolling-upgrade", + b"second", + ) + .await; + + assert_eq!(updated_generation, created_generation); + assert_ne!(second_version, first_version); + let response = app + .oneshot( + Request::builder() + .uri(format!( + "/api/pages/rolling-upgrade/versions?expected_generation={created_generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let versions: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(versions.len(), 2); + assert!(versions + .iter() + .all(|version| version["generation"] == created_generation)); + } + + #[tokio::test] + async fn pre_generation_management_client_binds_each_request_to_the_current_page() { + let (app, alice, _) = setup_app().await; + let (generation, first_version) = save_version_with_legacy_generation_intent( + &app, + &alice, + "legacy-management", + b"first", + ) + .await; + let (same_generation, second_version) = save_version_with_legacy_generation_intent( + &app, + &alice, + "legacy-management", + b"second", + ) + .await; + assert_eq!(same_generation, generation); + + let deployed = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/legacy-management/deploy") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "version_id": first_version, + "expected_generation": "", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deployed.status(), StatusCode::OK); + + let open_ticket = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/legacy-management") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(open_ticket.status(), StatusCode::OK); + + let listed = app + .clone() + .oneshot( + Request::builder() + .uri("/api/pages/legacy-management/versions?expected_generation=") + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(listed.status(), StatusCode::OK); + let listed_body = to_bytes(listed.into_body(), usize::MAX).await.unwrap(); + let listed_versions: Vec = serde_json::from_slice(&listed_body).unwrap(); + assert_eq!(listed_versions.len(), 2); + assert!(listed_versions + .iter() + .all(|version| version["generation"] == generation)); + + let updated = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/pages/legacy-management") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ "title": "Updated by legacy client" }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(updated.status(), StatusCode::OK); + let updated_body = to_bytes(updated.into_body(), usize::MAX).await.unwrap(); + let updated_page: serde_json::Value = serde_json::from_slice(&updated_body).unwrap(); + assert_eq!(updated_page["generation"], generation); + assert_eq!(updated_page["title"], "Updated by legacy client"); + + let unpublished = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/legacy-management/unpublish") + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unpublished.status(), StatusCode::NO_CONTENT); + + let redeployed = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/legacy-management/deploy") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ "version_id": second_version }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(redeployed.status(), StatusCode::OK); + + let deleted_version = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/api/pages/legacy-management/versions/{first_version}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deleted_version.status(), StatusCode::OK); + + let deleted_page = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/pages/legacy-management") + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deleted_page.status(), StatusCode::OK); + assert!(test_page_generation(&app, &alice, "legacy-management") + .await + .is_none()); + } + + #[tokio::test] + async fn expired_session_pruning_waits_for_the_target_page_lock() { + let manager = Arc::new(PageUploadManager::new()); + let key = page_upload_session_key("u1", "locked"); + manager.sessions.insert( + key.clone(), + PageUploadSession { + user_id: "u1".to_string(), + upload_id: Some("a".repeat(32)), + draft_key: "pages/u1/locked/draft/id".to_string(), + manifest: HashMap::new(), + finalized: false, + title: None, + visibility: None, + expected_generation: None, + create: true, + expires_at: Instant::now() - Duration::from_secs(1), + }, + ); + let page_guard = manager.lock_for(&key).lock().await; + let pruning_manager = Arc::clone(&manager); + let mut pruning = + tokio::spawn(async move { pruning_manager.prune_expired(Instant::now()).await }); + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut pruning) + .await + .is_err() + ); + manager.sessions.get_mut(&key).unwrap().expires_at = + Instant::now() + PAGE_UPLOAD_SESSION_TTL; + drop(page_guard); + assert!(pruning.await.unwrap().is_empty()); + assert!(manager.sessions.contains_key(&key)); + } + + #[tokio::test] + async fn upload_sessions_are_bounded_per_account_without_starving_others() { + let (app, alice, bob) = setup_app().await; + let hash = hex_sha256(b"x"); + for index in 0..MAX_PAGE_UPLOAD_SESSIONS_PER_USER { + let check = serde_json::json!({ + "slug": format!("site-{index}"), + "upload_id": format!("{index:032x}"), + "expected_generation": null, + "create": true, + "files": [{ "path": "index.html", "hash": hash, "size": 1 }] + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(check.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + let overflow = serde_json::json!({ + "slug": "site-overflow", + "upload_id": "f".repeat(32), + "expected_generation": null, + "create": true, + "files": [{ "path": "index.html", "hash": hash, "size": 1 }] + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(overflow.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + + let other_user = serde_json::json!({ + "slug": "other-user-site", + "upload_id": "e".repeat(32), + "expected_generation": null, + "create": true, + "files": [{ "path": "index.html", "hash": hash, "size": 1 }] + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {bob}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(other_user.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn page_metadata_and_max_file_upload_bounds_match_the_http_contract() { + assert!(is_valid_page_title("A valid title")); + assert!(!is_valid_page_title("bad\ntitle")); + assert!(!is_valid_page_title(&"x".repeat(MAX_PAGE_TITLE_CHARS + 1))); + assert!(is_valid_page_note("")); + assert!(!is_valid_page_note("bad\tnote")); + assert!(!is_valid_page_note(&"x".repeat(MAX_PAGE_NOTE_BYTES + 1))); + + let bytes = vec![0_u8; MAX_FILE_BYTES as usize]; + let encoded = B64.encode(&bytes); + let payload = serde_json::json!({ + "slug": "max-file", + "upload_id": "a".repeat(32), + "title": "Max file", + "visibility": "private", + "files": { + "index.html": { "content": encoded, "hash": hex_sha256(&bytes) } + }, + "finalize": true + }) + .to_string(); + assert!(payload.len() <= PAGE_UPLOAD_BODY_LIMIT); + + let (app, alice, _) = setup_app().await; + let files = [("index.html", bytes.as_slice())]; + let (upload_id, files) = begin_test_upload(&app, &alice, "max-file", &files).await; + let upload = serde_json::json!({ + "slug": "max-file", + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "title": "Max file", + "visibility": "private", + "files": files, + "finalize": true + }); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn page_metadata_bounds_are_enforced_by_upload_freeze_and_update_routes() { + let (app, alice, _) = setup_app().await; + let files = [("index.html", b"bounded".as_slice())]; + let (upload_id, upload_files) = + begin_test_upload(&app, &alice, "bounded-meta", &files).await; + let invalid_upload = serde_json::json!({ + "slug": "bounded-meta", + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "title": "bad\ntitle", + "visibility": "private", + "files": upload_files, + "finalize": true + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(invalid_upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let (upload_id, upload_files) = + begin_test_upload(&app, &alice, "bounded-meta", &files).await; + let valid_upload = serde_json::json!({ + "slug": "bounded-meta", + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "title": "Bounded", + "visibility": "private", + "files": upload_files, + "finalize": true + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(valid_upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/bounded-meta/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "note": "bad\tnote", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/bounded-meta/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "note": "valid", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let frozen: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let generation = frozen["generation"].as_str().unwrap(); + let response = app + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/pages/bounded-meta") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "expected_generation": generation, + "title": "x".repeat(MAX_PAGE_TITLE_CHARS + 1), + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn worker_can_use_kv() { + let (app, alice, _) = setup_app().await; + let worker = r#" + function fetch(request, env) { + if (request.path === "/api/hello") { + env.KV.put("msg", "hi"); + return { status: 200, headers: { "content-type": "text/plain" }, body: env.KV.get("msg") }; + } + return env.ASSETS.fetch("index.html"); + } + "#; + let files = [ + ("index.html", b"static".as_slice()), + ("server/worker.js", worker.as_bytes()), + ]; + let (upload_id, map) = begin_test_upload(&app, &alice, "fn", &files).await; + let upload = serde_json::json!({ + "slug": "fn", + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "title": "fn", + "visibility": "public", + "files": map, + "finalize": true + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let freeze = serde_json::json!({ + "upload_id": upload_id, + "expected_generation": null, + "create": true, + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/fn/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(freeze.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let version_id = v["version_id"].as_str().unwrap(); + let generation = v["generation"].as_str().unwrap(); + assert_eq!(v["has_worker"], true); + + let deploy = serde_json::json!({ + "version_id": version_id, + "expected_generation": generation, + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/fn/deploy") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(deploy.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/p/alice/fn/api/hello")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(String::from_utf8_lossy(&body), "hi"); + } + + #[tokio::test] + async fn deployed_page_request_does_not_wait_for_running_worker_read_lease() { + let (app, alice, _, pool) = setup_app_with_pool().await; + let worker = r#" + function fetch(request, env) { + if (request.path === "/hold") { + env.KV.put("hold-started", "yes"); + const until = Date.now() + 1200; + while (Date.now() < until) {} + return { status: 200, headers: { "content-type": "text/plain" }, body: "held" }; + } + return { status: 200, headers: { "content-type": "text/plain" }, body: "fast" }; + } + "#; + let files = [("server/worker.js", worker.as_bytes())]; + let (upload_id, map) = begin_test_upload(&app, &alice, "shared-read", &files).await; + let version = + finish_test_upload(&app, &alice, "shared-read", "public", &upload_id, map).await; + let generation = test_page_generation(&app, &alice, "shared-read") + .await + .unwrap(); + let deploy = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/shared-read/deploy") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "version_id": version, + "expected_generation": generation, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deploy.status(), StatusCode::OK); + + let holding_request = tokio::spawn({ + let app = app.clone(); + async move { + app.oneshot( + Request::builder() + .uri("/p/alice/shared-read/hold") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + }); + let mut observed_start = false; + for _ in 0..200 { + if page_kv::get(&pool, "u1", "shared-read", "hold-started") + .await + .unwrap() + .as_deref() + == Some("yes") + { + observed_start = true; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(observed_start, "holding worker did not start"); + + let fast_response = tokio::time::timeout( + Duration::from_millis(400), + app.clone().oneshot( + Request::builder() + .uri("/p/alice/shared-read/fast") + .body(axum::body::Body::empty()) + .unwrap(), + ), + ) + .await + .expect("deployed Page request waited for an existing read lease") + .unwrap(); + assert_eq!(fast_response.status(), StatusCode::OK); + let body = to_bytes(fast_response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(body.as_ref(), b"fast"); + assert_eq!(holding_request.await.unwrap().status(), StatusCode::OK); + } + + #[tokio::test] + async fn page_delete_waits_for_running_worker_and_leaves_no_orphan_kv() { + let (app, alice, _, pool) = setup_app_with_pool().await; + let worker = r#" + function fetch(request, env) { + env.KV.put("started", "yes"); + const until = Date.now() + 150; + while (Date.now() < until) {} + env.KV.put("late", "yes"); + return { status: 200, headers: { "content-type": "text/plain" }, body: "done" }; + } + "#; + let files = [("server/worker.js", worker.as_bytes())]; + let (upload_id, map) = begin_test_upload(&app, &alice, "delete-running", &files).await; + let version = + finish_test_upload(&app, &alice, "delete-running", "public", &upload_id, map).await; + let generation = test_page_generation(&app, &alice, "delete-running") + .await + .unwrap(); + let deploy = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/delete-running/deploy") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "version_id": version, + "expected_generation": generation, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deploy.status(), StatusCode::OK); + + let worker_app = app.clone(); + let worker_request = tokio::spawn(async move { + worker_app + .oneshot( + Request::builder() + .uri("/p/alice/delete-running/run") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap() + }); + let mut observed_start = false; + for _ in 0..200 { + if page_kv::get(&pool, "u1", "delete-running", "started") + .await + .unwrap() + .as_deref() + == Some("yes") + { + observed_start = true; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(observed_start, "worker did not start before delete"); + + let mut deletion = tokio::spawn({ + let app = app.clone(); + let alice = alice.clone(); + let generation = generation.clone(); + async move { + app.oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/api/pages/delete-running?expected_generation={generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + }); + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut deletion) + .await + .is_err() + ); + assert_eq!(worker_request.await.unwrap().status(), StatusCode::OK); + assert_eq!(deletion.await.unwrap().status(), StatusCode::OK); + assert!(PageRow::get(&pool, "u1", "delete-running") + .await + .unwrap() + .is_none()); + assert!(page_kv::get(&pool, "u1", "delete-running", "late") + .await + .unwrap() + .is_none()); + } + + async fn get_page(app: &axum::Router, uri: &str, token: Option<&str>) -> StatusCode { + let mut builder = Request::builder().uri(uri); + if let Some(token) = token { + builder = builder.header("Authorization", format!("Bearer {token}")); + } + let resp = app + .clone() + .oneshot(builder.body(axum::body::Body::empty()).unwrap()) + .await + .unwrap(); + resp.status() + } + + async fn login_page_cookie( + app: &axum::Router, + username: &str, + slug: &str, + version_id: Option<&str>, + ) -> (String, String) { + let return_to = match version_id { + Some(version_id) => format!("/p/alice/{slug}/@v/{version_id}"), + None => format!("/p/alice/{slug}"), + }; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-auth/login") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "username": username, + "password_hash": "hash", + "return_to": return_to, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + let cookie = set_cookie.split(';').next().unwrap().to_string(); + (cookie, set_cookie) + } + + #[tokio::test] + async fn visibility_matrix_private_relay_public() { + let (app, alice, bob) = setup_app().await; + save_and_deploy_with_visibility(&app, &alice, "priv", "p", "private").await; + save_and_deploy_with_visibility(&app, &alice, "rel", "r", "relay").await; + save_and_deploy(&app, &alice, "pub", "u").await; + + // Private: anonymous visitors are prompted to sign in, foreign + // authenticated users cannot discover it, and the owner can view it. + let anonymous_private = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/priv") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous_private.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + anonymous_private + .headers() + .get(header::CONTENT_TYPE) + .unwrap(), + "text/html; charset=utf-8" + ); + assert!(anonymous_private + .headers() + .get(header::CONTENT_SECURITY_POLICY) + .is_some()); + let body = to_bytes(anonymous_private.into_body(), usize::MAX) + .await + .unwrap(); + let login_html = String::from_utf8_lossy(&body); + assert!(login_html.contains("登录后访问")); + assert!(login_html.contains("data-page-login-username")); + assert_eq!( + get_page(&app, "/p/alice/priv", Some(&bob)).await, + StatusCode::NOT_FOUND + ); + assert_eq!( + get_page(&app, "/p/alice/priv", Some(&alice)).await, + StatusCode::OK + ); + // Query-string tokens must be ignored (same-origin JS could steal them). + assert_eq!( + get_page(&app, &format!("/p/alice/priv?access_token={alice}"), None).await, + StatusCode::UNAUTHORIZED + ); + + // Relay: any authenticated relay user may view, anonymous may not. + assert_eq!( + get_page(&app, "/p/alice/rel", None).await, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + get_page(&app, "/p/alice/rel", Some(&bob)).await, + StatusCode::OK + ); + assert_eq!( + get_page(&app, "/p/alice/rel", Some(&alice)).await, + StatusCode::OK + ); + + // Public: no credentials needed. + assert_eq!(get_page(&app, "/p/alice/pub", None).await, StatusCode::OK); + + let generation = test_page_generation(&app, &alice, "pub").await.unwrap(); + let public_open = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/pub") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ "expected_generation": generation }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(public_open.status(), StatusCode::OK); + let body = to_bytes(public_open.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["open_url_path"], "/p/alice/pub"); + assert_eq!(json["page_url_path"], "/p/alice/pub"); + assert_eq!(json["expires_in_seconds"], 0); + } + + #[tokio::test] + async fn visibility_change_revokes_existing_browser_grants() { + let (app, alice, _) = setup_app().await; + save_and_deploy_with_visibility( + &app, + &alice, + "visibility-revoke", + "private", + "private", + ) + .await; + let (cookie, _) = login_page_cookie(&app, "alice", "visibility-revoke", None).await; + let authorized = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/visibility-revoke") + .header(header::COOKIE, &cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authorized.status(), StatusCode::OK); + + let generation = test_page_generation(&app, &alice, "visibility-revoke") + .await + .unwrap(); + let update = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/pages/visibility-revoke") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "expected_generation": generation, + "visibility": "relay", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(update.status(), StatusCode::OK); + + let stale_grant = app + .oneshot( + Request::builder() + .uri("/p/alice/visibility-revoke") + .header(header::COOKIE, cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_grant.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn browser_login_enforces_page_visibility_and_sets_scoped_http_only_cookie() { + let (app, alice, _bob) = setup_app().await; + let private_version = save_and_deploy_with_visibility( + &app, + &alice, + "private-open", + "p", + "private", + ) + .await; + save_and_deploy_with_visibility(&app, &alice, "other-private", "o", "private") + .await; + save_and_deploy_with_visibility(&app, &alice, "relay-open", "r", "relay") + .await; + + let open_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/private-open") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "expected_generation": test_page_generation( + &app, + &alice, + "private-open", + ) + .await + .unwrap(), + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(open_response.status(), StatusCode::OK); + let body = to_bytes(open_response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["open_url_path"], "/p/alice/private-open"); + assert_eq!(json["page_url_path"], "/p/alice/private-open"); + assert_eq!(json["expires_in_seconds"], 0); + + let login_page = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/private-open") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login_page.status(), StatusCode::UNAUTHORIZED); + assert!(login_page.headers().get(header::SET_COOKIE).is_none()); + let body = to_bytes(login_page.into_body(), usize::MAX).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("data-page-login-username")); + assert!(html.contains("data-page-login-password")); + assert!(html.contains("/api/page-auth/client.js")); + + let script = app + .clone() + .oneshot( + Request::builder() + .uri("/api/page-auth/client.js") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(script.status(), StatusCode::OK); + assert_eq!( + script.headers().get(header::CONTENT_TYPE).unwrap(), + "application/javascript; charset=utf-8" + ); + + let wrong_password = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-auth/login") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "username": "alice", + "password_hash": "wrong", + "return_to": "/p/alice/private-open", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(wrong_password.status(), StatusCode::UNAUTHORIZED); + assert!(wrong_password.headers().get(header::SET_COOKIE).is_none()); + + let foreign_private = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-auth/login") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "username": "bob", + "password_hash": "hash", + "return_to": "/p/alice/private-open", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(foreign_private.status(), StatusCode::FORBIDDEN); + assert!(foreign_private.headers().get(header::SET_COOKIE).is_none()); + + let login = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-auth/login") + .header("x-forwarded-proto", "https") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "username": "alice", + "password_hash": "hash", + "return_to": "/p/alice/private-open", + "path_prefix": "/relay", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login.status(), StatusCode::OK); + let cookie = login + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Lax")); + assert!(cookie.contains("Secure")); + assert!(cookie.contains("Path=/relay/p/alice/private-open")); + assert!(!cookie.contains(&alice)); + let browser_cookie = cookie.split(';').next().unwrap(); + + let authorized = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/private-open") + .header(header::COOKIE, browser_cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authorized.status(), StatusCode::OK); + + // A production browser session is not a blanket grant for immutable + // preview routes, even when the preview belongs to the same Page. + let wrong_version_scope = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/p/alice/private-open/@v/{private_version}")) + .header(header::COOKIE, browser_cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(wrong_version_scope.status(), StatusCode::UNAUTHORIZED); + + let wrong_page = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/other-private") + .header(header::COOKIE, browser_cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(wrong_page.status(), StatusCode::UNAUTHORIZED); + + let relay_login = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-auth/login") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "username": "bob", + "password_hash": "hash", + "return_to": "/p/alice/relay-open", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(relay_login.status(), StatusCode::OK); + let relay_cookie = relay_login + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + let relay_page = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/relay-open") + .header(header::COOKIE, relay_cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(relay_page.status(), StatusCode::OK); + + let open_redirect = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-auth/login") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "username": "alice", + "password_hash": "hash", + "return_to": "https://attacker.invalid/", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(open_redirect.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn isolated_page_login_uses_one_time_cross_origin_callback() { + let config = crate::PageBrowserAuthConfig::new( + "https://pages.test/site", + "https://relay.test/relay", + ) + .unwrap(); + let (app, alice, _, _) = setup_app_with_page_auth(Some(config)).await; + save_and_deploy_with_visibility( + &app, + &alice, + "isolated-login", + "private", + "private", + ) + .await; + + let generation = test_page_generation(&app, &alice, "isolated-login") + .await + .unwrap(); + let open = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/isolated-login") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ "expected_generation": generation }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(open.status(), StatusCode::OK); + let body = to_bytes(open.into_body(), usize::MAX).await.unwrap(); + let open_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + open_json["page_url_path"], + "https://pages.test/site/p/alice/isolated-login" + ); + + let protected_page = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/isolated-login") + .header(header::HOST, "pages.test") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(protected_page.status().is_redirection()); + assert!(protected_page.headers().get(header::SET_COOKIE).is_none()); + let sign_in_url = protected_page + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap() + .to_string(); + let state = sign_in_url + .split_once("?state=") + .map(|(_, state)| state) + .unwrap(); + assert_eq!(state.len(), 64); + assert!(sign_in_url.starts_with("https://relay.test/relay/api/page-auth/sign-in")); + + let page_on_auth_origin = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/isolated-login") + .header(header::HOST, "relay.test") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(page_on_auth_origin.status(), StatusCode::NOT_FOUND); + + let wrong_host = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/page-auth/sign-in?state={state}")) + .header(header::HOST, "pages.test") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(wrong_host.status(), StatusCode::NOT_FOUND); + + let sign_in = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/page-auth/sign-in?state={state}")) + .header(header::HOST, "relay.test") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(sign_in.status(), StatusCode::OK); + let body = to_bytes(sign_in.into_body(), usize::MAX).await.unwrap(); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("data-page-login-username")); + assert!(html.contains(&format!(r#"data-page-login-state="{state}""#))); + assert!(html.contains(r#"src="./client.js""#)); + + let login = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-auth/login") + .header(header::HOST, "relay.test") + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "username": "alice", + "password_hash": "hash", + "state": state, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login.status(), StatusCode::OK); + assert!(login.headers().get(header::SET_COOKIE).is_none()); + let body = to_bytes(login.into_body(), usize::MAX).await.unwrap(); + let login_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let callback_url = login_json["redirect_to"].as_str().unwrap(); + let code = callback_url + .split_once("?code=") + .map(|(_, code)| code) + .unwrap(); + assert_eq!(code.len(), 64); + assert!(callback_url.starts_with("https://pages.test/site/api/page-auth/callback")); + + let callback = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/page-auth/callback?code={code}")) + .header(header::HOST, "pages.test") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(callback.status().is_redirection()); + assert_eq!( + callback.headers().get(header::LOCATION).unwrap(), + "https://pages.test/site/p/alice/isolated-login" + ); + let set_cookie = callback + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(set_cookie.contains("Path=/site/p/alice/isolated-login;")); + assert!(set_cookie.contains("HttpOnly")); + assert!(set_cookie.contains("Secure")); + let browser_cookie = set_cookie.split(';').next().unwrap(); + + let replay = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/page-auth/callback?code={code}")) + .header(header::HOST, "pages.test") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(replay.status(), StatusCode::GONE); + + let page = app + .oneshot( + Request::builder() + .uri("/p/alice/isolated-login") + .header(header::HOST, "pages.test") + .header(header::COOKIE, browser_cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(page.status(), StatusCode::OK); + } + + #[tokio::test] + async fn production_and_preview_browser_grants_can_coexist() { + let (app, alice, _) = setup_app().await; + let version = save_and_deploy_with_visibility( + &app, + &alice, + "cookie-scope", + "private", + "private", + ) + .await; + let (production_cookie, production_set_cookie) = + login_page_cookie(&app, "alice", "cookie-scope", None).await; + let (preview_cookie, preview_set_cookie) = + login_page_cookie(&app, "alice", "cookie-scope", Some(&version)).await; + assert!(production_set_cookie.contains("Path=/p/alice/cookie-scope;")); + assert!(preview_set_cookie.contains(&format!("Path=/p/alice/cookie-scope/@v/{version};"))); + + let combined = format!("{preview_cookie}; {production_cookie}"); + let production = app + .clone() + .oneshot( + Request::builder() + .uri("/p/alice/cookie-scope") + .header(header::COOKIE, &combined) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(production.status(), StatusCode::OK); + let preview = app + .oneshot( + Request::builder() + .uri(format!("/p/alice/cookie-scope/@v/{version}")) + .header(header::COOKIE, combined) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(preview.status(), StatusCode::OK); + } + + #[test] + fn access_grants_are_generation_bound_and_capacity_is_per_user() { + let manager = PageAccessManager::new(); + let grant = manager + .issue_browser_grant( + "u1".into(), + "viewer-1".into(), + "site".into(), + "generation-one".into(), + None, + ) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!("{PAGE_ACCESS_COOKIE}={grant}")).unwrap(), + ); + assert!(manager.authorizes_page(&headers, "u1", "site", "generation-one", None,)); + assert!(!manager.authorizes_page(&headers, "u1", "site", "generation-two", None,)); + + let bounded = PageAccessManager::new(); + for index in 0..MAX_PAGE_BROWSER_GRANTS_PER_USER { + bounded + .issue_browser_grant( + "u1".into(), + "viewer-1".into(), + format!("site-{index}"), + "generation".into(), + None, + ) + .unwrap(); + } + assert_eq!( + bounded + .issue_browser_grant( + "u1".into(), + "viewer-1".into(), + "overflow".into(), + "generation".into(), + None, + ) + .unwrap_err(), + StatusCode::TOO_MANY_REQUESTS + ); + assert!(bounded + .issue_browser_grant( + "u1".into(), + "viewer-2".into(), + "other".into(), + "generation".into(), + None, + ) + .is_ok()); + } + + #[tokio::test] + async fn deleting_and_recreating_a_page_rejects_the_old_browser_grant() { + let (app, alice, _) = setup_app().await; + save_and_deploy_with_visibility(&app, &alice, "recreated", "old", "private") + .await; + let (old_cookie, _) = login_page_cookie(&app, "alice", "recreated", None).await; + let old_generation = test_page_generation(&app, &alice, "recreated") + .await + .unwrap(); + let deleted = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/api/pages/recreated?expected_generation={old_generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deleted.status(), StatusCode::OK); + save_and_deploy_with_visibility(&app, &alice, "recreated", "new", "private") + .await; + let response = app + .oneshot( + Request::builder() + .uri("/p/alice/recreated") + .header(header::COOKIE, old_cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn recreated_page_rejects_all_stale_generation_management_requests() { + let (app, alice, _) = setup_app().await; + save_and_deploy_with_visibility(&app, &alice, "aba", "old", "private").await; + let old_generation = test_page_generation(&app, &alice, "aba").await.unwrap(); + + // Leave an upload request owned by A in flight. Deleting A must make + // this request unusable even after a new Page B takes the same slug. + let stale_files = [("index.html", b"stale upload".as_slice())]; + let (stale_upload_id, stale_upload_files) = + begin_test_upload(&app, &alice, "aba", &stale_files).await; + + let deleted = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/api/pages/aba?expected_generation={old_generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deleted.status(), StatusCode::OK); + + let new_version = + save_and_deploy_with_visibility(&app, &alice, "aba", "new", "private") + .await; + let new_generation = test_page_generation(&app, &alice, "aba").await.unwrap(); + assert_ne!(new_generation, old_generation); + + let stale_upload = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "slug": "aba", + "upload_id": stale_upload_id, + "title": "stale", + "visibility": "private", + "files": stale_upload_files, + "finalize": true, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_upload.status(), StatusCode::CONFLICT); + + let stale_update = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/pages/aba") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "expected_generation": old_generation, + "title": "stale overwrite", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_update.status(), StatusCode::CONFLICT); + + let stale_versions = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/api/pages/aba/versions?expected_generation={old_generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_versions.status(), StatusCode::CONFLICT); + + let stale_open = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/aba") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ "expected_generation": old_generation }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_open.status(), StatusCode::CONFLICT); + + let stale_deploy = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/aba/deploy") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "version_id": new_version, + "expected_generation": old_generation, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_deploy.status(), StatusCode::CONFLICT); + + let stale_unpublish = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/pages/aba/unpublish?expected_generation={old_generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_unpublish.status(), StatusCode::CONFLICT); + + let stale_version_delete = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/api/pages/aba/versions/{new_version}?expected_generation={old_generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_version_delete.status(), StatusCode::CONFLICT); + + let stale_check = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/check-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "slug": "aba", + "upload_id": uuid::Uuid::new_v4().simple().to_string(), + "expected_generation": old_generation, + "create": false, + "files": [{ + "path": "index.html", + "hash": hex_sha256(b"stale"), + "size": 5, + }], + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_check.status(), StatusCode::CONFLICT); + + let stale_delete = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!( + "/api/pages/aba?expected_generation={old_generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale_delete.status(), StatusCode::CONFLICT); + assert_eq!( + test_page_generation(&app, &alice, "aba").await.as_deref(), + Some(new_generation.as_str()), + ); + } + + #[tokio::test] + async fn unpublish_stops_production_without_deleting_versions() { + let (app, alice, _) = setup_app().await; + let version_id = save_and_deploy(&app, &alice, "pause-me", "live").await; + let generation = test_page_generation(&app, &alice, "pause-me") + .await + .unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/pages/pause-me/unpublish?expected_generation={generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + get_page(&app, "/p/alice/pause-me", None).await, + StatusCode::NOT_FOUND + ); + assert_eq!( + get_page(&app, &format!("/p/alice/pause-me/@v/{version_id}"), None,).await, + StatusCode::OK + ); + + let versions = app + .oneshot( + Request::builder() + .uri(format!( + "/api/pages/pause-me/versions?expected_generation={generation}" + )) + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(versions.status(), StatusCode::OK); + let body = to_bytes(versions.into_body(), usize::MAX).await.unwrap(); + let list: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 1); + assert_eq!(list[0]["deployed"], false); + } + + #[tokio::test] + async fn worker_does_not_receive_credential_headers() { + let (app, alice, _) = setup_app().await; + let worker = r#" + function fetch(request, env) { + const leaked = request.headers["authorization"] || request.headers["cookie"] || "none"; + return { status: 200, headers: { "content-type": "text/plain" }, body: leaked }; + } + "#; + let files = [("server/worker.js", worker.as_bytes())]; + let (upload_id, map) = begin_test_upload(&app, &alice, "hdr", &files).await; + let upload = serde_json::json!({ + "slug": "hdr", + "upload_id": upload_id, + "expected_generation": null, + "create": true, + "title": "hdr", + "visibility": "relay", + "files": map, + "finalize": true + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/hdr/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::json!({ + "upload_id": upload_id, + "expected_generation": null, + "create": true, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let version_id = v["version_id"].as_str().unwrap(); + let generation = v["generation"].as_str().unwrap(); + let deploy = serde_json::json!({ + "version_id": version_id, + "expected_generation": generation, + }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/hdr/deploy") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(deploy.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Viewer presents a valid bearer token to pass the relay gate; the + // worker must not see it. + let resp = app + .oneshot( + Request::builder() + .uri("/p/alice/hdr/api/echo") + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(String::from_utf8_lossy(&body), "none"); + } + + #[tokio::test] + async fn freeze_records_real_stats() { + let (app, alice, _) = setup_app().await; + let html = "real bytes"; + save_and_deploy(&app, &alice, "stats", html).await; + + let resp = app + .oneshot( + Request::builder() + .uri("/api/pages") + .header("Authorization", format!("Bearer {alice}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let pages: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let page = &pages[0]; + assert_eq!(page["file_count"].as_i64().unwrap(), 1); + assert_eq!(page["total_bytes"].as_i64().unwrap(), html.len() as i64); + } + + #[tokio::test] + async fn page_request_body_limit_is_applied_before_worker_execution() { + let (app, _, _) = setup_app().await; + let body = vec![0u8; crate::page_execution::MAX_PAGE_FUNCTION_REQUEST_BODY_BYTES + 1]; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/p/alice/missing/api") + .body(axum::body::Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } +} diff --git a/src/crates/services/relay-service/src/routes/sync.rs b/src/crates/services/relay-service/src/routes/sync.rs index 6b45672a5a..e36c50cd7b 100644 --- a/src/crates/services/relay-service/src/routes/sync.rs +++ b/src/crates/services/relay-service/src/routes/sync.rs @@ -20,6 +20,33 @@ use crate::routes::api::AppState; /// conversations. Keep an explicit ceiling so reverse proxies and operators /// can align `client_max_body_size` (nginx) / equivalent limits. pub const SYNC_BODY_LIMIT: usize = 64 * 1024 * 1024; +const MAX_ENCRYPTED_BLOB_BYTES: usize = 48 * 1024 * 1024; +const MAX_SESSION_ID_BYTES: usize = 256; +const MAX_NONCE_BYTES: usize = 256; + +// Per-account sync-session quotas. +// +// Defaults use i32::MAX so BitFun's own deployments do not hit artificial +// product caps. Keep these knobs (and the upsert/make-room enforcement paths) +// so self-hosted / open-source operators can lower them to bound each user's +// cloud session backup footprint. +const MAX_SYNC_SESSIONS_PER_USER: i64 = i32::MAX as i64; +const MAX_SYNC_SESSION_BYTES_PER_USER: i64 = i32::MAX as i64; + +fn valid_session_id(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= MAX_SESSION_ID_BYTES + && !value.chars().any(char::is_control) +} + +fn valid_encrypted_blob(encrypted_data: &str, nonce: &str, version: i64) -> bool { + !encrypted_data.is_empty() + && encrypted_data.len() <= MAX_ENCRYPTED_BLOB_BYTES + && !nonce.is_empty() + && nonce.len() <= MAX_NONCE_BYTES + && !nonce.chars().any(char::is_control) + && version > 0 +} /// Validated principal extracted from the bearer token. pub struct AuthUser { @@ -29,19 +56,31 @@ pub struct AuthUser { } /// Validate the bearer token in `headers`; returns the owning user/device. -async fn validate_auth(state: &AppState, headers: &HeaderMap) -> Result { - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let token = headers +pub async fn validate_auth(state: &AppState, headers: &HeaderMap) -> Result { + let token = extract_bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; + validate_token(state, &token).await +} + +/// Extract `Bearer` token from the `Authorization` header. +pub fn extract_bearer_token(headers: &HeaderMap) -> Option { + headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|s| s.strip_prefix("Bearer ")) .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) - .ok_or(StatusCode::UNAUTHORIZED)?; - let auth = AuthToken::find(db, &token) +} + +/// Validate a raw token string against the account database. +pub async fn validate_token(state: &AppState, token: &str) -> Result { + let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; + let auth = AuthToken::find(db, token) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::UNAUTHORIZED)?; + if !auth.is_device_token() { + return Err(StatusCode::FORBIDDEN); + } Ok(AuthUser { user_id: auth.user_id, device_id: auth.device_id, @@ -98,17 +137,55 @@ async fn sessions_upsert( Json(body): Json, ) -> Result { let auth = validate_auth(&state, &headers).await?; + if !valid_session_id(&body.session_id) + || !valid_encrypted_blob(&body.encrypted_data, &body.nonce, body.version) + { + return Err(StatusCode::BAD_REQUEST); + } let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - SyncSessionRow::upsert( + let mut stored = SyncSessionRow::upsert_with_quota( db, &auth.user_id, &body.session_id, &body.encrypted_data, &body.nonce, body.version, + MAX_SYNC_SESSIONS_PER_USER, + MAX_SYNC_SESSION_BYTES_PER_USER, ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !stored { + // Prefer keeping the session being uploaded: evict LRU cloud backups + // until this blob fits the configured per-user quotas, then retry once. + let evicted = SyncSessionRow::make_room_for_upsert( + db, + &auth.user_id, + &body.session_id, + body.encrypted_data.len() as i64, + MAX_SYNC_SESSIONS_PER_USER, + MAX_SYNC_SESSION_BYTES_PER_USER, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if evicted > 0 { + stored = SyncSessionRow::upsert_with_quota( + db, + &auth.user_id, + &body.session_id, + &body.encrypted_data, + &body.nonce, + body.version, + MAX_SYNC_SESSIONS_PER_USER, + MAX_SYNC_SESSION_BYTES_PER_USER, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + } + if !stored { + return Err(StatusCode::INSUFFICIENT_STORAGE); + } Ok(StatusCode::NO_CONTENT) } @@ -141,6 +218,9 @@ async fn sessions_get( Path(session_id): Path, ) -> Result, StatusCode> { let auth = validate_auth(&state, &headers).await?; + if !valid_session_id(&session_id) { + return Err(StatusCode::BAD_REQUEST); + } let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; let row = SyncSessionRow::get(db, &auth.user_id, &session_id) .await @@ -161,6 +241,9 @@ async fn sessions_delete( Path(session_id): Path, ) -> Result { let auth = validate_auth(&state, &headers).await?; + if !valid_session_id(&session_id) { + return Err(StatusCode::BAD_REQUEST); + } let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; SyncSessionRow::delete(db, &auth.user_id, &session_id) .await @@ -191,6 +274,9 @@ async fn settings_upsert( Json(body): Json, ) -> Result { let auth = validate_auth(&state, &headers).await?; + if !valid_encrypted_blob(&body.encrypted_data, &body.nonce, body.version) { + return Err(StatusCode::BAD_REQUEST); + } let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; SyncSettingsRow::upsert( db, diff --git a/src/crates/services/relay-service/src/routes/websocket.rs b/src/crates/services/relay-service/src/routes/websocket.rs index 4a30b975ca..a1a5fa1181 100644 --- a/src/crates/services/relay-service/src/routes/websocket.rs +++ b/src/crates/services/relay-service/src/routes/websocket.rs @@ -9,28 +9,60 @@ use axum::{ ws::{Message, WebSocket, WebSocketUpgrade}, State, }, - response::Response, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, }; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc::{self, error::TrySendError}; +use std::{ + collections::HashSet, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::{ + mpsc::{self, error::TrySendError}, + watch, +}; use tracing::{debug, error, info, warn}; use crate::relay::room::{send_outbound_message, ConnId, OutboundMessage, ResponsePayload}; use crate::routes::api::AppState; -const OUTBOUND_QUEUE_CAPACITY: usize = 256; +const OUTBOUND_QUEUE_CAPACITY: usize = i32::MAX as usize; +const MAX_WS_MESSAGE_BYTES: usize = 64 * 1024 * 1024; +const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 48 * 1024 * 1024; +const MAX_IDENTIFIER_BYTES: usize = 128; +const MAX_DEVICE_NAME_BYTES: usize = 256; +const MAX_PUBLIC_KEY_BYTES: usize = 512; +const MAX_NONCE_BYTES: usize = 256; +const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); +const MAX_MESSAGES_PER_WINDOW: u32 = i32::MAX as u32; +const DEVICE_TOKEN_REVALIDATION_INTERVAL: Duration = Duration::from_secs(5); + +struct ConnectionRateLimiter { + window_started: Instant, + message_count: u32, +} -fn truncate_preview(text: &str, max_bytes: usize) -> &str { - if text.len() <= max_bytes { - return text; +impl ConnectionRateLimiter { + fn new() -> Self { + Self { + window_started: Instant::now(), + message_count: 0, + } } - let mut end = max_bytes; - while end > 0 && !text.is_char_boundary(end) { - end -= 1; + fn allow(&mut self) -> bool { + if self.window_started.elapsed() >= RATE_LIMIT_WINDOW { + self.window_started = Instant::now(); + self.message_count = 0; + } + if self.message_count >= MAX_MESSAGES_PER_WINDOW { + return false; + } + self.message_count += 1; + true } - &text[..end] } /// Messages received from the desktop via WebSocket. @@ -117,37 +149,137 @@ pub struct DevicePresenceEntry { pub device_name: String, } -pub async fn websocket_handler(ws: WebSocketUpgrade, State(state): State) -> Response { - ws.max_message_size(64 * 1024 * 1024) - .max_frame_size(64 * 1024 * 1024) - .max_write_buffer_size(64 * 1024 * 1024) +pub async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, + headers: HeaderMap, +) -> Response { + if !is_websocket_origin_allowed(&headers, &state.cors_allow_origins) { + warn!("Rejected WebSocket connection from a disallowed browser origin"); + return StatusCode::FORBIDDEN.into_response(); + } + ws.max_message_size(MAX_WS_MESSAGE_BYTES) + .max_frame_size(MAX_WS_MESSAGE_BYTES) + .max_write_buffer_size(MAX_WS_MESSAGE_BYTES) .on_upgrade(move |socket| handle_socket(socket, state)) } +fn is_websocket_origin_allowed(headers: &HeaderMap, allowed_origins: &[String]) -> bool { + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + else { + // Native clients do not send Origin and authenticate at the protocol + // layer. Origin is a browser boundary, not a replacement for auth. + return true; + }; + if origin.eq_ignore_ascii_case("null") { + return false; + } + let Some(origin) = crate::normalized_browser_origin(origin) else { + return false; + }; + if allowed_origins + .iter() + .any(|allowed| allowed == "*" || allowed.eq_ignore_ascii_case(&origin)) + { + return true; + } + + let Some(host) = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + else { + return false; + }; + let origin_authority = origin + .split_once("://") + .map(|(_, authority)| authority) + .and_then(|authority| authority.split('/').next()); + origin_authority.is_some_and(|authority| authority.eq_ignore_ascii_case(host)) +} + async fn handle_socket(socket: WebSocket, state: AppState) { + ensure_device_token_revalidator(&state); let (mut ws_sender, mut ws_receiver) = socket.split(); let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); + let (force_close_tx, mut force_close_rx) = watch::channel(false); let conn_id = state.room_manager.next_conn_id(); + let mut rate_limiter = ConnectionRateLimiter::new(); + let mut token_expiry_task: Option> = None; info!("WebSocket connected: conn_id={conn_id}"); + let mut writer_force_close_rx = force_close_rx.clone(); + let writer_failure_close_tx = force_close_tx.clone(); let write_task = tokio::spawn(async move { - while let Some(msg) = out_rx.recv().await { - if !msg.text.is_empty() - && ws_sender - .send(Message::Text(msg.text.into())) - .await - .is_err() - { - break; + loop { + tokio::select! { + changed = writer_force_close_rx.changed() => { + if changed.is_ok() && *writer_force_close_rx.borrow() { + info!("Closing revoked WebSocket connection"); + let _ = ws_sender.send(Message::Close(None)).await; + } + break; + } + msg = out_rx.recv() => { + let Some(msg) = msg else { break }; + if !msg.text.is_empty() + && ws_sender + .send(Message::Text(msg.text.into())) + .await + .is_err() + { + // The read half can remain open after a write-half + // failure. Wake the owner loop so it promptly removes + // routing/presence instead of leaving a half-open + // device online until token expiry. + let _ = writer_failure_close_tx.send(true); + break; + } + } } } }); - while let Some(msg_result) = ws_receiver.next().await { + loop { + let msg_result = tokio::select! { + biased; + changed = force_close_rx.changed() => { + if changed.is_ok() && *force_close_rx.borrow() { + info!("Revoked WebSocket connection: conn_id={conn_id}"); + } + break; + } + msg = ws_receiver.next() => { + let Some(msg) = msg else { break }; + msg + } + }; + if !rate_limiter.allow() { + warn!("WebSocket rate limit exceeded: conn_id={conn_id}"); + let _ = send_json_best_effort( + &out_tx, + &OutboundProtocol::Error { + message: "message rate limit exceeded".into(), + }, + ); + break; + } match msg_result { Ok(Message::Text(text)) => { - if !handle_text_message(&text, conn_id, &state, &out_tx).await { + if !handle_text_message( + &text, + conn_id, + &state, + &out_tx, + &force_close_tx, + &mut token_expiry_task, + ) + .await + { break; } } @@ -156,6 +288,16 @@ async fn handle_socket(socket: WebSocket, state: AppState) { info!("WebSocket close from conn_id={conn_id}"); break; } + Ok(Message::Binary(_)) => { + warn!("Rejected binary WebSocket message: conn_id={conn_id}"); + let _ = send_json_best_effort( + &out_tx, + &OutboundProtocol::Error { + message: "binary messages are not supported".into(), + }, + ); + break; + } Err(e) => { error!("WebSocket error conn_id={conn_id}: {e}"); break; @@ -164,36 +306,132 @@ async fn handle_socket(socket: WebSocket, state: AppState) { } } + if let Some(task) = token_expiry_task { + task.abort(); + } + state.room_manager.on_disconnect(conn_id); + let _presence_projection_guard = state.device_manager.lock_presence_projection().await; if let Some((user_id, device_id)) = state.device_manager.unregister(conn_id) { // Best-effort: mark the device offline in the DB and notify peers. - if let Some(db) = state.db.as_ref() { - let _ = crate::db::DeviceRow::set_online(db, &device_id, false).await; + if !state.device_manager.is_device_online(&user_id, &device_id) { + if let Some(db) = state.db.as_ref() { + let _ = crate::db::DeviceRow::set_online(db, &user_id, &device_id, false).await; + } } - let remaining = state.device_manager.online_devices(&user_id); - let presence = build_presence(&remaining); - state.device_manager.broadcast_except( - &user_id, - &device_id, - &serde_json::to_string(&OutboundProtocol::DevicePresence { devices: presence }) - .unwrap_or_default(), - ); + state + .device_manager + .broadcast_current_presence(&user_id, |devices| { + serde_json::to_string(&OutboundProtocol::DevicePresence { + devices: build_presence(devices), + }) + .ok() + }); } + drop(_presence_projection_guard); drop(out_tx); let _ = write_task.await; info!("WebSocket disconnected: conn_id={conn_id}"); } +fn ensure_device_token_revalidator(state: &AppState) { + let Some(db) = state.db.clone() else { + return; + }; + if !state.device_manager.claim_token_revalidator_start() { + return; + } + let device_manager = Arc::clone(&state.device_manager); + tokio::spawn(async move { + let mut interval = tokio::time::interval(DEVICE_TOKEN_REVALIDATION_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Consume the immediate first tick. AuthConnect performs its own two + // checks; this worker is for later out-of-process revocation. + interval.tick().await; + loop { + interval.tick().await; + if let Err(error) = revalidate_active_device_tokens_once(&db, &device_manager).await { + warn!(%error, "Failed to revalidate active device tokens"); + } + } + }); +} + +async fn revalidate_active_device_tokens_once( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, +) -> anyhow::Result { + let active = device_manager.active_device_credentials(); + if active.is_empty() { + return Ok(0); + } + let tokens = active + .iter() + .map(|(_, _, _, token)| token.clone()) + .collect::>(); + let valid = crate::db::AuthToken::find_valid_device_tokens(db, &tokens) + .await? + .into_iter() + .map(|token| (token.user_id, token.device_id, token.token)) + .collect::>(); + let revoked = active + .into_iter() + .filter(|(_, user_id, device_id, token)| { + !valid.contains(&(user_id.clone(), device_id.clone(), token.clone())) + }) + .collect::>(); + if revoked.is_empty() { + return Ok(0); + } + + // A batch snapshot can race a same-process login/logout. Re-check each + // missing exact token under the lifecycle gate before removing its socket. + let _presence_projection_guard = device_manager.lock_presence_projection().await; + let mut affected_users = HashSet::new(); + let mut disconnected = 0; + for (_conn_id, user_id, device_id, token) in revoked { + match registered_device_token_is_current(db, &token, &user_id, &device_id).await { + Ok(true) => continue, + Err(error) => { + warn!(%error, %user_id, %device_id, "Failed exact token revalidation"); + continue; + } + Ok(false) => {} + } + if device_manager.disconnect_device_if_token(&user_id, &device_id, &token) { + disconnected += 1; + affected_users.insert(user_id.clone()); + if !device_manager.is_device_online(&user_id, &device_id) { + if let Err(error) = + crate::db::DeviceRow::set_online(db, &user_id, &device_id, false).await + { + warn!(%error, %user_id, %device_id, "Failed to project revoked device offline"); + } + } + } + } + for user_id in affected_users { + device_manager.broadcast_current_presence(&user_id, |devices| { + serde_json::to_string(&OutboundProtocol::DevicePresence { + devices: build_presence(devices), + }) + .ok() + }); + } + Ok(disconnected) +} + async fn handle_text_message( text: &str, conn_id: ConnId, state: &AppState, out_tx: &mpsc::Sender, + force_close_tx: &watch::Sender, + token_expiry_task: &mut Option>, ) -> bool { - debug!( - "Received from conn_id={conn_id}: {}", - truncate_preview(text, 200) - ); + if text.len() > MAX_WS_MESSAGE_BYTES { + return reject_protocol(out_tx, "message is too large"); + } let msg: InboundMessage = match serde_json::from_str(text) { Ok(m) => m, Err(e) => { @@ -201,19 +439,45 @@ async fn handle_text_message( return send_json_best_effort( out_tx, &OutboundProtocol::Error { - message: format!("invalid message format: {e}"), + message: "invalid message format".into(), }, ); } }; + let message_type = match &msg { + InboundMessage::CreateRoom { .. } => "create_room", + InboundMessage::RelayResponse { .. } => "relay_response", + InboundMessage::Heartbeat => "heartbeat", + InboundMessage::AuthConnect { .. } => "auth_connect", + InboundMessage::DeviceMessage { .. } => "device_message", + }; + debug!( + "Received WebSocket message: conn_id={conn_id} type={message_type} bytes={}", + text.len() + ); match msg { InboundMessage::CreateRoom { room_id, device_id, - device_type: _, + device_type, public_key, } => { + if state.device_manager.conn_mapping(conn_id).is_some() { + return reject_protocol( + out_tx, + "an authenticated device connection cannot create a pairing room", + ); + } + if !is_valid_identifier(&device_id) + || !is_valid_display_text(&device_type, 32) + || !is_valid_display_text(&public_key, MAX_PUBLIC_KEY_BYTES) + || room_id + .as_deref() + .is_some_and(|value| !crate::relay::room::is_valid_room_id(value)) + { + return reject_protocol(out_tx, "invalid room parameters"); + } let room_id = room_id.unwrap_or_else(generate_room_id); let ok = state.room_manager.create_room( &room_id, @@ -240,14 +504,22 @@ async fn handle_text_message( encrypted_data, nonce, } => { + if !is_valid_identifier(&correlation_id) + || !is_valid_encrypted_payload(&encrypted_data, &nonce) + { + return reject_protocol(out_tx, "invalid relay response"); + } debug!("RelayResponse from desktop conn_id={conn_id} corr={correlation_id}"); - state.room_manager.resolve_pending( + if !state.room_manager.resolve_pending_from_conn( + conn_id, &correlation_id, ResponsePayload { encrypted_data, nonce, }, - ); + ) { + return reject_protocol(out_tx, "relay response does not match this room"); + } true } @@ -269,6 +541,16 @@ async fn handle_text_message( } InboundMessage::AuthConnect { token, device_name } => { + if state.room_manager.has_connection(conn_id) + || state.device_manager.has_connection(conn_id) + { + return reject_protocol(out_tx, "connection is already authenticated"); + } + if !crate::db::is_valid_auth_token(&token) + || !is_valid_display_text(&device_name, MAX_DEVICE_NAME_BYTES) + { + return reject_protocol(out_tx, "invalid authentication parameters"); + } let Some(db) = state.db.as_ref() else { return send_json_best_effort( out_tx, @@ -288,47 +570,67 @@ async fn handle_text_message( ) } }; - // Mark the device online in the DB and the in-memory registry. - let _ = crate::db::DeviceRow::upsert( - db, - &auth.device_id, - &auth.user_id, - &device_name, - None, - ) - .await; - let _ = crate::db::DeviceRow::set_online(db, &auth.device_id, true).await; - let _others = state.device_manager.register( + if !auth.is_device_token() { + return send_json_best_effort( + out_tx, + &OutboundProtocol::AuthError { + message: "token is not valid for a device connection".into(), + }, + ); + } + // Make the candidate visible to token-scoped logout without + // exposing it to presence/routing or replacing the current active + // socket until the mandatory final token check succeeds. + state.device_manager.register_pending( &auth.user_id, &auth.device_id, + &token, &device_name, conn_id, out_tx.clone(), + force_close_tx.clone(), ); - send_json( - out_tx, - &OutboundProtocol::AuthOk { - user_id: auth.user_id.clone(), - device_id: auth.device_id.clone(), - }, - ) - .await; - // Full presence (including self) so clients can treat the snapshot - // as authoritative rather than an incremental patch. - let all_online = state.device_manager.online_devices(&auth.user_id); - let presence = build_presence(&all_online); - send_json_best_effort( - out_tx, - &OutboundProtocol::DevicePresence { - devices: presence.clone(), - }, - ); - state.device_manager.broadcast_except( + + let activated = match activate_pending_device_if_authorized( + db, + &state.device_manager, + &token, &auth.user_id, &auth.device_id, - &serde_json::to_string(&OutboundProtocol::DevicePresence { devices: presence }) - .unwrap_or_default(), - ); + &device_name, + conn_id, + ) + .await + { + Ok(activated) => activated, + Err(error) => { + warn!(%error, "Failed to activate authenticated device"); + false + } + }; + if !activated { + return false; + } + let expires_in = auth + .expires_at + .saturating_sub(chrono::Utc::now().timestamp()) + .max(0) as u64; + let expiry_close_tx = force_close_tx.clone(); + *token_expiry_task = Some(tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(expires_in)).await; + let _ = expiry_close_tx.send(true); + })); + // Full presence (including self) so clients can treat the snapshot + // as authoritative rather than an incremental patch. Snapshot and + // enqueue are serialized with membership mutations. + state + .device_manager + .broadcast_current_presence(&auth.user_id, |devices| { + serde_json::to_string(&OutboundProtocol::DevicePresence { + devices: build_presence(devices), + }) + .ok() + }); true } @@ -338,6 +640,12 @@ async fn handle_text_message( encrypted_data, nonce, } => { + if !is_valid_identifier(&target_device_id) + || !is_valid_identifier(&correlation_id) + || !is_valid_encrypted_payload(&encrypted_data, &nonce) + { + return reject_protocol(out_tx, "invalid device message"); + } // Look up the sender's (user_id, device_id) from the conn map. let sender = state.device_manager.conn_mapping(conn_id); let Some((user_id, source_device_id)) = sender else { @@ -355,10 +663,12 @@ async fn handle_text_message( encrypted_data: encrypted_data.clone(), nonce: nonce.clone(), }; - if state - .device_manager - .resolve_rpc(&correlation_id, rpc_response) - { + if state.device_manager.resolve_rpc( + &correlation_id, + &user_id, + &source_device_id, + rpc_response, + ) { // HTTP RPC resolved — the HTTP caller gets the response. return true; } @@ -387,6 +697,36 @@ async fn handle_text_message( } } +fn is_valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_IDENTIFIER_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn is_valid_display_text(value: &str, max_bytes: usize) -> bool { + !value.trim().is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control) +} + +fn is_valid_encrypted_payload(encrypted_data: &str, nonce: &str) -> bool { + !encrypted_data.is_empty() + && encrypted_data.len() <= MAX_ENCRYPTED_PAYLOAD_BYTES + && !nonce.is_empty() + && nonce.len() <= MAX_NONCE_BYTES + && !nonce.chars().any(char::is_control) +} + +fn reject_protocol(tx: &mpsc::Sender, message: &str) -> bool { + let _ = send_json_best_effort( + tx, + &OutboundProtocol::Error { + message: message.to_string(), + }, + ); + false +} + fn build_presence(devices: &[(String, String)]) -> Vec { devices .iter() @@ -397,9 +737,168 @@ fn build_presence(devices: &[(String, String)]) -> Vec { .collect() } +async fn registered_device_token_is_current( + db: &crate::db::DbPool, + token: &str, + expected_user_id: &str, + expected_device_id: &str, +) -> anyhow::Result { + Ok(crate::db::AuthToken::find(db, token) + .await? + .is_some_and(|current| { + current.is_device_token() + && current.user_id == expected_user_id + && current.device_id == expected_device_id + })) +} + +async fn project_device_offline_if_unowned( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + user_id: &str, + device_id: &str, +) -> anyhow::Result<()> { + if !device_manager.is_device_online(user_id, device_id) { + crate::db::DeviceRow::set_online(db, user_id, device_id, false).await?; + } + Ok(()) +} + +async fn reconcile_device_after_token_disconnect( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + user_id: &str, + device_id: &str, +) -> anyhow::Result<()> { + let projection_result = + project_device_offline_if_unowned(db, device_manager, user_id, device_id).await; + device_manager.broadcast_current_presence(user_id, |devices| { + serde_json::to_string(&OutboundProtocol::DevicePresence { + devices: build_presence(devices), + }) + .ok() + }); + projection_result +} + +/// Complete the activation after the durable device projection has been +/// written. The caller must hold `presence_projection_gate`, keeping the +/// second token check, in-memory promotion, and any rollback atomic with +/// logout, device deletion, and socket cleanup. +async fn complete_pending_device_activation_if_authorized( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + token: &str, + expected_user_id: &str, + expected_device_id: &str, + conn_id: ConnId, +) -> anyhow::Result { + // The durable writes can wait on SQLite. Re-check wall-clock expiry + // immediately before the synchronous promotion so a token that expired + // during that wait never becomes routable or receives AuthOk. + let still_current = + match registered_device_token_is_current(db, token, expected_user_id, expected_device_id) + .await + { + Ok(current) => current, + Err(error) => { + device_manager.disconnect_pending(conn_id); + let _ = project_device_offline_if_unowned( + db, + device_manager, + expected_user_id, + expected_device_id, + ) + .await; + return Err(error); + } + }; + if !still_current { + device_manager.disconnect_device_if_token(expected_user_id, expected_device_id, token); + reconcile_device_after_token_disconnect( + db, + device_manager, + expected_user_id, + expected_device_id, + ) + .await?; + return Ok(false); + } + + // Pending remains invisible until every durable write succeeds. Promotion + // is synchronous under the same lifecycle gate, so clients never route to + // a socket whose AuthConnect may still fail on database projection. + let auth_ok = serde_json::to_string(&OutboundProtocol::AuthOk { + user_id: expected_user_id.to_string(), + device_id: expected_device_id.to_string(), + })?; + if !device_manager.activate_pending_with_initial_message( + expected_user_id, + expected_device_id, + token, + conn_id, + &auth_ok, + ) { + project_device_offline_if_unowned(db, device_manager, expected_user_id, expected_device_id) + .await?; + return Ok(false); + } + Ok(true) +} + +async fn activate_pending_device_if_authorized( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + token: &str, + expected_user_id: &str, + expected_device_id: &str, + device_name: &str, + conn_id: ConnId, +) -> anyhow::Result { + // Serialize the final token lookup, durable device update, activation, and + // online projection with logout/delete/socket cleanup. No persistent side + // effect occurs before this lookup, so a deleted device cannot be revived + // by an AuthConnect request that passed only the earlier lookup. + let _presence_projection_guard = device_manager.lock_presence_projection().await; + if !registered_device_token_is_current(db, token, expected_user_id, expected_device_id).await? { + device_manager.disconnect_device_if_token(expected_user_id, expected_device_id, token); + reconcile_device_after_token_disconnect( + db, + device_manager, + expected_user_id, + expected_device_id, + ) + .await?; + return Ok(false); + } + + if let Err(error) = + crate::db::DeviceRow::upsert(db, expected_device_id, expected_user_id, device_name, None) + .await + { + device_manager.disconnect_pending(conn_id); + return Err(error); + } + if let Err(error) = + crate::db::DeviceRow::set_online(db, expected_user_id, expected_device_id, true).await + { + device_manager.disconnect_pending(conn_id); + return Err(error); + } + complete_pending_device_activation_if_authorized( + db, + device_manager, + token, + expected_user_id, + expected_device_id, + conn_id, + ) + .await +} + async fn send_json(tx: &mpsc::Sender, msg: &T) -> bool { match serde_json::to_string(msg) { - Ok(json) => send_outbound_message(tx, OutboundMessage { text: json }).await, + Ok(json) => send_outbound_message(tx, OutboundMessage::text(json)).await, Err(e) => { warn!("Failed to serialize outbound websocket message: {e}"); false @@ -409,7 +908,7 @@ async fn send_json(tx: &mpsc::Sender, msg: &T) -> fn send_json_best_effort(tx: &mpsc::Sender, msg: &T) -> bool { match serde_json::to_string(msg) { - Ok(json) => match tx.try_send(OutboundMessage { text: json }) { + Ok(json) => match tx.try_send(OutboundMessage::text(json)) { Ok(()) => true, Err(TrySendError::Full(_)) => { warn!("Outbound websocket queue is full; dropping best-effort control response"); @@ -431,15 +930,17 @@ fn generate_room_id() -> String { #[cfg(test)] mod tests { - use super::{send_json_best_effort, truncate_preview, OutboundProtocol}; - use tokio::sync::mpsc; - - #[test] - fn truncate_preview_respects_utf8_boundaries() { - let text = format!("{}{}", "a".repeat(199), "你"); - - assert_eq!(truncate_preview(&text, 200), "a".repeat(199)); - } + use super::{ + activate_pending_device_if_authorized, complete_pending_device_activation_if_authorized, + is_valid_display_text, is_valid_encrypted_payload, is_valid_identifier, + is_websocket_origin_allowed, registered_device_token_is_current, + revalidate_active_device_tokens_once, send_json_best_effort, ConnectionRateLimiter, + OutboundProtocol, MAX_MESSAGES_PER_WINDOW, + }; + use crate::db::{connect, AuthToken, DeviceRow, UserRow}; + use crate::relay::DeviceManager; + use axum::http::{header, HeaderMap}; + use tokio::sync::{mpsc, watch}; #[test] fn best_effort_control_response_does_not_block_on_full_queue() { @@ -451,4 +952,411 @@ mod tests { "full queue should drop best-effort control response without closing read loop" ); } + + #[test] + fn protocol_fields_are_bounded() { + assert!(is_valid_identifier("device-1")); + assert!(!is_valid_identifier("../device")); + assert!(!is_valid_identifier(&"x".repeat(129))); + assert!(is_valid_display_text("MacBook Pro", 256)); + assert!(!is_valid_display_text("bad\nname", 256)); + assert!(is_valid_encrypted_payload("ciphertext", "nonce")); + assert!(!is_valid_encrypted_payload("", "nonce")); + } + + #[test] + fn websocket_message_rate_is_effectively_unbounded() { + assert_eq!(MAX_MESSAGES_PER_WINDOW, i32::MAX as u32); + let mut limiter = ConnectionRateLimiter::new(); + for _ in 0..64 { + assert!(limiter.allow()); + } + } + + #[test] + fn websocket_browser_origin_is_same_origin_or_explicitly_allowed() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "relay.example.com".parse().unwrap()); + headers.insert(header::ORIGIN, "https://relay.example.com".parse().unwrap()); + assert!(is_websocket_origin_allowed(&headers, &[])); + + headers.insert(header::ORIGIN, "https://app.example.com".parse().unwrap()); + assert!(!is_websocket_origin_allowed(&headers, &[])); + assert!(is_websocket_origin_allowed( + &headers, + &["https://app.example.com".to_string()] + )); + + headers.insert(header::ORIGIN, "null".parse().unwrap()); + assert!(!is_websocket_origin_allowed(&headers, &["*".to_string()])); + + for invalid in [ + "file://relay.example.com", + "https://relay.example.com/path", + "https://user@relay.example.com", + ] { + headers.insert(header::ORIGIN, invalid.parse().unwrap()); + assert!(!is_websocket_origin_allowed(&headers, &[])); + } + } + + #[tokio::test] + async fn token_revoked_between_initial_validation_and_activation_is_rejected() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + + // This is the first AuthConnect lookup. Pause the conceptual request + // after it, then let logout delete the row before registration's + // mandatory second lookup. + assert!(AuthToken::find(&db, &token).await.unwrap().is_some()); + sqlx::query("DELETE FROM auth_tokens WHERE token = ?") + .bind(&token) + .execute(&db) + .await + .unwrap(); + + let manager = DeviceManager::new(); + let (tx, _rx) = mpsc::channel(4); + let (close_tx, mut close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + assert!(manager.online_devices("owner").is_empty()); + assert!(manager.conn_mapping(1).is_none()); + + assert!( + !registered_device_token_is_current(&db, &token, "owner", "device-a") + .await + .unwrap() + ); + assert!(!activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", 1, + ) + .await + .unwrap()); + close_rx.changed().await.unwrap(); + assert!(*close_rx.borrow()); + assert!(manager.conn_mapping(1).is_none()); + } + + #[tokio::test] + async fn expired_token_disconnects_active_and_pending_without_ghost_online_projection() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + .await + .unwrap(); + DeviceRow::set_online(&db, "owner", "device-a", true) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + + let manager = DeviceManager::new(); + let (active_tx, _active_rx) = mpsc::channel(4); + let (active_close_tx, mut active_close_rx) = watch::channel(false); + manager.register( + "owner", + "device-a", + &token, + "Device A", + 1, + active_tx, + active_close_tx, + ); + let (pending_tx, _pending_rx) = mpsc::channel(4); + let (pending_close_tx, mut pending_close_rx) = watch::channel(false); + manager.register_pending( + "owner", + "device-a", + &token, + "Device A", + 2, + pending_tx, + pending_close_tx, + ); + let (peer_tx, mut peer_rx) = mpsc::channel(4); + let (peer_close_tx, _peer_close_rx) = watch::channel(false); + manager.register( + "owner", + "device-c", + "independent-token", + "Device C", + 3, + peer_tx, + peer_close_tx, + ); + + sqlx::query("UPDATE auth_tokens SET expires_at = ? WHERE token = ?") + .bind(chrono::Utc::now().timestamp()) + .bind(&token) + .execute(&db) + .await + .unwrap(); + assert!(!activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", 2, + ) + .await + .unwrap()); + + active_close_rx.changed().await.unwrap(); + pending_close_rx.changed().await.unwrap(); + assert!(*active_close_rx.borrow()); + assert!(*pending_close_rx.borrow()); + assert!(manager.conn_mapping(1).is_none()); + assert!(manager.conn_mapping(2).is_none()); + assert_eq!( + manager.conn_mapping(3), + Some(("owner".into(), "device-c".into())) + ); + assert!(!manager.route_message("owner", "device-a", "opaque")); + let presence: serde_json::Value = + serde_json::from_str(&peer_rx.recv().await.unwrap().text).unwrap(); + assert_eq!(presence["type"], "device_presence"); + assert_eq!(presence["devices"].as_array().unwrap().len(), 1); + assert_eq!(presence["devices"][0]["device_id"], "device-c"); + let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].online, 0); + } + + #[tokio::test] + async fn external_token_revocation_reaper_disconnects_idle_device_and_updates_presence() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + .await + .unwrap(); + DeviceRow::upsert(&db, "device-c", "owner", "Device C", None) + .await + .unwrap(); + DeviceRow::set_online(&db, "owner", "device-a", true) + .await + .unwrap(); + DeviceRow::set_online(&db, "owner", "device-c", true) + .await + .unwrap(); + let revoked_token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + let peer_token = AuthToken::create(&db, "owner", "device-c") + .await + .unwrap() + .token; + + let manager = DeviceManager::new(); + let (revoked_tx, _revoked_rx) = mpsc::channel(4); + let (revoked_close_tx, mut revoked_close_rx) = watch::channel(false); + manager.register( + "owner", + "device-a", + &revoked_token, + "Device A", + 1, + revoked_tx, + revoked_close_tx, + ); + let (peer_tx, mut peer_rx) = mpsc::channel(4); + let (peer_close_tx, _peer_close_rx) = watch::channel(false); + manager.register( + "owner", + "device-c", + &peer_token, + "Device C", + 2, + peer_tx, + peer_close_tx, + ); + + // Simulate reset-password/delete-user tooling mutating the shared DB + // from outside the running relay process while both sockets are idle. + sqlx::query("DELETE FROM auth_tokens WHERE token = ?") + .bind(&revoked_token) + .execute(&db) + .await + .unwrap(); + assert_eq!( + revalidate_active_device_tokens_once(&db, &manager) + .await + .unwrap(), + 1 + ); + + revoked_close_rx.changed().await.unwrap(); + assert!(*revoked_close_rx.borrow()); + assert!(manager.conn_mapping(1).is_none()); + assert_eq!( + manager.conn_mapping(2), + Some(("owner".into(), "device-c".into())) + ); + let presence: serde_json::Value = + serde_json::from_str(&peer_rx.recv().await.unwrap().text).unwrap(); + assert_eq!(presence["devices"].as_array().unwrap().len(), 1); + assert_eq!(presence["devices"][0]["device_id"], "device-c"); + let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); + assert_eq!( + rows.iter() + .find(|row| row.device_id == "device-a") + .unwrap() + .online, + 0 + ); + assert_eq!( + rows.iter() + .find(|row| row.device_id == "device-c") + .unwrap() + .online, + 1 + ); + } + + #[tokio::test] + async fn token_expiring_during_durable_projection_never_receives_auth_ok() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + let manager = DeviceManager::new(); + let (tx, mut rx) = mpsc::channel(4); + let (close_tx, mut close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + + let _projection_guard = manager.lock_presence_projection().await; + assert!( + registered_device_token_is_current(&db, &token, "owner", "device-a") + .await + .unwrap() + ); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + .await + .unwrap(); + DeviceRow::set_online(&db, "owner", "device-a", true) + .await + .unwrap(); + sqlx::query("UPDATE auth_tokens SET expires_at = ? WHERE token = ?") + .bind(chrono::Utc::now().timestamp()) + .bind(&token) + .execute(&db) + .await + .unwrap(); + + assert!(!complete_pending_device_activation_if_authorized( + &db, &manager, &token, "owner", "device-a", 1, + ) + .await + .unwrap()); + close_rx.changed().await.unwrap(); + assert!(*close_rx.borrow()); + assert!(rx.try_recv().is_err(), "AuthOk must not be queued"); + assert!(manager.conn_mapping(1).is_none()); + let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].online, 0); + } + + #[tokio::test] + async fn stale_auth_connect_cannot_recreate_a_deleted_device() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + assert!(AuthToken::find(&db, &token).await.unwrap().is_some()); + + assert!(DeviceRow::delete_for_user(&db, "owner", "device-a") + .await + .unwrap()); + let manager = DeviceManager::new(); + let (tx, _rx) = mpsc::channel(4); + let (close_tx, mut close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + + assert!(!activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", 1, + ) + .await + .unwrap()); + close_rx.changed().await.unwrap(); + assert!(*close_rx.borrow()); + assert!(DeviceRow::list_by_user(&db, "owner") + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn pending_device_becomes_routable_only_after_durable_projection() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + let manager = DeviceManager::new(); + let (tx, mut rx) = mpsc::channel(4); + let (close_tx, _close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + + assert!(manager.online_devices("owner").is_empty()); + assert!(!manager.route_message("owner", "device-a", "opaque")); + assert!(activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", 1, + ) + .await + .unwrap()); + + assert_eq!( + manager.conn_mapping(1), + Some(("owner".into(), "device-a".into())) + ); + assert_eq!(manager.online_devices("owner").len(), 1); + let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].online, 1); + + manager.broadcast_current_presence("owner", |devices| { + serde_json::to_string(&OutboundProtocol::DevicePresence { + devices: super::build_presence(devices), + }) + .ok() + }); + let auth_ok: serde_json::Value = + serde_json::from_str(&rx.recv().await.unwrap().text).unwrap(); + let presence: serde_json::Value = + serde_json::from_str(&rx.recv().await.unwrap().text).unwrap(); + assert_eq!(auth_ok["type"], "auth_ok"); + assert_eq!(presence["type"], "device_presence"); + } } diff --git a/src/crates/services/relay-service/web/page-auth-client.test.mjs b/src/crates/services/relay-service/web/page-auth-client.test.mjs new file mode 100644 index 0000000000..999eacb9b3 --- /dev/null +++ b/src/crates/services/relay-service/web/page-auth-client.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { argon2idAsync } from '@noble/hashes/argon2.js'; + +test('browser Argon2id output matches the native account client', async () => { + const password = new TextEncoder().encode('correct horse battery staple'); + const salt = Uint8Array.from({ length: 16 }, (_, index) => index); + const output = await argon2idAsync(password, salt, { + m: 8 * 1024, + t: 1, + p: 1, + dkLen: 32, + version: 0x13, + asyncTick: 10, + }); + + assert.equal( + Buffer.from(output).toString('base64'), + 'mu73UxPlhfSSwzxeEtgumtJTt914Yy1Tfomc1O3deJw=', + ); +}); diff --git a/src/crates/services/relay-service/web/page-auth-client.ts b/src/crates/services/relay-service/web/page-auth-client.ts new file mode 100644 index 0000000000..135d558c41 --- /dev/null +++ b/src/crates/services/relay-service/web/page-auth-client.ts @@ -0,0 +1,211 @@ +import { argon2idAsync } from '@noble/hashes/argon2.js'; + +interface LoginChallenge { + kdf_salt: string; + argon2_params: string; +} + +interface KdfParams { + m: number; + t: number; + p: number; +} + +interface ErrorBody { + error?: string; + retry_after_secs?: number; +} + +const form = document.querySelector('[data-page-login-form]'); +const usernameInput = document.querySelector('[data-page-login-username]'); +const passwordInput = document.querySelector('[data-page-login-password]'); +const submitButton = document.querySelector('[data-page-login-submit]'); +const errorElement = document.querySelector('[data-page-login-error]'); +const toggleButton = document.querySelector('[data-page-login-toggle-password]'); +const loginState = form?.dataset.pageLoginState; + +function relayPathPrefix(): string { + const authRouteIndex = window.location.pathname.indexOf('/api/page-auth/'); + if (authRouteIndex >= 0) { + return window.location.pathname.slice(0, authRouteIndex); + } + const pageRouteIndex = window.location.pathname.indexOf('/p/'); + if (pageRouteIndex >= 0) { + return window.location.pathname.slice(0, pageRouteIndex); + } + return ''; +} + +function relayApiPath(path: string): string { + return `${relayPathPrefix()}${path}`; +} + +function currentPageReturnPath(): string { + const pageRouteIndex = window.location.pathname.indexOf('/p/'); + const pagePath = pageRouteIndex >= 0 + ? window.location.pathname.slice(pageRouteIndex) + : window.location.pathname; + return `${pagePath}${window.location.search}`; +} + +function externalRedirectTarget(target: string): string { + if (/^https?:\/\//i.test(target)) return target; + return `${relayPathPrefix()}${target}`; +} + +function isChineseLocale(): boolean { + return navigator.language.toLowerCase().startsWith('zh'); +} + +function message(zh: string, en: string): string { + return isChineseLocale() ? zh : en; +} + +function showError(value: string): void { + if (!errorElement) return; + errorElement.textContent = value; + errorElement.hidden = value.length === 0; +} + +function decodeBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function encodeBase64(value: Uint8Array): string { + let binary = ''; + for (let offset = 0; offset < value.length; offset += 0x8000) { + binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function parseKdfParams(raw: string): KdfParams { + const value = JSON.parse(raw) as Partial; + if (!Number.isInteger(value.m) + || !Number.isInteger(value.t) + || !Number.isInteger(value.p) + || value.m! < 8 * 1024 + || value.m! > 256 * 1024 + || value.t! < 1 + || value.t! > 10 + || value.p! < 1 + || value.p! > 16) { + throw new Error(message('登录参数无效。', 'The sign-in parameters are invalid.')); + } + return value as KdfParams; +} + +async function readError(response: Response): Promise { + const fallback = message('登录失败,请重试。', 'Sign-in failed. Try again.'); + try { + const body = await response.json() as ErrorBody; + if (body.retry_after_secs && body.retry_after_secs > 0) { + return message( + `尝试次数过多,请在 ${body.retry_after_secs} 秒后重试。`, + `Too many attempts. Try again in ${body.retry_after_secs} seconds.`, + ); + } + if (body.error === 'invalid username or password') { + return message('用户名或密码不正确。', 'Incorrect username or password.'); + } + if (body.error === 'account does not have access to this Page') { + return message('该账号没有此页面的访问权限。', 'This account cannot access the Page.'); + } + return body.error || fallback; + } catch { + return fallback; + } +} + +async function postJson(path: string, body: Record): Promise { + const response = await fetch(path, { + method: 'POST', + credentials: 'same-origin', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(await readError(response)); + } + return response.json() as Promise; +} + +toggleButton?.addEventListener('click', () => { + if (!passwordInput) return; + const reveal = passwordInput.type === 'password'; + passwordInput.type = reveal ? 'text' : 'password'; + toggleButton.textContent = reveal + ? message('隐藏', 'Hide') + : message('显示', 'Show'); + toggleButton.setAttribute('aria-pressed', String(reveal)); +}); + +form?.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!usernameInput || !passwordInput || !submitButton) return; + + const username = usernameInput.value.trim(); + const password = passwordInput.value; + if (!username || username.length > 128 || !password || password.length > 1024) { + showError(message('请输入有效的用户名和密码。', 'Enter a valid username and password.')); + return; + } + + showError(''); + submitButton.disabled = true; + submitButton.textContent = message('正在验证…', 'Signing in…'); + + let passwordBytes: Uint8Array | undefined; + let passwordHash: Uint8Array | undefined; + try { + const challenge = await postJson( + relayApiPath('/api/auth/login/challenge'), + { username }, + ); + const salt = decodeBase64(challenge.kdf_salt); + if (salt.length !== 16) { + throw new Error(message('登录参数无效。', 'The sign-in parameters are invalid.')); + } + const params = parseKdfParams(challenge.argon2_params); + passwordBytes = new TextEncoder().encode(password); + passwordInput.value = ''; + passwordHash = await argon2idAsync(passwordBytes, salt, { + m: params.m, + t: params.t, + p: params.p, + dkLen: 32, + version: 0x13, + asyncTick: 16, + }); + const loginBody: Record = { + username, + password_hash: encodeBase64(passwordHash), + }; + if (loginState) { + loginBody.state = loginState; + } else { + loginBody.return_to = currentPageReturnPath(); + loginBody.path_prefix = relayPathPrefix(); + } + const result = await postJson<{ redirect_to: string }>( + relayApiPath('/api/page-auth/login'), + loginBody, + ); + window.location.replace(externalRedirectTarget(result.redirect_to)); + } catch (error) { + showError(error instanceof Error + ? error.message + : message('登录失败,请重试。', 'Sign-in failed. Try again.')); + passwordInput.focus(); + } finally { + passwordBytes?.fill(0); + passwordHash?.fill(0); + submitButton.disabled = false; + submitButton.textContent = message('登录并访问', 'Sign in and continue'); + } +}); diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 1f197f4020..713f10ed04 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -7,7 +7,7 @@ without the full product runtime. This includes generic filesystem/search/JSON IO helpers, LSP package/protocol/watch/process primitives, session metadata storage helpers, and local OS action primitives such as command lookup, clipboard, file/url opening, script execution, workspace runtime FS/shell -providers, and system facts. Product crates may layer routing, policy, +providers, managed process-tree lifecycle, and system facts. Product crates may layer routing, policy, capability selection, event emission, or legacy error mapping outside this crate. @@ -31,6 +31,14 @@ crate. globals, or product runtime bindings to `filesystem`; keep those in core or a reviewed adapter/provider. - Preserve legacy core imports with facade/re-export code when ownership moves. +- `process_tree` is the single reusable owner for supervised child-process + lifecycle. Unix implementations use a dedicated process group; Windows must + attach a suspended child to a kill-on-close Job Object before resuming and + fail closed if attachment fails. Consumers own protocol shutdown; this owner + owns cleanup for managed descendants and does not claim sandbox or + resource-limit safety. Unix descendants that deliberately create a new + session/process group are outside this boundary and must be treated as a + disclosed residual risk until a platform supervisor is introduced. ## Verification diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 7214f70b1e..0369d14475 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["rlib"] anyhow = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } bitfun-core-types = { path = "../../contracts/core-types" } +bitfun-events = { path = "../../contracts/events" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", optional = true } tokio = { workspace = true } serde = { workspace = true } @@ -20,6 +21,7 @@ serde_json = { workspace = true } serde_yaml = { workspace = true, optional = true } base64 = { workspace = true } chrono = { workspace = true } +dunce = { workspace = true, optional = true } zip = { workspace = true, optional = true } thiserror = { workspace = true } log = { workspace = true } @@ -30,16 +32,26 @@ sha2 = { workspace = true } which = { workspace = true } similar = { workspace = true } regex = { workspace = true } +rusqlite = { version = "0.32", features = ["bundled"], optional = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } -windows = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } [features] default = ["lsp"] lsp = ["dep:anyhow", "dep:notify", "dep:zip"] markdown = ["dep:serde_yaml"] -workspace-runtime = ["dep:anyhow", "dep:async-trait", "dep:bitfun-runtime-ports"] +workspace-runtime = ["dep:anyhow", "dep:async-trait", "dep:bitfun-runtime-ports", "dep:dunce"] +permission = ["dep:async-trait", "dep:bitfun-runtime-ports", "dep:rusqlite", "bitfun-runtime-ports/permission"] [dev-dependencies] filetime = { workspace = true } diff --git a/src/crates/services/services-core/src/filesystem/tree.rs b/src/crates/services/services-core/src/filesystem/tree.rs index 00923daa67..f3982a8e98 100644 --- a/src/crates/services/services-core/src/filesystem/tree.rs +++ b/src/crates/services/services-core/src/filesystem/tree.rs @@ -108,22 +108,15 @@ impl Default for FileTreeOptions { fn default() -> Self { Self { max_depth: Some(50), - include_hidden: false, + include_hidden: true, include_git_info: false, include_mime_types: false, skip_patterns: vec![ - "node_modules".to_string(), - "target".to_string(), ".git".to_string(), - "dist".to_string(), - "build".to_string(), - ".next".to_string(), - ".nuxt".to_string(), - ".cache".to_string(), - "coverage".to_string(), - "__pycache__".to_string(), - ".vscode".to_string(), - ".idea".to_string(), + ".svn".to_string(), + ".hg".to_string(), + ".DS_Store".to_string(), + "Thumbs.db".to_string(), ], max_file_size_mb: Some(100), follow_symlinks: false, @@ -776,8 +769,7 @@ impl FileTreeService { } fn should_skip_file(&self, file_name: &str) -> bool { - // Skip hidden files and directories (unless explicitly included) - // But .gitignore and .bitfun are always shown + // Skip hidden files and directories unless explicitly included. if !self.options.include_hidden && file_name.starts_with('.') && file_name != ".gitignore" @@ -1596,6 +1588,30 @@ pub enum SearchMatchType { mod tests { use super::*; + #[test] + fn default_tree_visibility_matches_vscode_baseline() { + let service = FileTreeService::default(); + + for name in [".git", ".svn", ".hg", ".DS_Store", "Thumbs.db"] { + assert!(service.should_skip_file(name), "{name} should be excluded"); + } + + for name in [ + ".env", + ".github", + ".vscode", + "node_modules", + "target", + "dist", + "build", + ] { + assert!( + !service.should_skip_file(name), + "{name} should be visible by default" + ); + } + } + #[tokio::test] async fn content_search_preserves_matching_lines_and_preview_ranges() { let root = tempfile::tempdir().expect("create temp search directory"); diff --git a/src/crates/services/services-core/src/json_store.rs b/src/crates/services/services-core/src/json_store.rs index c8544d0753..45e1611c9f 100644 --- a/src/crates/services/services-core/src/json_store.rs +++ b/src/crates/services/services-core/src/json_store.rs @@ -207,6 +207,31 @@ impl JsonFileStore { path: &Path, value: &T, strict: bool, + ) -> Result<(), JsonFileStoreError> { + let json = serde_json::to_string_pretty(value) + .map_err(|source| JsonFileStoreError::Serialize { source })?; + self.write_bytes_atomic_with_policy(path, json.into_bytes(), strict) + .await + } + + /// Atomically replace a plain UTF-8 text file using the same locking and + /// retry policy as JSON persistence. Session transcript artifacts use this + /// instead of open-coded `fs::write` so readers never observe a partial + /// generated reference. + pub async fn write_text_atomic( + &self, + path: &Path, + text: &str, + ) -> Result<(), JsonFileStoreError> { + self.write_bytes_atomic_with_policy(path, text.as_bytes().to_vec(), false) + .await + } + + async fn write_bytes_atomic_with_policy( + &self, + path: &Path, + bytes: Vec, + strict: bool, ) -> Result<(), JsonFileStoreError> { let parent = path .parent() @@ -218,17 +243,14 @@ impl JsonFileStore { .await .map_err(|source| JsonFileStoreError::CreateParent { source })?; - let json = serde_json::to_string_pretty(value) - .map_err(|source| JsonFileStoreError::Serialize { source })?; let lock = Self::get_file_write_lock(path).await; let _lock_guard = lock.lock().await; - let json_bytes = json.into_bytes(); let mut last_replace_error: Option = None; for attempt in 0..=JSON_WRITE_MAX_RETRIES { let tmp_path = Self::build_temp_json_path(path, attempt)?; - if let Err(source) = fs::write(&tmp_path, &json_bytes).await { + if let Err(source) = fs::write(&tmp_path, &bytes).await { return Err(JsonFileStoreError::WriteTemp { source }); } @@ -265,7 +287,7 @@ impl JsonFileStore { "Atomic JSON replace permission denied for {}, fallback to direct overwrite", path.display() ); - fs::write(path, &json_bytes).await.map_err(|source| { + fs::write(path, &bytes).await.map_err(|source| { JsonFileStoreError::FallbackOverwrite { path: path.to_path_buf(), source, diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 6f08ec5c33..757cd92dd8 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -7,13 +7,18 @@ pub mod diagnostics; pub mod diff; pub mod filesystem; pub mod json_store; +#[cfg(feature = "workspace-runtime")] +pub mod local_runtime_ports; #[cfg(feature = "lsp")] pub mod lsp; pub mod managed_runtime; #[cfg(feature = "markdown")] pub mod markdown; +#[cfg(feature = "permission")] +pub mod permission_store; pub mod persistence; pub mod process_manager; +pub mod process_tree; pub mod session; pub mod session_usage; pub mod storage_cleanup; diff --git a/src/crates/services/services-core/src/local_runtime_ports.rs b/src/crates/services/services-core/src/local_runtime_ports.rs new file mode 100644 index 0000000000..db1ac55101 --- /dev/null +++ b/src/crates/services/services-core/src/local_runtime_ports.rs @@ -0,0 +1,147 @@ +//! Reusable local implementations of the required runtime service ports. +//! +//! Product composition roots select these ports. This module only owns local +//! workspace identity, the system clock, and the in-process event sink. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use bitfun_runtime_ports::{ + ClockPort, FileSystemPort, PortResult, RuntimeEventEnvelope, RuntimeEventSink, + RuntimeServiceCapability, RuntimeServicePort, WorkspacePort, +}; +use tokio::sync::broadcast; + +#[derive(Debug)] +struct LocalFileSystemPort { + _workspace_root: PathBuf, +} + +impl RuntimeServicePort for LocalFileSystemPort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::FileSystem + } +} + +impl FileSystemPort for LocalFileSystemPort {} + +#[derive(Debug)] +struct LocalWorkspacePort { + _workspace_root: PathBuf, +} + +impl RuntimeServicePort for LocalWorkspacePort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Workspace + } +} + +impl WorkspacePort for LocalWorkspacePort {} + +#[derive(Debug, Clone, Copy, Default)] +struct SystemClock; + +impl RuntimeServicePort for SystemClock { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Clock + } +} + +impl ClockPort for SystemClock { + fn now_unix_millis(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or_default() + } +} + +#[derive(Debug, Clone)] +struct LocalRuntimeEventSink { + tx: broadcast::Sender, +} + +impl LocalRuntimeEventSink { + fn new(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity.max(1)); + Self { tx } + } +} + +#[async_trait::async_trait] +impl RuntimeEventSink for LocalRuntimeEventSink { + async fn publish_runtime_event(&self, event: RuntimeEventEnvelope) -> PortResult<()> { + let _ = self.tx.send(event); + Ok(()) + } +} + +/// Local runtime ports bound to one canonical workspace. +#[derive(Clone)] +pub struct LocalRuntimePorts { + workspace_root: PathBuf, + filesystem: Arc, + workspace: Arc, + events: Arc, + clock: Arc, +} + +impl std::fmt::Debug for LocalRuntimePorts { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LocalRuntimePorts") + .field("workspace_root", &self.workspace_root) + .finish_non_exhaustive() + } +} + +impl LocalRuntimePorts { + pub fn new(workspace_root: impl AsRef, event_capacity: usize) -> anyhow::Result { + let requested_root = workspace_root.as_ref(); + let canonical_root = dunce::canonicalize(requested_root).map_err(|error| { + anyhow::anyhow!( + "workspace root is not available ({}): {error}", + requested_root.display() + ) + })?; + if !canonical_root.is_dir() { + anyhow::bail!( + "workspace root is not a directory: {}", + canonical_root.display() + ); + } + + Ok(Self { + workspace_root: canonical_root.clone(), + filesystem: Arc::new(LocalFileSystemPort { + _workspace_root: canonical_root.clone(), + }), + workspace: Arc::new(LocalWorkspacePort { + _workspace_root: canonical_root, + }), + events: Arc::new(LocalRuntimeEventSink::new(event_capacity)), + clock: Arc::new(SystemClock), + }) + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn filesystem(&self) -> Arc { + self.filesystem.clone() + } + + pub fn workspace(&self) -> Arc { + self.workspace.clone() + } + + pub fn events(&self) -> Arc { + self.events.clone() + } + + pub fn clock(&self) -> Arc { + self.clock.clone() + } +} diff --git a/src/crates/services/services-core/src/permission_store.rs b/src/crates/services/services-core/src/permission_store.rs new file mode 100644 index 0000000000..d38f7e3edd --- /dev/null +++ b/src/crates/services/services-core/src/permission_store.rs @@ -0,0 +1,505 @@ +//! SQLite-backed project permission grants and bounded audit facts. + +use bitfun_runtime_ports::{ + PermissionAuditEvent, PermissionAuditRecord, PermissionAuditStorePort, PermissionGrant, + PermissionGrantKey, PermissionGrantStorePort, PermissionReplyStorePort, PermissionRequest, + PortError, PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, +}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const PERMISSION_STORE_FILE_NAME: &str = "tool-permissions.sqlite"; +const PERMISSION_STORE_SCHEMA_VERSION: i64 = 1; +const DEFAULT_AUDIT_LIMIT_PER_PROJECT: usize = 1_000; + +#[derive(Debug, Clone)] +struct PreparedAuditRecord { + record: PermissionAuditRecord, + request_json: String, + event_json: String, +} + +/// User-level permission persistence. Grants and audit records are scoped by +/// project ID inside one database under the user data directory. +#[derive(Debug, Clone)] +pub struct ProjectPermissionSqliteStore { + path: PathBuf, + audit_limit_per_project: usize, +} + +impl ProjectPermissionSqliteStore { + pub fn new(base_dir: impl Into) -> Self { + Self { + path: base_dir.into().join(PERMISSION_STORE_FILE_NAME), + audit_limit_per_project: DEFAULT_AUDIT_LIMIT_PER_PROJECT, + } + } + + pub fn with_audit_limit(mut self, audit_limit_per_project: usize) -> Self { + self.audit_limit_per_project = audit_limit_per_project.max(1); + self + } + + pub fn path(&self) -> &Path { + &self.path + } + + async fn execute( + &self, + operation: impl FnOnce(&mut Connection, usize) -> PortResult + Send + 'static, + ) -> PortResult { + let path = self.path.clone(); + let audit_limit_per_project = self.audit_limit_per_project; + tokio::task::spawn_blocking(move || { + let mut connection = open_connection(&path)?; + operation(&mut connection, audit_limit_per_project) + }) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("Permission store worker failed: {error}"), + ) + })? + } +} + +impl RuntimeServicePort for ProjectPermissionSqliteStore { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Permission + } +} + +#[async_trait::async_trait] +impl PermissionGrantStorePort for ProjectPermissionSqliteStore { + async fn list_project_grants(&self, project_id: &str) -> PortResult> { + let project_id = project_id.to_string(); + self.execute(move |connection, _| list_grants(connection, &project_id)) + .await + } + + async fn add_project_grants(&self, grants: Vec) -> PortResult<()> { + if grants.is_empty() { + return Ok(()); + } + validate_grants(&grants)?; + + self.execute(move |connection, _| { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + insert_grants(&transaction, &grants)?; + transaction.commit().map_err(store_error) + }) + .await + } + + async fn remove_project_grant(&self, key: PermissionGrantKey) -> PortResult { + self.execute(move |connection, _| { + let removed = connection + .execute( + "DELETE FROM grants WHERE project_id = ?1 AND action = ?2 AND resource = ?3", + params![key.project_id, key.action, key.resource], + ) + .map_err(store_error)?; + Ok(removed > 0) + }) + .await + } + + async fn clear_project_grants(&self, project_id: &str) -> PortResult { + if project_id.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Permission grant project ID must be non-empty", + )); + } + let project_id = project_id.to_string(); + + self.execute(move |connection, _| { + connection + .execute( + "DELETE FROM grants WHERE project_id = ?1", + params![project_id], + ) + .map_err(store_error) + }) + .await + } +} + +#[async_trait::async_trait] +impl PermissionReplyStorePort for ProjectPermissionSqliteStore { + async fn commit_permission_reply( + &self, + grants: Vec, + audit: Vec, + ) -> PortResult<()> { + validate_grants(&grants)?; + let audit = prepare_audit_records(audit)?; + + self.execute(move |connection, audit_limit_per_project| { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + ensure_audit_records_compatible(&transaction, &audit)?; + insert_grants(&transaction, &grants)?; + insert_audit_records(&transaction, &audit)?; + prune_audit_records(&transaction, &audit, audit_limit_per_project)?; + transaction.commit().map_err(store_error) + }) + .await + } +} + +#[async_trait::async_trait] +impl PermissionAuditStorePort for ProjectPermissionSqliteStore { + async fn append_permission_audit(&self, record: PermissionAuditRecord) -> PortResult<()> { + let record = prepare_audit_record(record)?; + + self.execute(move |connection, audit_limit_per_project| { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + ensure_audit_record_compatible(&transaction, &record)?; + insert_audit_records(&transaction, std::slice::from_ref(&record))?; + prune_audit_records( + &transaction, + std::slice::from_ref(&record), + audit_limit_per_project, + )?; + transaction.commit().map_err(store_error) + }) + .await + } + + async fn list_project_permission_audit( + &self, + project_id: &str, + ) -> PortResult> { + let project_id = project_id.to_string(); + self.execute(move |connection, _| list_audit_records(connection, &project_id)) + .await + } +} + +fn open_connection(path: &Path) -> PortResult { + let parent = path.parent().ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + format!( + "Permission store database path has no parent: {}", + path.display() + ), + ) + })?; + fs::create_dir_all(parent).map_err(store_error)?; + + let mut connection = Connection::open(path).map_err(store_error)?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(store_error)?; + connection + .execute_batch( + r#" + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + "#, + ) + .map_err(store_error)?; + initialize_schema(&mut connection)?; + Ok(connection) +} + +fn initialize_schema(connection: &mut Connection) -> PortResult<()> { + let schema_version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(store_error)?; + if schema_version == PERMISSION_STORE_SCHEMA_VERSION { + return Ok(()); + } + if schema_version != 0 { + return Err(PortError::new( + PortErrorKind::Backend, + format!( + "Unsupported permission store schema version: {schema_version}; expected at most {PERMISSION_STORE_SCHEMA_VERSION}" + ), + )); + } + + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(store_error)?; + transaction + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS grants ( + project_id TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (project_id, action, resource) + ); + + CREATE TABLE IF NOT EXISTS audit ( + audit_id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + request_json TEXT NOT NULL, + event_json TEXT NOT NULL, + timestamp_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_audit_project_timestamp + ON audit (project_id, timestamp_ms, audit_id); + "#, + ) + .map_err(store_error)?; + transaction + .pragma_update(None, "user_version", PERMISSION_STORE_SCHEMA_VERSION) + .map_err(store_error)?; + transaction.commit().map_err(store_error) +} + +fn list_grants(connection: &Connection, project_id: &str) -> PortResult> { + let mut statement = connection + .prepare( + "SELECT project_id, action, resource, created_at_ms + FROM grants + WHERE project_id = ?1 + ORDER BY action ASC, resource ASC", + ) + .map_err(store_error)?; + let rows = statement + .query_map(params![project_id], |row| { + Ok(PermissionGrant { + project_id: row.get(0)?, + action: row.get(1)?, + resource: row.get(2)?, + created_at_ms: row.get(3)?, + }) + }) + .map_err(store_error)?; + collect_rows(rows) +} + +fn list_audit_records( + connection: &Connection, + project_id: &str, +) -> PortResult> { + let mut statement = connection + .prepare( + "SELECT audit_id, request_json, event_json, timestamp_ms + FROM audit + WHERE project_id = ?1 + ORDER BY timestamp_ms ASC, audit_id ASC", + ) + .map_err(store_error)?; + let rows = statement + .query_map(params![project_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .map_err(store_error)?; + + let mut records = Vec::new(); + for row in rows { + let (audit_id, request_json, event_json, timestamp_ms) = row.map_err(store_error)?; + records.push(PermissionAuditRecord { + audit_id, + request: deserialize_audit_value(&request_json, "request")?, + event: deserialize_audit_value(&event_json, "event")?, + timestamp_ms, + }); + } + Ok(records) +} + +fn collect_rows( + rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result>, +) -> PortResult> { + rows.collect::, _>>().map_err(store_error) +} + +fn insert_grants(transaction: &Transaction<'_>, grants: &[PermissionGrant]) -> PortResult<()> { + let mut statement = transaction + .prepare( + "INSERT OR IGNORE INTO grants (project_id, action, resource, created_at_ms) + VALUES (?1, ?2, ?3, ?4)", + ) + .map_err(store_error)?; + for grant in grants { + statement + .execute(params![ + grant.project_id, + grant.action, + grant.resource, + grant.created_at_ms, + ]) + .map_err(store_error)?; + } + Ok(()) +} + +fn ensure_audit_records_compatible( + transaction: &Transaction<'_>, + records: &[PreparedAuditRecord], +) -> PortResult<()> { + for record in records { + ensure_audit_record_compatible(transaction, record)?; + } + Ok(()) +} + +fn ensure_audit_record_compatible( + transaction: &Transaction<'_>, + record: &PreparedAuditRecord, +) -> PortResult<()> { + let existing = transaction + .query_row( + "SELECT request_json, event_json FROM audit WHERE audit_id = ?1", + params![record.record.audit_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(store_error)?; + + if let Some((request_json, event_json)) = existing { + let existing_request: PermissionRequest = + deserialize_audit_value(&request_json, "request")?; + let existing_event: PermissionAuditEvent = deserialize_audit_value(&event_json, "event")?; + if existing_request != record.record.request || existing_event != record.record.event { + return Err(audit_conflict(&record.record.audit_id)); + } + } + Ok(()) +} + +fn insert_audit_records( + transaction: &Transaction<'_>, + records: &[PreparedAuditRecord], +) -> PortResult<()> { + let mut statement = transaction + .prepare( + "INSERT OR IGNORE INTO audit + (audit_id, project_id, request_json, event_json, timestamp_ms) + VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .map_err(store_error)?; + for record in records { + statement + .execute(params![ + record.record.audit_id, + record.record.request.project_id, + record.request_json, + record.event_json, + record.record.timestamp_ms, + ]) + .map_err(store_error)?; + } + Ok(()) +} + +fn prune_audit_records( + transaction: &Transaction<'_>, + records: &[PreparedAuditRecord], + audit_limit_per_project: usize, +) -> PortResult<()> { + let project_ids = records + .iter() + .map(|record| record.record.request.project_id.as_str()) + .collect::>(); + let offset = i64::try_from(audit_limit_per_project).map_err(|_| { + PortError::new( + PortErrorKind::InvalidRequest, + "Permission audit retention limit exceeds SQLite integer range", + ) + })?; + + for project_id in project_ids { + transaction + .execute( + "DELETE FROM audit + WHERE project_id = ?1 + AND audit_id IN ( + SELECT audit_id + FROM audit + WHERE project_id = ?2 + ORDER BY timestamp_ms DESC, audit_id DESC + LIMIT -1 OFFSET ?3 + )", + params![project_id, project_id, offset], + ) + .map_err(store_error)?; + } + Ok(()) +} + +fn prepare_audit_records( + records: Vec, +) -> PortResult> { + records.into_iter().map(prepare_audit_record).collect() +} + +fn prepare_audit_record(record: PermissionAuditRecord) -> PortResult { + if record.audit_id.trim().is_empty() || record.request.project_id.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Permission audit ID and project ID must be non-empty", + )); + } + + let request_json = serde_json::to_string(&record.request).map_err(store_error)?; + let event_json = serde_json::to_string(&record.event).map_err(store_error)?; + Ok(PreparedAuditRecord { + record, + request_json, + event_json, + }) +} + +fn deserialize_audit_value( + value: &str, + field: &str, +) -> PortResult { + serde_json::from_str(value).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("Permission audit {field} data is invalid: {error}"), + ) + }) +} + +fn validate_grants(grants: &[PermissionGrant]) -> PortResult<()> { + if grants.iter().any(|grant| { + grant.project_id.trim().is_empty() + || grant.action.trim().is_empty() + || grant.resource.trim().is_empty() + }) { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Permission grant project, action, and resource must be non-empty", + )); + } + Ok(()) +} + +fn audit_conflict(audit_id: &str) -> PortError { + PortError::new( + PortErrorKind::InvalidRequest, + format!("Permission audit ID already exists with different content: {audit_id}"), + ) +} + +fn store_error(error: impl std::fmt::Display) -> PortError { + PortError::new( + PortErrorKind::Backend, + format!("Permission store operation failed: {error}"), + ) +} diff --git a/src/crates/services/services-core/src/process_manager.rs b/src/crates/services/services-core/src/process_manager.rs index 8faab185e3..5f8d7d6ce4 100644 --- a/src/crates/services/services-core/src/process_manager.rs +++ b/src/crates/services/services-core/src/process_manager.rs @@ -1,14 +1,10 @@ //! Unified process management to avoid Windows child process leaks -use std::io; use std::process::Command; use std::sync::LazyLock; #[cfg(target_os = "macos")] use std::sync::OnceLock; -use tokio::process::{Child, Command as TokioCommand}; -#[cfg(unix)] -use tokio::time::timeout; -use tokio::time::Duration; +use tokio::process::Command as TokioCommand; #[cfg(windows)] use log::warn; @@ -166,92 +162,6 @@ fn build_macos_path_env() -> Option { std::env::join_paths(merged).ok() } -#[cfg(unix)] -pub fn configure_process_group(command: &mut TokioCommand) { - command.process_group(0); -} - -#[cfg(not(unix))] -pub fn configure_process_group(_command: &mut TokioCommand) {} - -#[cfg(unix)] -pub async fn terminate_child_process_tree( - child: &mut Child, - graceful_timeout: Duration, -) -> io::Result<()> { - let pid = child.id(); - - if let Some(pid) = pid { - let process_group = format!("-{}", pid); - let _ = create_tokio_command("kill") - .arg("-TERM") - .arg(&process_group) - .status() - .await; - - match timeout(graceful_timeout, child.wait()).await { - Ok(wait_result) => return wait_result.map(|_| ()), - Err(_) => { - let _ = create_tokio_command("kill") - .arg("-KILL") - .arg(&process_group) - .status() - .await; - return child.wait().await.map(|_| ()); - } - } - } - - child.start_kill()?; - child.wait().await.map(|_| ()) -} - -#[cfg(windows)] -pub async fn terminate_child_process_tree( - child: &mut Child, - graceful_timeout: Duration, -) -> io::Result<()> { - let pid = child.id(); - - let _ = graceful_timeout; - - if let Some(pid) = pid { - let _ = create_tokio_command("taskkill") - .arg("/PID") - .arg(pid.to_string()) - .arg("/T") - .arg("/F") - .status() - .await; - return child.wait().await.map(|_| ()); - } - - child.start_kill()?; - child.wait().await.map(|_| ()) -} - -pub fn spawn_child_process_tree_cleanup(child: Child, graceful_timeout: Duration) { - let _ = std::thread::Builder::new() - .name("process-tree-cleanup".to_string()) - .spawn(move || { - match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => { - runtime.block_on(async move { - let mut child = child; - let _ = terminate_child_process_tree(&mut child, graceful_timeout).await; - }); - } - Err(_) => { - let mut child = child; - let _ = child.start_kill(); - } - } - }); -} - pub fn cleanup_all_processes() { GLOBAL_PROCESS_MANAGER.cleanup_all(); } diff --git a/src/crates/services/services-core/src/process_tree.rs b/src/crates/services/services-core/src/process_tree.rs new file mode 100644 index 0000000000..9cc76cd09d --- /dev/null +++ b/src/crates/services/services-core/src/process_tree.rs @@ -0,0 +1,518 @@ +//! Process-tree supervision for executable integration runtimes. +//! +//! This provides failure containment and deterministic cleanup for managed +//! descendants. It is not an OS sandbox and does not establish a trust +//! boundary for child-process IO. On Unix, the boundary is one process group; +//! a program that deliberately creates a new session/process group escapes it. + +use std::fmt; +use std::io; +use std::time::Duration; +#[cfg(unix)] +use std::time::Instant; +use tokio::process::ChildStderr; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CleanupOutcome { + AlreadyExited, + #[cfg(unix)] + Graceful, + Forced, +} + +pub struct ProcessTreeChild { + child: Child, + platform: PlatformProcessTree, +} + +impl fmt::Debug for ProcessTreeChild { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessTreeChild") + .field("process_id", &self.child.id()) + .finish_non_exhaustive() + } +} + +impl ProcessTreeChild { + pub async fn spawn(command: &mut Command) -> io::Result { + command.kill_on_drop(true); + + #[cfg(unix)] + { + command.process_group(0); + let child = command.spawn()?; + let process_group_id = child + .id() + .ok_or_else(|| io::Error::other("spawned process has no process id"))? + as i32; + Ok(Self { + child, + platform: PlatformProcessTree { process_group_id }, + }) + } + + #[cfg(windows)] + { + spawn_windows_process_tree(command).await + } + } + + pub fn take_stdin(&mut self) -> Option { + self.child.stdin.take() + } + + pub fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + pub fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } + + pub fn try_wait(&mut self) -> io::Result> { + self.child.try_wait() + } + + pub async fn wait(&mut self) -> io::Result { + self.child.wait().await + } + + pub async fn terminate(&mut self, grace: Duration) -> io::Result { + let parent_exited = self.child.try_wait()?.is_some(); + + #[cfg(unix)] + { + if parent_exited && !process_group_is_alive(self.platform.process_group_id) { + return Ok(CleanupOutcome::AlreadyExited); + } + terminate_unix_process_tree(&mut self.child, self.platform.process_group_id, grace) + .await + } + + #[cfg(windows)] + { + let _ = grace; + let had_job = self.platform.job.take().is_some(); + if !parent_exited { + self.child.wait().await?; + } + Ok(if had_job { + CleanupOutcome::Forced + } else { + CleanupOutcome::AlreadyExited + }) + } + } + + /// Schedules best-effort graceful cleanup without requiring an async caller. + /// If the cleanup thread or runtime cannot be created, `Drop` still performs + /// immediate process-tree cleanup. + pub fn spawn_cleanup(mut self, grace: Duration) { + let _ = std::thread::Builder::new() + .name("process-tree-cleanup".to_string()) + .spawn(move || { + if let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + let _ = runtime.block_on(self.terminate(grace)); + } + }); + } +} + +impl Drop for ProcessTreeChild { + fn drop(&mut self) { + let parent_exited = self.child.try_wait().is_ok_and(|status| status.is_some()); + + #[cfg(unix)] + { + // SAFETY: negative PID targets the process group created by + // `process_group(0)`; no borrowed memory crosses the FFI boundary. + let _ = unsafe { libc::kill(-self.platform.process_group_id, libc::SIGKILL) }; + } + #[cfg(windows)] + { + self.platform.job.take(); + } + if !parent_exited { + let _ = self.child.start_kill(); + } + } +} + +#[cfg(unix)] +struct PlatformProcessTree { + process_group_id: i32, +} + +#[cfg(unix)] +async fn terminate_unix_process_tree( + child: &mut Child, + process_group_id: i32, + grace: Duration, +) -> io::Result { + signal_process_group(process_group_id, libc::SIGTERM)?; + let deadline = Instant::now() + grace; + loop { + let parent_exited = child.try_wait()?.is_some(); + if !process_group_is_alive(process_group_id) { + if !parent_exited { + child.wait().await?; + } + return Ok(CleanupOutcome::Graceful); + } + if Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + signal_process_group(process_group_id, libc::SIGKILL)?; + child.wait().await?; + Ok(CleanupOutcome::Forced) +} + +#[cfg(unix)] +fn signal_process_group(process_group_id: i32, signal: i32) -> io::Result<()> { + // SAFETY: the integer process-group id was captured from the spawned child + // and the signal value is a libc constant. + let result = unsafe { libc::kill(-process_group_id, signal) }; + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(error) + } +} + +#[cfg(unix)] +fn process_group_is_alive(process_group_id: i32) -> bool { + // SAFETY: signal 0 performs an existence/permission check only. + let result = unsafe { libc::kill(-process_group_id, 0) }; + result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] +struct PlatformProcessTree { + job: Option, +} + +#[cfg(windows)] +async fn spawn_windows_process_tree(command: &mut Command) -> io::Result { + use windows::Win32::System::Threading::CREATE_SUSPENDED; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let job = win32job::Job::create().map_err(job_error)?; + let mut limits = win32job::ExtendedLimitInfo::new(); + limits.limit_kill_on_job_close(); + job.set_extended_limit_info(&limits).map_err(job_error)?; + + command.creation_flags(CREATE_SUSPENDED.0 | CREATE_NO_WINDOW); + let mut child = command.spawn()?; + let attach_result = (|| { + let process_id = child + .id() + .ok_or_else(|| io::Error::other("spawned process has no process id"))?; + let process_handle = child + .raw_handle() + .ok_or_else(|| io::Error::other("spawned process has no process handle"))?; + job.assign_process(process_handle as isize) + .map_err(job_error)?; + resume_primary_thread(process_id) + })(); + + if let Err(error) = attach_result { + drop(job); + let _ = child.kill().await; + return Err(error); + } + + Ok(ProcessTreeChild { + child, + platform: PlatformProcessTree { job: Some(job) }, + }) +} + +#[cfg(windows)] +fn resume_primary_thread(process_id: u32) -> io::Result<()> { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + // SAFETY: all handles are checked, closed on every branch, and the + // THREADENTRY32 size is initialized as required by ToolHelp. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) + .map_err(|error| io::Error::other(error.to_string()))?; + let result = (|| { + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + Thread32First(snapshot, &mut entry) + .map_err(|error| io::Error::other(error.to_string()))?; + loop { + if entry.th32OwnerProcessID == process_id { + let thread = OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) + .map_err(|error| io::Error::other(error.to_string()))?; + let resumed = ResumeThread(thread); + let _ = CloseHandle(thread); + if resumed == u32::MAX { + return Err(io::Error::last_os_error()); + } + return Ok(()); + } + if Thread32Next(snapshot, &mut entry).is_err() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "suspended process primary thread was not found", + )); + } + } + })(); + let _ = CloseHandle(snapshot); + result + } +} + +#[cfg(windows)] +fn job_error(error: impl std::fmt::Display) -> io::Error { + io::Error::other(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{CleanupOutcome, ProcessTreeChild}; + use std::path::Path; + use std::process::Stdio; + use std::time::{Duration, Instant}; + use tokio::process::Command; + + #[tokio::test] + async fn terminate_removes_the_parent_and_its_descendant() { + let temporary = tempfile::tempdir().expect("create process-tree fixture directory"); + let pid_file = temporary.path().join("descendant.pid"); + let mut command = descendant_fixture(&pid_file); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut tree = ProcessTreeChild::spawn(&mut command) + .await + .expect("spawn attached process tree"); + let descendant_pid = wait_for_descendant_pid(&pid_file).await; + assert!(process_is_alive(descendant_pid)); + + let outcome = tree + .terminate(Duration::from_millis(250)) + .await + .expect("terminate process tree"); + + #[cfg(unix)] + assert!(matches!( + outcome, + CleanupOutcome::Graceful | CleanupOutcome::Forced + )); + #[cfg(windows)] + assert_eq!(outcome, CleanupOutcome::Forced); + wait_until_process_exits(descendant_pid).await; + } + + #[tokio::test] + async fn terminate_removes_descendant_after_parent_has_exited() { + let temporary = tempfile::tempdir().expect("create process-tree fixture directory"); + let pid_file = temporary.path().join("orphaned-descendant.pid"); + let mut command = orphaned_descendant_fixture(&pid_file); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut tree = ProcessTreeChild::spawn(&mut command) + .await + .expect("spawn attached process tree"); + let descendant_pid = wait_for_descendant_pid(&pid_file).await; + wait_until_parent_exits(&mut tree).await; + assert!(process_is_alive(descendant_pid)); + + tree.terminate(Duration::from_millis(250)) + .await + .expect("terminate orphaned descendant"); + wait_until_process_exits(descendant_pid).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn detached_unix_descendant_is_explicitly_outside_the_process_group_boundary() { + let temporary = tempfile::tempdir().expect("create detached process fixture directory"); + let pid_file = temporary.path().join("detached-descendant.pid"); + let executable = std::env::current_exe().expect("locate test executable"); + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("\"$BITFUN_PROCESS_TREE_TEST_EXE\" --exact process_tree::tests::unix_detached_fixture_process --nocapture") + .env("BITFUN_PROCESS_TREE_TEST_EXE", executable) + .env("BITFUN_DETACHED_FIXTURE", "1") + .env("BITFUN_DESCENDANT_PID_FILE", &pid_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut tree = ProcessTreeChild::spawn(&mut command) + .await + .expect("spawn process-group fixture"); + let descendant_pid = wait_for_descendant_pid(&pid_file).await; + tree.terminate(Duration::from_millis(100)) + .await + .expect("terminate managed process group"); + + assert!( + process_is_alive(descendant_pid), + "a detached descendant must not be described as managed by the Unix process-group boundary" + ); + // SAFETY: the PID was written by this test fixture and is cleaned up + // immediately so the contract test never leaks the escaped process. + let _ = unsafe { libc::kill(descendant_pid as i32, libc::SIGKILL) }; + wait_until_process_exits(descendant_pid).await; + } + + #[cfg(unix)] + #[test] + fn unix_detached_fixture_process() { + if std::env::var_os("BITFUN_DETACHED_FIXTURE").is_none() { + return; + } + // SAFETY: the fixture is a single-threaded test subprocess created + // solely to verify the documented process-group containment limit. + assert!( + unsafe { libc::setsid() } >= 0, + "fixture must create a new session" + ); + let pid_file = std::env::var("BITFUN_DESCENDANT_PID_FILE").expect("fixture PID file path"); + std::fs::write(pid_file, std::process::id().to_string()) + .expect("publish detached fixture PID"); + loop { + std::thread::sleep(Duration::from_secs(60)); + } + } + + #[cfg(windows)] + fn descendant_fixture(pid_file: &Path) -> Command { + let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:BITFUN_DESCENDANT_PID_FILE, [string]$child.Id); while ($true) { Start-Sleep -Seconds 60 }"#; + let mut command = Command::new("powershell.exe"); + command + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(script) + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + #[cfg(windows)] + fn orphaned_descendant_fixture(pid_file: &Path) -> Command { + let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:BITFUN_DESCENDANT_PID_FILE, [string]$child.Id)"#; + let mut command = Command::new("powershell.exe"); + command + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(script) + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + #[cfg(unix)] + fn descendant_fixture(pid_file: &Path) -> Command { + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("sleep 60 & echo $! > \"$BITFUN_DESCENDANT_PID_FILE\"; wait") + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + #[cfg(unix)] + fn orphaned_descendant_fixture(pid_file: &Path) -> Command { + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("sleep 60 & echo $! > \"$BITFUN_DESCENDANT_PID_FILE\"") + .env("BITFUN_DESCENDANT_PID_FILE", pid_file); + command + } + + async fn wait_until_parent_exits(tree: &mut ProcessTreeChild) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if tree.try_wait().expect("query parent process").is_some() { + return; + } + assert!(Instant::now() < deadline, "parent process did not exit"); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + async fn wait_for_descendant_pid(path: &Path) -> u32 { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(raw) = std::fs::read_to_string(path) { + if let Ok(pid) = raw.trim().parse() { + return pid; + } + } + assert!( + Instant::now() < deadline, + "descendant PID was not published" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + async fn wait_until_process_exits(pid: u32) { + let deadline = Instant::now() + Duration::from_secs(5); + while process_is_alive(pid) { + assert!( + Instant::now() < deadline, + "descendant process survived cleanup" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + #[cfg(windows)] + fn process_is_alive(pid: u32) -> bool { + std::process::Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(format!( + "if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}" + )) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } + + #[cfg(unix)] + fn process_is_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } +} diff --git a/src/crates/services/services-core/src/session/layout.rs b/src/crates/services/services-core/src/session/layout.rs index 3dba839f9c..d658dcb4ee 100644 --- a/src/crates/services/services-core/src/session/layout.rs +++ b/src/crates/services/services-core/src/session/layout.rs @@ -64,6 +64,22 @@ impl SessionStorageLayout { self.session_dir(session_id).join("artifacts") } + /// Generated, read-only copies of referenced sessions. These artifacts + /// deliberately live with the consuming session rather than exposing the + /// referenced session's storage root to agent tools. + pub fn session_references_dir(&self, session_id: &str) -> PathBuf { + self.artifacts_dir(session_id).join("session-references") + } + + pub fn session_reference_transcript_path( + &self, + session_id: &str, + reference_artifact_stem: &str, + ) -> PathBuf { + self.session_references_dir(session_id) + .join(format!("{reference_artifact_stem}.txt")) + } + pub fn turn_path(&self, session_id: &str, turn_index: usize) -> PathBuf { self.turns_dir(session_id) .join(format!("turn-{:04}.json", turn_index)) @@ -129,6 +145,11 @@ impl SessionStorageLayout { self.ensure_dir(self.artifacts_dir(session_id)).await } + pub async fn ensure_session_references_dir(&self, session_id: &str) -> io::Result { + self.ensure_dir(self.session_references_dir(session_id)) + .await + } + pub async fn ensure_compression_transcripts_dir( &self, session_id: &str, diff --git a/src/crates/services/services-core/src/session/lineage.rs b/src/crates/services/services-core/src/session/lineage.rs index 36bdd0601e..ede951a252 100644 --- a/src/crates/services/services-core/src/session/lineage.rs +++ b/src/crates/services/services-core/src/session/lineage.rs @@ -42,6 +42,13 @@ pub struct SessionBranchResult { pub agent_type: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BranchSessionLineage { + pub base_session_name: String, + /// Positive ordinal allocated in the base session-title namespace. + pub ordinal: usize, +} + #[derive(Debug, Clone)] pub struct BranchSessionMetadataFacts<'a> { pub source_metadata: &'a SessionMetadata, @@ -52,9 +59,57 @@ pub struct BranchSessionMetadataFacts<'a> { pub source_turn_id: &'a str, pub source_turn_index: usize, pub branched_turns: &'a [DialogTurnData], + pub branch_lineage: &'a BranchSessionLineage, pub now_ms: u64, } +/// Resolves the title namespace and the next compact branch title. +/// +/// A title matching the inherited `base title (N)` keeps that title namespace, +/// even when a user changed `N`; any other renamed title becomes a new base. +/// The next ordinal is allocated from matching titles across the workspace, not +/// from the fork root, so nested forks do not accumulate suffixes. +pub fn resolve_branch_session_lineage( + source_metadata: &SessionMetadata, + source_session_name: &str, + metadata_list: &[SessionMetadata], +) -> BranchSessionLineage { + let source_session_name = source_session_name.trim(); + let base_session_name = fork_base_session_name(source_metadata) + .filter(|base_session_name| { + branch_session_name_ordinal(source_session_name, base_session_name).is_some() + }) + .unwrap_or_else(|| source_session_name.to_string()); + + let max_ordinal = std::iter::once(source_session_name) + .chain( + metadata_list + .iter() + .map(|metadata| metadata.session_name.as_str()), + ) + .filter_map(|session_name| branch_session_name_ordinal(session_name, &base_session_name)) + .max() + .unwrap_or_default(); + + BranchSessionLineage { + base_session_name, + ordinal: max_ordinal.saturating_add(1), + } +} + +pub fn format_branch_session_name(base_session_name: &str, ordinal: usize) -> String { + format!("{base_session_name} ({ordinal})") +} + +fn branch_session_name_ordinal(session_name: &str, base_session_name: &str) -> Option { + let suffix = session_name + .trim() + .strip_prefix(base_session_name)? + .strip_prefix(" (")? + .strip_suffix(')')?; + suffix.parse::().ok().filter(|value| *value > 0) +} + pub fn apply_session_lineage(metadata: &mut SessionMetadata, relationship: SessionRelationship) { metadata.relationship = Some(relationship); metadata.custom_metadata = strip_lineage_custom_metadata(metadata.custom_metadata.take()); @@ -216,6 +271,7 @@ pub fn build_branched_session_metadata(facts: BranchSessionMetadataFacts<'_>) -> facts.source_session_id, facts.source_turn_id, facts.source_turn_index, + facts.branch_lineage, ); metadata.relationship = None; metadata.todos = None; @@ -258,6 +314,7 @@ fn build_branch_custom_metadata( source_session_id: &str, source_turn_id: &str, source_turn_index: usize, + branch_lineage: &BranchSessionLineage, ) -> Option { let mut base = match strip_child_session_metadata(source_metadata) { Some(JsonValue::Object(map)) => map, @@ -270,12 +327,28 @@ fn build_branch_custom_metadata( "sessionId": source_session_id, "turnId": source_turn_id, "turnIndex": source_turn_index + 1, + "baseTitle": branch_lineage.base_session_name, }), ); Some(JsonValue::Object(base)) } +fn fork_base_session_name(metadata: &SessionMetadata) -> Option { + metadata + .custom_metadata + .as_ref()? + .get("forkOrigin")? + .get("baseTitle")? + .as_str() + .and_then(normalize_nonempty) +} + +fn normalize_nonempty(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -381,6 +454,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), @@ -413,6 +487,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }, ); @@ -443,6 +518,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }); let mut grandchild = metadata("grandchild"); @@ -492,12 +568,17 @@ mod tests { parent_turn_index: Some(1), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }); source.todos = Some(json!([{ "id": "todo" }])); source.deep_review_run_manifest = Some(json!({ "run": "manifest" })); source.unread_completion = Some("completed".to_string()); source.needs_user_attention = Some("ask_user".to_string()); let turns = vec![turn("target", "turn-1", 0), turn("target", "turn-2", 1)]; + let branch_lineage = BranchSessionLineage { + base_session_name: "Source".to_string(), + ordinal: 1, + }; let branched = build_branched_session_metadata(BranchSessionMetadataFacts { source_metadata: &source, @@ -508,6 +589,7 @@ mod tests { source_turn_id: "turn-2", source_turn_index: 1, branched_turns: &turns, + branch_lineage: &branch_lineage, now_ms: 42, }); @@ -538,8 +620,82 @@ mod tests { json!({ "sessionId": "source", "turnId": "turn-2", - "turnIndex": 2 + "turnIndex": 2, + "baseTitle": "Source" }) ); } + + #[test] + fn branch_lineage_uses_the_inherited_title_namespace_for_renamed_suffixes() { + let mut root = metadata("root"); + root.session_name = "Title".to_string(); + + let mut first_branch = metadata("first-branch"); + first_branch.session_name = "Title (2)".to_string(); + first_branch.custom_metadata = Some(json!({ + "forkOrigin": { + "sessionId": "root", + "turnId": "turn-0", + "turnIndex": 1, + "baseTitle": "Title" + } + })); + + let nested_lineage = resolve_branch_session_lineage( + &first_branch, + &first_branch.session_name, + &[root.clone(), first_branch.clone()], + ); + assert_eq!(nested_lineage.base_session_name, "Title"); + assert_eq!(nested_lineage.ordinal, 3); + assert_eq!( + format_branch_session_name(&nested_lineage.base_session_name, nested_lineage.ordinal), + "Title (3)" + ); + + let mut second_branch = metadata("second-branch"); + second_branch.session_name = "Title (3)".to_string(); + second_branch.custom_metadata = Some(json!({ + "forkOrigin": { + "sessionId": "first-branch", + "turnId": "turn-0", + "turnIndex": 1, + "baseTitle": "Title" + } + })); + + let sibling_lineage = resolve_branch_session_lineage( + &root, + &root.session_name, + &[root.clone(), first_branch, second_branch], + ); + assert_eq!(sibling_lineage.base_session_name, "Title"); + assert_eq!(sibling_lineage.ordinal, 4); + } + + #[test] + fn branch_lineage_treats_an_unrelated_suffix_as_a_new_title_base() { + let mut root = metadata("root"); + root.session_name = "Title".to_string(); + let mut branch = metadata("branch"); + branch.session_name = "Another title (2)".to_string(); + branch.custom_metadata = Some(json!({ + "forkOrigin": { + "sessionId": "root", + "turnId": "turn-0", + "turnIndex": 1, + "baseTitle": "Title" + } + })); + + let lineage = + resolve_branch_session_lineage(&branch, &branch.session_name, &[root, branch.clone()]); + assert_eq!(lineage.base_session_name, "Another title (2)"); + assert_eq!(lineage.ordinal, 1); + assert_eq!( + format_branch_session_name(&lineage.base_session_name, lineage.ordinal), + "Another title (2) (1)" + ); + } } diff --git a/src/crates/services/services-core/src/session/metadata.rs b/src/crates/services/services-core/src/session/metadata.rs index d6a32a4ce9..009df6c906 100644 --- a/src/crates/services/services-core/src/session/metadata.rs +++ b/src/crates/services/services-core/src/session/metadata.rs @@ -126,6 +126,9 @@ fn build_session_relationship( .as_ref() .and_then(|value| value.subagent_type.clone()) .or_else(|| legacy_custom_metadata_string(existing_custom_metadata, "subagentType")); + let continuation_policy = existing_relationship + .as_ref() + .and_then(|value| value.continuation_policy); if kind.is_none() && parent_session_id.is_none() @@ -134,6 +137,7 @@ fn build_session_relationship( && parent_turn_index.is_none() && parent_tool_call_id.is_none() && subagent_type.is_none() + && continuation_policy.is_none() { return None; } @@ -146,6 +150,7 @@ fn build_session_relationship( parent_turn_index, parent_tool_call_id, subagent_type, + continuation_policy, }) } @@ -156,7 +161,7 @@ fn legacy_custom_metadata_string(value: Option<&Value>, key: &str) -> Option usize { +pub fn estimate_turn_message_count(turn: &DialogTurnData) -> usize { let assistant_text_count: usize = turn .model_rounds .iter() @@ -362,6 +367,7 @@ fn fill_workspace_path_if_missing(metadata: &mut SessionMetadata, workspace_path mod tests { use super::*; use crate::session::{SessionRelationship, SessionRelationshipKind}; + use bitfun_core_types::SessionContinuationPolicy; use serde_json::json; fn metadata() -> SessionMetadata { @@ -421,6 +427,7 @@ mod tests { parent_turn_index: Some(3), parent_tool_call_id: Some("tool".to_string()), subagent_type: Some("ReviewSecurity".to_string()), + continuation_policy: Some(SessionContinuationPolicy::FreshOnly), }; set_session_relationship(&mut metadata, relationship.clone()); @@ -535,6 +542,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: Some("tool-1".to_string()), subagent_type: Some("review".to_string()), + continuation_policy: None, }) ); } diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index ccc52db726..a59fd413d9 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -11,6 +11,7 @@ pub use bitfun_core_types::SessionKind; pub use layout::SessionStorageLayout; pub use lineage::{ apply_session_lineage, build_branched_session_metadata, collect_hidden_subagent_cascade, + format_branch_session_name, resolve_branch_session_lineage, BranchSessionLineage, BranchSessionMetadataFacts, SessionBranchRequest, SessionBranchResult, }; pub use memory_workspace::{ @@ -19,11 +20,11 @@ pub use memory_workspace::{ MemoryWorkspaceDiff, MemoryWorkspaceGitError, }; pub use metadata::{ - build_session_index_snapshot, build_session_metadata, merge_session_custom_metadata, - refresh_session_metadata_from_turns, remove_session_index_entry, set_deep_review_cache, - set_deep_review_run_manifest, set_review_target_evidence, set_session_relationship, - try_refresh_session_metadata_for_saved_turn, upsert_session_index_entry, - SessionMetadataBuildFacts, + build_session_index_snapshot, build_session_metadata, estimate_turn_message_count, + merge_session_custom_metadata, refresh_session_metadata_from_turns, remove_session_index_entry, + set_deep_review_cache, set_deep_review_run_manifest, set_review_target_evidence, + set_session_relationship, try_refresh_session_metadata_for_saved_turn, + upsert_session_index_entry, SessionMetadataBuildFacts, }; pub use metadata_store::{SessionMetadataStore, SessionMetadataStoreError}; pub use migration::{ diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 8d28f4c7f0..9d9e5cad53 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -1,6 +1,8 @@ //! Types for session persistence -use bitfun_core_types::SessionKind; +use bitfun_core_types::ToolImageAttachment; +use bitfun_core_types::{AiErrorDetail, SessionContinuationPolicy, SessionKind}; +use bitfun_events::ModelRoundAttemptDiagnostic; use serde::{Deserialize, Serialize}; pub const SESSION_STORAGE_SCHEMA_VERSION: u32 = 2; @@ -56,6 +58,8 @@ pub struct SessionRelationship { alias = "subagent_type" )] pub subagent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub continuation_policy: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -416,6 +420,18 @@ pub struct DialogTurnData { )] pub has_final_response: Option, + /// Terminal error message when the turn failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + + /// Structured provider diagnostics for a failed turn. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "error_detail" + )] + pub error_detail: Option, + /// Turn status pub status: TurnStatus, } @@ -545,6 +561,12 @@ pub struct ModelRoundData { alias = "attempt_count" )] pub attempt_count: Option, + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + alias = "attempt_diagnostics" + )] + pub attempt_diagnostics: Vec, #[serde( default, skip_serializing_if = "Option::is_none", @@ -755,6 +777,12 @@ pub struct ToolResultData { alias = "result_for_assistant" )] pub result_for_assistant: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "image_attachments" + )] + pub image_attachments: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(skip_serializing_if = "Option::is_none", alias = "duration_ms")] @@ -987,6 +1015,8 @@ impl DialogTurnData { token_usage: None, finish_reason: None, has_final_response: None, + error: None, + error_detail: None, status: TurnStatus::InProgress, } } @@ -1019,7 +1049,7 @@ mod tests { SessionRelationship, SessionRelationshipKind, TextItemData, ThinkingItemData, ToolItemData, UserMessageData, }; - use bitfun_core_types::SessionKind; + use bitfun_core_types::{SessionContinuationPolicy, SessionKind}; #[test] fn dialog_turn_kind_defaults_to_user_dialog_for_legacy_payloads() { @@ -1216,6 +1246,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: Some(SessionContinuationPolicy::FreshOnly), }); let json = serde_json::to_value(&metadata).expect("metadata should serialize"); diff --git a/src/crates/services/services-core/src/session_usage/classifier.rs b/src/crates/services/services-core/src/session_usage/classifier.rs index f0adc5b5e5..d9831adaa8 100644 --- a/src/crates/services/services-core/src/session_usage/classifier.rs +++ b/src/crates/services/services-core/src/session_usage/classifier.rs @@ -1,13 +1,4 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageToolCategory { - Git, - Shell, - File, - Other, -} +pub use bitfun_core_types::UsageToolCategory; pub fn classify_tool_usage( tool_name: &str, diff --git a/src/crates/services/services-core/src/session_usage/types.rs b/src/crates/services/services-core/src/session_usage/types.rs index e7cf9e9e08..2d59053222 100644 --- a/src/crates/services/services-core/src/session_usage/types.rs +++ b/src/crates/services/services-core/src/session_usage/types.rs @@ -1,446 +1,12 @@ -use serde::{Deserialize, Serialize}; - -pub const SESSION_USAGE_REPORT_SCHEMA_VERSION: u16 = 1; - -// PartialEq only (not Eq) because nested UsageTokenBreakdown/UsageModelBreakdown -// hold `cache_hit_rate: Option`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SessionUsageReport { - pub schema_version: u16, - pub report_id: String, - pub session_id: String, - pub generated_at: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generated_from_app_version: Option, - pub workspace: UsageWorkspace, - pub scope: UsageScope, - pub coverage: UsageCoverage, - pub time: UsageTimeBreakdown, - pub tokens: UsageTokenBreakdown, - #[serde(default)] - pub models: Vec, - #[serde(default)] - pub tools: Vec, - pub files: UsageFileBreakdown, - pub compression: UsageCompressionBreakdown, - pub errors: UsageErrorBreakdown, - #[serde(default)] - pub slowest: Vec, - pub privacy: UsagePrivacy, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageWorkspace { - pub kind: UsageWorkspaceKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path_label: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageWorkspaceKind { - Local, - RemoteSsh, - Unknown, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageScope { - pub kind: UsageScopeKind, - pub turn_count: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub from_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub to_turn_id: Option, - pub includes_subagents: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageScopeKind { - EntireSession, - TurnRange, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageCoverage { - pub level: UsageCoverageLevel, - #[serde(default)] - pub available: Vec, - #[serde(default)] - pub missing: Vec, - #[serde(default)] - pub notes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageCoverageLevel { - Complete, - Partial, - Minimal, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum UsageCoverageKey { - ModelRoundTiming, - ToolPhaseTiming, - CachedTokens, - TokenDetailBreakdown, - SubagentScope, - RemoteSnapshotStats, - FileLineStats, - WorkspaceIdentity, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageTimeBreakdown { - pub accounting: UsageTimeAccounting, - pub denominator: UsageTimeDenominator, - /// First recorded turn start to last recorded turn end; may include idle gaps. - pub wall_time_ms: Option, - /// Sum of persisted turn durations; may include orchestration/waiting inside a turn. - pub active_turn_ms: Option, - /// Sum of persisted model-round spans, not provider streaming throughput. - pub model_ms: Option, - pub tool_ms: Option, - pub idle_gap_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageTimeAccounting { - Approximate, - Exact, - Unavailable, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageTimeDenominator { - SessionWallTime, - ActiveTurnTime, - Unavailable, -} - -// PartialEq only (not Eq) because `cache_hit_rate: Option` precludes -// total equality. Existing call sites compare with `==`, which works on f64 -// via PartialEq (NaN-aware). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UsageTokenBreakdown { - pub source: UsageTokenSource, - pub input_tokens: Option, - pub output_tokens: Option, - pub total_tokens: Option, - pub cached_tokens: Option, - pub cache_coverage: UsageCacheCoverage, - /// `cached_tokens / input_tokens` over records that explicitly report - /// cached tokens. `None` when no record has cached coverage. Range: 0.0–1.0. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_hit_rate: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageTokenSource { - TokenUsageRecords, - Unavailable, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageCacheCoverage { - Available, - Partial, - Unavailable, -} - -// PartialEq only (not Eq) — see comment on UsageTokenBreakdown. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UsageModelBreakdown { - pub model_id: String, - pub call_count: u64, - pub input_tokens: Option, - pub output_tokens: Option, - pub total_tokens: Option, - pub cached_tokens: Option, - /// Per-model hit rate. Same semantic as [`UsageTokenBreakdown::cache_hit_rate`]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_hit_rate: Option, - pub duration_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_index: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageToolBreakdown { - pub tool_name: String, - pub category: super::classifier::UsageToolCategory, - pub call_count: u64, - pub success_count: u64, - pub error_count: u64, - pub duration_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub p95_duration_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub queue_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preflight_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub confirmation_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_item_id: Option, - pub redacted: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageFileBreakdown { - pub scope: UsageFileScope, - pub changed_files: Option, - pub added_lines: Option, - pub deleted_lines: Option, - #[serde(default)] - pub files: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageFileScope { - SnapshotSummary, - ToolInputsOnly, - Unavailable, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageFileRow { - pub path_label: String, - pub operation_count: u64, - pub added_lines: Option, - pub deleted_lines: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub turn_indexes: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub operation_ids: Vec, - pub redacted: bool, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageSnapshotFacts { - pub source_available: bool, - #[serde(default)] - pub operations: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageSnapshotOperationSummary { - pub operation_id: String, - pub session_id: String, - pub turn_index: usize, - pub file_path: String, - pub lines_added: u64, - pub lines_removed: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageCompressionBreakdown { - pub compaction_count: u64, - pub manual_compaction_count: u64, - pub automatic_compaction_count: u64, - pub saved_tokens: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageErrorBreakdown { - pub total_errors: u64, - pub tool_errors: u64, - pub model_errors: u64, - #[serde(default)] - pub examples: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageErrorExample { - pub label: String, - pub count: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_item_id: Option, - pub redacted: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageSlowSpan { - pub label: String, - pub kind: UsageSlowSpanKind, - pub duration_ms: u64, - pub redacted: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub item_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timed_out: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub queue_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preflight_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub confirmation_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageSlowSpanKind { - Model, - Tool, - Turn, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsagePrivacy { - pub prompt_content_included: bool, - pub tool_inputs_included: bool, - pub command_outputs_included: bool, - pub file_contents_included: bool, - #[serde(default)] - pub redacted_fields: Vec, -} - -impl SessionUsageReport { - pub fn partial_unavailable(session_id: impl Into, generated_at: i64) -> Self { - Self { - schema_version: SESSION_USAGE_REPORT_SCHEMA_VERSION, - report_id: format!("usage-{}", generated_at), - session_id: session_id.into(), - generated_at, - generated_from_app_version: None, - workspace: UsageWorkspace { - kind: UsageWorkspaceKind::Unknown, - path_label: None, - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - }, - scope: UsageScope { - kind: UsageScopeKind::EntireSession, - turn_count: 0, - from_turn_id: None, - to_turn_id: None, - includes_subagents: false, - }, - coverage: UsageCoverage { - level: UsageCoverageLevel::Partial, - available: vec![], - missing: vec![ - UsageCoverageKey::CachedTokens, - UsageCoverageKey::TokenDetailBreakdown, - ], - notes: vec![ - "P0 report uses existing persisted session facts and marks not-reported metrics explicitly." - .to_string(), - ], - }, - time: UsageTimeBreakdown { - accounting: UsageTimeAccounting::Unavailable, - denominator: UsageTimeDenominator::Unavailable, - wall_time_ms: None, - active_turn_ms: None, - model_ms: None, - tool_ms: None, - idle_gap_ms: None, - }, - tokens: UsageTokenBreakdown { - source: UsageTokenSource::Unavailable, - input_tokens: None, - output_tokens: None, - total_tokens: None, - cached_tokens: None, - cache_coverage: UsageCacheCoverage::Unavailable, - cache_hit_rate: None, - }, - models: vec![], - tools: vec![], - files: UsageFileBreakdown { - scope: UsageFileScope::Unavailable, - changed_files: None, - added_lines: None, - deleted_lines: None, - files: vec![], - }, - compression: UsageCompressionBreakdown { - compaction_count: 0, - manual_compaction_count: 0, - automatic_compaction_count: 0, - saved_tokens: None, - }, - errors: UsageErrorBreakdown { - total_errors: 0, - tool_errors: 0, - model_errors: 0, - examples: vec![], - }, - slowest: vec![], - privacy: UsagePrivacy { - prompt_content_included: false, - tool_inputs_included: false, - command_outputs_included: false, - file_contents_included: false, - redacted_fields: vec![], - }, - } - } -} +pub use bitfun_core_types::{ + SessionUsageReport, UsageCacheCoverage, UsageCompressionBreakdown, UsageCoverage, + UsageCoverageKey, UsageCoverageLevel, UsageErrorBreakdown, UsageErrorExample, + UsageFileBreakdown, UsageFileRow, UsageFileScope, UsageModelBreakdown, UsagePrivacy, + UsageScope, UsageScopeKind, UsageSlowSpan, UsageSlowSpanKind, UsageSnapshotFacts, + UsageSnapshotOperationSummary, UsageTimeAccounting, UsageTimeBreakdown, UsageTimeDenominator, + UsageTokenBreakdown, UsageTokenSource, UsageToolBreakdown, UsageWorkspace, UsageWorkspaceKind, + SESSION_USAGE_REPORT_SCHEMA_VERSION, +}; #[cfg(test)] pub(crate) fn test_report() -> SessionUsageReport { @@ -497,7 +63,7 @@ pub(crate) fn test_report() -> SessionUsageReport { }]; report.tools = vec![UsageToolBreakdown { tool_name: "read_file".to_string(), - category: super::classifier::UsageToolCategory::File, + category: bitfun_core_types::UsageToolCategory::File, call_count: 1, success_count: 1, error_count: 0, @@ -530,53 +96,3 @@ pub(crate) fn test_report() -> SessionUsageReport { }; report } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn session_usage_report_round_trips_with_partial_coverage() { - let report = test_report(); - let json = serde_json::to_string(&report).expect("serialize report"); - let restored: SessionUsageReport = serde_json::from_str(&json).expect("deserialize report"); - - assert_eq!(restored, report); - assert_eq!( - restored.coverage.level, - UsageCoverageLevel::Partial, - "P0 reports must make partial coverage explicit" - ); - } - - #[test] - fn session_usage_report_round_trips_with_workspace_scope_and_privacy() { - let mut report = test_report(); - report.workspace.kind = UsageWorkspaceKind::RemoteSsh; - report.workspace.remote_ssh_host = Some("example.internal".to_string()); - report - .privacy - .redacted_fields - .push("slowest.label".to_string()); - - let json = serde_json::to_string(&report).expect("serialize report"); - - assert!(json.contains("remote_ssh")); - assert!(json.contains("redactedFields")); - - let restored: SessionUsageReport = serde_json::from_str(&json).expect("deserialize report"); - assert_eq!(restored.workspace.kind, UsageWorkspaceKind::RemoteSsh); - assert_eq!(restored.privacy.redacted_fields, vec!["slowest.label"]); - } - - #[test] - fn token_cache_unavailable_does_not_require_cached_value() { - let report = test_report(); - - assert_eq!( - report.tokens.cache_coverage, - UsageCacheCoverage::Unavailable - ); - assert_eq!(report.tokens.cached_tokens, None); - } -} diff --git a/src/crates/services/services-core/tests/local_runtime_ports.rs b/src/crates/services/services-core/tests/local_runtime_ports.rs new file mode 100644 index 0000000000..bd28c71430 --- /dev/null +++ b/src/crates/services/services-core/tests/local_runtime_ports.rs @@ -0,0 +1,44 @@ +#![cfg(feature = "workspace-runtime")] + +use bitfun_runtime_ports::{RuntimeEventEnvelope, RuntimeEventType, RuntimeServiceCapability}; +use bitfun_services_core::local_runtime_ports::LocalRuntimePorts; + +#[tokio::test] +async fn local_runtime_ports_bind_one_canonical_workspace_and_runtime_facts() { + let workspace = tempfile::tempdir().expect("workspace"); + let ports = LocalRuntimePorts::new(workspace.path(), 8).expect("local runtime ports"); + + assert_eq!( + ports.workspace_root(), + dunce::canonicalize(workspace.path()).unwrap() + ); + assert_eq!( + ports.filesystem().capability(), + RuntimeServiceCapability::FileSystem + ); + assert_eq!( + ports.workspace().capability(), + RuntimeServiceCapability::Workspace + ); + assert!(ports.clock().now_unix_millis() > 0); + ports + .events() + .publish_runtime_event(RuntimeEventEnvelope { + session_id: "local-runtime-test".to_string(), + turn_id: None, + source: None, + event_type: RuntimeEventType::SessionStateChanged, + payload: serde_json::json!({ "status": "ready" }), + }) + .await + .expect("publish local runtime event"); +} + +#[test] +fn local_runtime_ports_reject_a_missing_workspace() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = LocalRuntimePorts::new(temp.path().join("missing"), 8) + .expect_err("missing workspace must fail"); + + assert!(error.to_string().contains("workspace"), "{error}"); +} diff --git a/src/crates/services/services-core/tests/permission_store_contracts.rs b/src/crates/services/services-core/tests/permission_store_contracts.rs new file mode 100644 index 0000000000..9070b146a4 --- /dev/null +++ b/src/crates/services/services-core/tests/permission_store_contracts.rs @@ -0,0 +1,300 @@ +#![cfg(feature = "permission")] + +use bitfun_runtime_ports::{ + PermissionAuditEvent, PermissionAuditRecord, PermissionAuditStorePort, PermissionGrant, + PermissionGrantKey, PermissionGrantStorePort, PermissionReply, PermissionReplySource, + PermissionReplyStorePort, PermissionRequest, PermissionRequestSource, + PermissionRequestSourceKind, +}; +use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; +use serde_json::Map; + +fn request(request_id: &str, project_id: &str) -> PermissionRequest { + PermissionRequest { + request_id: request_id.to_string(), + round_id: format!("synthetic:{request_id}"), + order: 0, + tool_call_id: None, + project_path: None, + project_id: project_id.to_string(), + session_id: "session-1".to_string(), + agent_id: "agentic".to_string(), + action: "read".to_string(), + resources: vec!["README.md".to_string()], + save_resources: vec!["README.md".to_string()], + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "tool-1".to_string(), + }, + delegation: None, + display_metadata: Map::new(), + } +} + +fn audit_record( + audit_id: &str, + request_id: &str, + project_id: &str, + timestamp_ms: i64, +) -> PermissionAuditRecord { + PermissionAuditRecord { + audit_id: audit_id.to_string(), + request: request(request_id, project_id), + event: PermissionAuditEvent::Requested, + timestamp_ms, + } +} + +#[tokio::test] +async fn project_grants_are_idempotent_isolated_and_survive_store_recreation() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + store.path(), + root.path().join("tool-permissions.sqlite").as_path() + ); + let project_a = PermissionGrant { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + created_at_ms: 10, + }; + let project_b = PermissionGrant { + project_id: "project-b".to_string(), + action: "edit".to_string(), + resource: "src/*".to_string(), + created_at_ms: 20, + }; + + store + .add_project_grants(vec![project_a.clone(), project_a.clone(), project_b]) + .await + .expect("persist grants"); + assert!(store.path().is_file()); + let connection = rusqlite::Connection::open(store.path()).expect("open permission database"); + let schema_version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .expect("read permission database schema version"); + assert_eq!(schema_version, 1); + + let reopened = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + reopened + .list_project_grants("project-a") + .await + .expect("list project grants"), + vec![project_a.clone()] + ); + assert_eq!( + reopened + .list_project_grants("project-b") + .await + .expect("list other project grants") + .len(), + 1 + ); + + assert!(reopened + .remove_project_grant(PermissionGrantKey { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + }) + .await + .expect("remove grant")); + assert!(reopened + .list_project_grants("project-a") + .await + .expect("list removed project grants") + .is_empty()); +} + +#[tokio::test] +async fn clearing_grants_only_removes_the_selected_project() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + store + .add_project_grants(vec![ + PermissionGrant { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + created_at_ms: 10, + }, + PermissionGrant { + project_id: "project-b".to_string(), + action: "edit".to_string(), + resource: "src/*".to_string(), + created_at_ms: 20, + }, + ]) + .await + .expect("persist grants"); + + assert_eq!( + store + .clear_project_grants("project-a") + .await + .expect("clear project grants"), + 1 + ); + assert!(store + .list_project_grants("project-a") + .await + .expect("list cleared project grants") + .is_empty()); + assert_eq!( + store + .list_project_grants("project-b") + .await + .expect("list retained project grants") + .len(), + 1 + ); +} + +#[tokio::test] +async fn audit_records_are_idempotent_project_scoped_and_persistent() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + let record = PermissionAuditRecord { + audit_id: "request-1:replied".to_string(), + request: request("request-1", "project-a"), + event: PermissionAuditEvent::Replied { + reply: PermissionReply::Once, + source: PermissionReplySource::User, + }, + timestamp_ms: 100, + }; + + store + .append_permission_audit(record.clone()) + .await + .expect("append audit"); + store + .append_permission_audit(record.clone()) + .await + .expect("repeat audit idempotently"); + store + .append_permission_audit(PermissionAuditRecord { + timestamp_ms: 101, + ..record.clone() + }) + .await + .expect("repeat audit after retry timestamp change"); + store + .append_permission_audit(PermissionAuditRecord { + audit_id: "request-2:requested".to_string(), + request: request("request-2", "project-b"), + event: PermissionAuditEvent::Requested, + timestamp_ms: 90, + }) + .await + .expect("append other project audit"); + + let reopened = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + reopened + .list_project_permission_audit("project-a") + .await + .expect("list project audit"), + vec![record] + ); + assert_eq!( + reopened + .list_project_permission_audit("project-b") + .await + .expect("list other project audit") + .len(), + 1 + ); +} + +#[tokio::test] +async fn reply_transaction_persists_grants_and_audit_in_one_state_update() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()); + let grant = PermissionGrant { + project_id: "project-a".to_string(), + action: "read".to_string(), + resource: "README.md".to_string(), + created_at_ms: 100, + }; + let audit = PermissionAuditRecord { + audit_id: "request-1:replied".to_string(), + request: request("request-1", "project-a"), + event: PermissionAuditEvent::Replied { + reply: PermissionReply::Always, + source: PermissionReplySource::User, + }, + timestamp_ms: 100, + }; + + store + .commit_permission_reply(vec![grant.clone()], vec![audit.clone()]) + .await + .expect("commit reply transaction"); + + let reopened = ProjectPermissionSqliteStore::new(root.path()); + assert_eq!( + reopened + .list_project_grants("project-a") + .await + .expect("list committed grants"), + vec![grant] + ); + assert_eq!( + reopened + .list_project_permission_audit("project-a") + .await + .expect("list committed audit"), + vec![audit] + ); +} + +#[tokio::test] +async fn audit_retention_keeps_the_newest_records_for_each_project() { + let root = tempfile::tempdir().expect("temp permission store"); + let store = ProjectPermissionSqliteStore::new(root.path()).with_audit_limit(2); + + store + .append_permission_audit(audit_record("project-a:1", "request-1", "project-a", 10)) + .await + .expect("append first project audit"); + store + .append_permission_audit(audit_record("project-a:2", "request-2", "project-a", 20)) + .await + .expect("append second project audit"); + store + .append_permission_audit(audit_record("project-b:1", "request-3", "project-b", 30)) + .await + .expect("append other project audit"); + store + .commit_permission_reply( + Vec::new(), + vec![audit_record("project-a:3", "request-4", "project-a", 40)], + ) + .await + .expect("commit bounded project audit"); + + assert_eq!( + store + .list_project_permission_audit("project-a") + .await + .expect("list retained project audit") + .into_iter() + .map(|record| record.audit_id) + .collect::>(), + vec!["project-a:2", "project-a:3"] + ); + assert_eq!( + store + .list_project_permission_audit("project-b") + .await + .expect("list isolated project audit") + .into_iter() + .map(|record| record.audit_id) + .collect::>(), + vec!["project-b:1"] + ); +} diff --git a/src/crates/services/services-core/tests/session_metadata_contracts.rs b/src/crates/services/services-core/tests/session_metadata_contracts.rs index 1c5480eb9b..3297a3981f 100644 --- a/src/crates/services/services-core/tests/session_metadata_contracts.rs +++ b/src/crates/services/services-core/tests/session_metadata_contracts.rs @@ -95,6 +95,7 @@ fn round(turn_id: &str, text_count: usize, tool_count: usize) -> ModelRoundData first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 0ef8960f90..23e9e7811e 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -35,6 +35,11 @@ slices that are outside pure product logic but still platform-neutral. SFTP, remote FS, remote workspace FS/shell providers, remote terminal, remote ExecCommand runtime-port adapter, and manager assembly live here behind explicit remote SSH features. +- One-click relay self-deploy (`remote_ssh/relay_deploy.rs`) stages embedded + scripts under `~/.bitfun/relay-deploy/` and clones source to + `~/.bitfun/relay-src/` (never `$HOME/bitfun`). Invariants: + `src/web-ui/src/features/relay-deploy/README.md`. Desktop Tauri wrapper: + `src/apps/desktop/src/api/relay_deploy_api.rs`. - Workspace search owns the local flashgrep daemon/session lifecycle and indexed-search result conversion behind `workspace-search`; product config and workspace bootstrap stay in the core facade as injected hooks. @@ -70,8 +75,13 @@ slices that are outside pure product logic but still platform-neutral. decide approval/conflicts, register product tools, or claim OS sandboxing. Approved modules run in target child processes separated from the Rust host for failure containment, not as a security or protocol-authentication boundary. - Target process trees and OS resource containment remain an explicit product - risk until a platform process-tree boundary is implemented. + The shared `services-core::process_tree` boundary owns managed-descendant cleanup for + script workers, local stdio MCP, and other managed service children: Unix uses a dedicated process group; Windows attaches a + suspended child to a kill-on-close Job Object before resuming it and fails + closed when attachment fails. This is lifecycle containment, not an OS + sandbox or a CPU/memory/filesystem/network resource limit; surfaces must keep + those residual risks explicit. Unix descendants that deliberately create a + new session/process group are outside the managed boundary. - Announcement remote fetch/cache lives here; product assembly supplies config values such as endpoint, locale, version, platform, and cache path. - DeepResearch report IO here may own report/citation sidecar filesystem work; diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index ca4cf88099..b81264615c 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -69,7 +69,12 @@ git2 = { workspace = true, features = ["vendored-openssl"], optional = true } [target.'cfg(windows)'.dependencies] schannel = { version = "0.1", optional = true } -windows = { workspace = true, optional = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows = { workspace = true, optional = true, features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } [features] default = [] @@ -105,6 +110,7 @@ mcp = [ "rmcp", "rmcp/transport-streamable-http-client-reqwest", "sse-stream", + "process-tree", ] miniapp-runtime = [ "base64", @@ -207,7 +213,8 @@ workspace-search = [ "thiserror", "which", ] -script-tool-runtime = ["async-trait", "bitfun-runtime-ports", "which"] +process-tree = ["bitfun-services-core"] +script-tool-runtime = ["async-trait", "bitfun-runtime-ports", "process-tree", "which"] web-tools = ["reqwest", "thiserror"] product-full = [ "announcement", @@ -232,6 +239,7 @@ ssh_config = ["dep:ssh_config"] [dev-dependencies] tempfile = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } [[test]] name = "debug_log_owner_contracts" diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 690d46721c..8c74a04cfa 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -39,6 +39,14 @@ pub mod plugin_source; #[cfg(feature = "remote-connect")] pub mod remote_connect; +#[cfg(all(test, feature = "remote-connect"))] +mod feature_contract_tests { + #[test] + fn remote_connect_feature_exposes_its_public_module() { + let _ = super::remote_connect::RemoteConnectSubmissionSource::Relay; + } +} + #[cfg(feature = "remote-ssh")] pub mod remote_ssh; diff --git a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs index e90b6fc1ac..e95bb74f7f 100644 --- a/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs +++ b/src/crates/services/services-integrations/src/mcp/config/cursor_format.rs @@ -220,6 +220,8 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { command, args, env, + working_directory: None, + inherit_parent_environment: None, headers, url, auto_start, @@ -231,6 +233,7 @@ pub fn parse_cursor_format(config: &serde_json::Value) -> Vec { .get("oauth") .cloned() .and_then(|value| serde_json::from_value(value).ok()), + oauth_enabled: None, xaa: obj .get("xaa") .cloned() diff --git a/src/crates/services/services-integrations/src/mcp/server/mod.rs b/src/crates/services/services-integrations/src/mcp/server/mod.rs index 7a2471da95..d3c5bc5cc7 100644 --- a/src/crates/services/services-integrations/src/mcp/server/mod.rs +++ b/src/crates/services/services-integrations/src/mcp/server/mod.rs @@ -118,6 +118,13 @@ pub struct MCPServerConfig { pub args: Vec, #[serde(default)] pub env: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + /// Whether a local process inherits every environment variable from the + /// BitFun process. `None` preserves the legacy behavior for native MCP + /// configurations; imported executable configuration can opt out. + #[serde(skip_serializing_if = "Option::is_none")] + pub inherit_parent_environment: Option, /// Additional HTTP headers for remote MCP servers (Cursor-style `headers`). #[serde(default)] pub headers: HashMap, @@ -134,6 +141,10 @@ pub struct MCPServerConfig { pub settings: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub oauth: Option, + /// Explicit OAuth discovery policy. `None` preserves the legacy behavior + /// where remote servers may negotiate OAuth after an authentication error. + #[serde(skip_serializing_if = "Option::is_none")] + pub oauth_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub xaa: Option, } @@ -164,6 +175,14 @@ impl fmt::Display for MCPServerConfigValidationError { impl std::error::Error for MCPServerConfigValidationError {} impl MCPServerConfig { + pub fn remote_oauth_enabled(&self) -> bool { + self.oauth_enabled.unwrap_or(true) + } + + pub fn inherits_parent_environment(&self) -> bool { + self.inherit_parent_environment.unwrap_or(true) + } + pub fn resolved_transport(&self) -> MCPServerTransport { self.transport.unwrap_or(match self.server_type { MCPServerType::Local => MCPServerTransport::Stdio, @@ -187,6 +206,12 @@ impl MCPServerConfig { let transport = self.resolved_transport(); match self.server_type { MCPServerType::Local => { + if self.oauth_enabled.is_some() { + return Err(MCPServerConfigValidationError::new(format!( + "Local MCP server '{}' cannot configure OAuth", + self.id + ))); + } if self.command.is_none() { return Err(MCPServerConfigValidationError::new(format!( "Local MCP server '{}' must have a command", @@ -201,8 +226,28 @@ impl MCPServerConfig { transport.as_str() ))); } + if self.working_directory.as_deref().is_some_and(|directory| { + directory.trim().is_empty() || directory.chars().any(char::is_control) + }) { + return Err(MCPServerConfigValidationError::new(format!( + "Local MCP server '{}' has an invalid working directory", + self.id + ))); + } } MCPServerType::Remote => { + if self.inherit_parent_environment.is_some() { + return Err(MCPServerConfigValidationError::new(format!( + "Remote MCP server '{}' cannot configure process environment inheritance", + self.id + ))); + } + if self.working_directory.is_some() { + return Err(MCPServerConfigValidationError::new(format!( + "Remote MCP server '{}' cannot set a working directory", + self.id + ))); + } if self.url.is_none() { return Err(MCPServerConfigValidationError::new(format!( "Remote MCP server '{}' must have a URL", diff --git a/src/crates/services/services-integrations/src/mcp/server/process.rs b/src/crates/services/services-integrations/src/mcp/server/process.rs index c5b0e0c01b..bb61793a9f 100644 --- a/src/crates/services/services-integrations/src/mcp/server/process.rs +++ b/src/crates/services/services-integrations/src/mcp/server/process.rs @@ -8,11 +8,11 @@ use crate::mcp::protocol::{InitializeResult, MCPMessage, MCPServerInfo, MCPTrans use crate::mcp::server::{is_mcp_auth_error_message, merge_mcp_remote_headers}; use crate::mcp::{MCPRuntimeError, MCPRuntimeResult}; use bitfun_services_core::process_manager; +use bitfun_services_core::process_tree::ProcessTreeChild; use log::{debug, error, info, warn}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio::process::Child; use tokio::sync::{mpsc, RwLock}; /// MCP server process. @@ -21,7 +21,7 @@ pub struct MCPServerProcess { name: String, server_type: MCPServerType, status: Arc>, - child: Option, + child: Option, connection: Option>, server_info: Option, start_time: Option, @@ -31,6 +31,9 @@ pub struct MCPServerProcess { last_ping_time: Arc>>, last_error_message: Arc>>, message_rx: Option>, + remote_url: Option, + #[cfg(test)] + fail_next_stop: bool, } impl MCPServerProcess { @@ -51,15 +54,46 @@ impl MCPServerProcess { last_ping_time: Arc::new(RwLock::new(None)), last_error_message: Arc::new(RwLock::new(None)), message_rx: None, + remote_url: None, + #[cfg(test)] + fail_next_stop: false, } } + #[cfg(test)] + pub(crate) fn fail_next_stop_for_test(&mut self) { + self.fail_next_stop = true; + } + /// Starts the server process. pub async fn start( &mut self, command: &str, args: &[String], env: &std::collections::HashMap, + ) -> MCPRuntimeResult<()> { + self.start_in_directory(command, args, env, None).await + } + + /// Starts the server process in an explicitly selected working directory. + pub async fn start_in_directory( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + ) -> MCPRuntimeResult<()> { + self.start_with_environment_policy(command, args, env, working_directory, true) + .await + } + + pub async fn start_with_environment_policy( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + inherit_parent_environment: bool, ) -> MCPRuntimeResult<()> { info!("Starting MCP server: name={} id={}", self.name, self.id); self.set_status(MCPServerStatus::Starting).await; @@ -86,12 +120,23 @@ impl MCPServerProcess { let mut cmd = process_manager::create_tokio_command(&final_command); cmd.args(&final_args); + if !inherit_parent_environment { + cmd.env_clear(); + for key in safe_process_environment_keys() { + if let Some(value) = std::env::var_os(key) { + cmd.env(key, value); + } + } + } cmd.envs(env); + if let Some(working_directory) = working_directory { + cmd.current_dir(working_directory); + } cmd.stdin(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - let child = cmd.spawn().map_err(|e| { + let child = ProcessTreeChild::spawn(&mut cmd).await.map_err(|e| { error!( "Failed to spawn MCP server process: command={} error={}", final_command, e @@ -111,12 +156,10 @@ impl MCPServerProcess { }; let stdin = child - .stdin - .take() + .take_stdin() .ok_or_else(|| MCPRuntimeError::process("Failed to capture stdin".to_string()))?; let stdout = child - .stdout - .take() + .take_stdout() .ok_or_else(|| MCPRuntimeError::process("Failed to capture stdout".to_string()))?; let (tx, rx) = mpsc::unbounded_channel(); @@ -174,13 +217,13 @@ impl MCPServerProcess { ))); } info!( - "Starting remote MCP server: name={} id={} transport={} url={}", + "Starting remote MCP server: name={} id={} transport={}", self.name, self.id, - transport.as_str(), - url + transport.as_str() ); self.set_status(MCPServerStatus::Starting).await; + self.remote_url = Some(url.to_string()); let merged_headers = merge_mcp_remote_headers(&config.headers, &config.env); @@ -190,30 +233,37 @@ impl MCPServerProcess { &self.id, url.to_string(), merged_headers, - true, + config.remote_oauth_enabled(), ) - .await?, + .await + .map_err(|error| { + MCPRuntimeError::mcp(redact_sensitive_value(&error.to_string(), Some(url))) + })?, ); self.connection = Some(connection.clone()); self.start_time = Some(Instant::now()); if let Err(e) = self.handshake().await { + let redacted_error = redact_sensitive_value(&e.to_string(), Some(url)); error!( - "Remote MCP server handshake failed: name={} id={} url={} error={}", - self.name, self.id, url, e + "Remote MCP server handshake failed: name={} id={} error={}", + self.name, self.id, redacted_error ); self.connection = None; self.message_rx = None; self.child = None; self.server_info = None; - if is_mcp_auth_error_message(&e.to_string()) { - self.set_status_with_error(MCPServerStatus::NeedsAuth, Some(e.to_string())) - .await; + if is_mcp_auth_error_message(&redacted_error) { + self.set_status_with_error( + MCPServerStatus::NeedsAuth, + Some(redacted_error.clone()), + ) + .await; } else { - self.set_status_with_error(MCPServerStatus::Failed, Some(e.to_string())) + self.set_status_with_error(MCPServerStatus::Failed, Some(redacted_error.clone())) .await; } - return Err(e); + return Err(MCPRuntimeError::mcp(redacted_error)); } self.set_status_with_error(MCPServerStatus::Connected, None) @@ -263,13 +313,24 @@ impl MCPServerProcess { info!("Stopping MCP server: name={} id={}", self.name, self.id); self.set_status(MCPServerStatus::Stopping).await; - if let Some(mut child) = self.child.take() { - if let Err(e) = child.kill().await { - warn!( + #[cfg(test)] + if self.fail_next_stop { + self.fail_next_stop = false; + return Err(MCPRuntimeError::process("Injected MCP stop failure")); + } + + if let Some(child) = self.child.as_mut() { + if let Err(error) = child.terminate(Duration::from_millis(500)).await { + let message = format!( "Failed to kill MCP server process: name={} id={} error={}", - self.name, self.id, e + self.name, self.id, error ); + warn!("{}", message); + self.set_status_with_error(MCPServerStatus::Failed, Some(message.clone())) + .await; + return Err(MCPRuntimeError::process(message)); } + self.child = None; } self.connection = None; @@ -286,6 +347,28 @@ impl MCPServerProcess { command: &str, args: &[String], env: &std::collections::HashMap, + ) -> MCPRuntimeResult<()> { + self.restart_in_directory(command, args, env, None).await + } + + pub async fn restart_in_directory( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + ) -> MCPRuntimeResult<()> { + self.restart_with_environment_policy(command, args, env, working_directory, true) + .await + } + + pub async fn restart_with_environment_policy( + &mut self, + command: &str, + args: &[String], + env: &std::collections::HashMap, + working_directory: Option<&std::path::Path>, + inherit_parent_environment: bool, ) -> MCPRuntimeResult<()> { if self.restart_count >= self.max_restarts { error!( @@ -314,7 +397,14 @@ impl MCPServerProcess { self.stop().await?; tokio::time::sleep(Duration::from_secs(1)).await; - self.start(command, args, env).await + self.start_with_environment_policy( + command, + args, + env, + working_directory, + inherit_parent_environment, + ) + .await } /// Sets status. @@ -357,6 +447,7 @@ impl MCPServerProcess { let connection = self.connection.clone(); let interval = self.health_check_interval; let server_name = self.name.clone(); + let remote_url = self.remote_url.clone(); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); @@ -384,16 +475,18 @@ impl MCPServerProcess { *last_error_message.write().await = None; } Err(e) => { + let redacted_error = + redact_sensitive_value(&e.to_string(), remote_url.as_deref()); warn!( "Health check failed: server_name={} error={}", - server_name, e + server_name, redacted_error ); - if is_mcp_auth_error_message(&e.to_string()) { + if is_mcp_auth_error_message(&redacted_error) { *status.write().await = MCPServerStatus::NeedsAuth; } else { *status.write().await = MCPServerStatus::Reconnecting; } - *last_error_message.write().await = Some(e.to_string()); + *last_error_message.write().await = Some(redacted_error); } } } else { @@ -424,10 +517,61 @@ impl MCPServerProcess { } } +#[cfg(windows)] +fn safe_process_environment_keys() -> &'static [&'static str] { + &[ + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "PATH", + "TEMP", + "TMP", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + ] +} + +fn redact_sensitive_value(message: &str, sensitive_value: Option<&str>) -> String { + sensitive_value + .filter(|value| !value.is_empty()) + .map(|value| message.replace(value, "")) + .unwrap_or_else(|| message.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{redact_sensitive_value, safe_process_environment_keys}; + + #[test] + fn isolated_environment_excludes_common_secret_variables() { + let keys = safe_process_environment_keys(); + assert!(!keys.contains(&"OPENAI_API_KEY")); + assert!(!keys.contains(&"ANTHROPIC_API_KEY")); + assert!(keys.contains(&"PATH")); + } + + #[test] + fn remote_errors_do_not_expose_the_configured_url() { + let url = "https://mcp.example.test/path?token=secret"; + let error = format!("request to {url} failed"); + let redacted = redact_sensitive_value(&error, Some(url)); + + assert!(!redacted.contains("secret")); + assert!(redacted.contains("")); + } +} + +#[cfg(not(windows))] +fn safe_process_environment_keys() -> &'static [&'static str] { + &[ + "PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL", + ] +} + impl Drop for MCPServerProcess { fn drop(&mut self) { - if let Some(mut child) = self.child.take() { - let _ = child.start_kill(); - } + self.child.take(); } } diff --git a/src/crates/services/services-integrations/src/mcp/server/registry.rs b/src/crates/services/services-integrations/src/mcp/server/registry.rs index 9818426415..6aa8476ec0 100644 --- a/src/crates/services/services-integrations/src/mcp/server/registry.rs +++ b/src/crates/services/services-integrations/src/mcp/server/registry.rs @@ -83,14 +83,13 @@ impl MCPServerRegistry { /// Unregisters a server. pub async fn unregister(&self, server_id: &str) -> MCPRuntimeResult<()> { let _lifecycle_guard = self.lifecycle_lock.lock().await; - let process = { - let mut servers = self.servers.write().await; - servers.remove(server_id) - }; + let process = self.servers.read().await.get(server_id).cloned(); if let Some(process) = process { let mut proc = process.write().await; proc.stop().await?; + drop(proc); + self.servers.write().await.remove(server_id); info!("Unregistered MCP server: id={}", server_id); Ok(()) } else { @@ -151,17 +150,32 @@ impl MCPServerRegistry { /// Clears the registry. pub async fn clear(&self) -> MCPRuntimeResult<()> { let _lifecycle_guard = self.lifecycle_lock.lock().await; - let processes = { - let mut servers = self.servers.write().await; - servers - .drain() - .map(|(_, process)| process) - .collect::>() - }; + let processes = self + .servers + .read() + .await + .iter() + .map(|(server_id, process)| (server_id.clone(), process.clone())) + .collect::>(); + let mut first_error = None; - for process in processes { + for (server_id, process) in processes { let mut proc = process.write().await; - let _ = proc.stop().await; + match proc.stop().await { + Ok(()) => { + drop(proc); + self.servers.write().await.remove(&server_id); + } + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + + if let Some(error) = first_error { + return Err(error); } info!("Cleared MCP server registry"); @@ -192,6 +206,8 @@ mod tests { command: Some("node".to_string()), args: Vec::new(), env: HashMap::new(), + working_directory: None, + inherit_parent_environment: None, headers: HashMap::new(), url: None, auto_start: false, @@ -200,6 +216,7 @@ mod tests { capabilities: Vec::new(), settings: HashMap::new(), oauth: None, + oauth_enabled: None, xaa: None, } } @@ -221,6 +238,61 @@ mod tests { assert!(!registry.contains("test").await); } + #[tokio::test] + async fn failed_unregister_retains_process_ownership_for_retry() { + let registry = MCPServerRegistry::new(); + let config = local_config("retryable-stop"); + registry.register(&config).await.unwrap(); + let process = registry + .get_process(&config.id) + .await + .expect("registered process should exist"); + process.write().await.fail_next_stop_for_test(); + + let error = registry + .unregister(&config.id) + .await + .expect_err("injected stop failure must propagate"); + + assert_eq!(error.kind(), MCPRuntimeErrorKind::Process); + assert!(registry.contains(&config.id).await); + registry + .unregister(&config.id) + .await + .expect("retained process should be retryable"); + assert!(!registry.contains(&config.id).await); + } + + #[tokio::test] + async fn failed_clear_retains_only_processes_that_still_need_cleanup() { + let registry = MCPServerRegistry::new(); + let retryable = local_config("retryable-clear"); + let stoppable = local_config("stoppable-clear"); + registry.register(&retryable).await.unwrap(); + registry.register(&stoppable).await.unwrap(); + registry + .get_process(&retryable.id) + .await + .expect("retryable process") + .write() + .await + .fail_next_stop_for_test(); + + let error = registry + .clear() + .await + .expect_err("one failed stop must fail registry clear"); + + assert_eq!(error.kind(), MCPRuntimeErrorKind::Process); + assert!(registry.contains(&retryable.id).await); + assert!(!registry.contains(&stoppable.id).await); + registry + .clear() + .await + .expect("retained process should retry"); + assert!(registry.get_all_server_ids().await.is_empty()); + } + #[tokio::test] async fn registry_reports_validation_and_missing_errors_as_runtime_errors() { let registry = MCPServerRegistry::new(); diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 0318b315cb..8232e1f665 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -15,9 +15,11 @@ pub mod encryption; mod lan; mod mobile_web_upload; mod ngrok; +mod page_upload; pub mod pairing; pub mod qr_generator; pub mod relay_client; +mod relay_http; pub mod session_store; pub mod sync_state; @@ -48,6 +50,14 @@ pub use mobile_web_upload::upload_mobile_web_to_relay; pub use ngrok::{ cleanup_all_ngrok, detect_running_ngrok, is_ngrok_available, start_ngrok_tunnel, NgrokTunnel, }; +pub use page_upload::{ + create_page_open_link_on_relay, delete_page_from_relay, delete_page_version_on_relay, + deploy_page_version_on_relay, join_relay_url, list_page_versions_from_relay, + list_pages_from_relay, publish_page_content_on_relay, publish_page_to_relay, + save_page_version_from_inline_files, save_page_version_to_relay, unpublish_page_from_relay, + update_page_on_relay, PageContentPublishResult, PageInfo, PageOpenLink, PagePublishResult, + PageSaveVersionResult, PageVersionInfo, +}; pub use pairing::{PairingChallenge, PairingProtocol, PairingResponse, PairingState, QrPayload}; pub use qr_generator::QrGenerator; pub use relay_client::{ @@ -100,8 +110,10 @@ pub fn build_remote_session_create_request( session_name: session_name.into(), agent_type: agent_type.into(), workspace_path: workspace_path.map(Into::into), + workspace_id: None, remote_connection_id: workspace_identity.remote_connection_id, remote_ssh_host: workspace_identity.remote_ssh_host, + model_id: None, metadata, } } @@ -321,7 +333,6 @@ pub enum RemoteDialogQueuePriority { pub struct RemoteDialogSubmissionPolicy { pub source: RemoteConnectSubmissionSource, pub queue_priority: RemoteDialogQueuePriority, - pub skip_tool_confirmation: bool, } impl RemoteDialogSubmissionPolicy { @@ -329,7 +340,6 @@ impl RemoteDialogSubmissionPolicy { Self { source, queue_priority: RemoteDialogQueuePriority::Normal, - skip_tool_confirmation: true, } } } @@ -532,14 +542,13 @@ fn is_remote_absolute_workspace_path(path: &str) -> bool { pub fn resolve_remote_workspace_path(raw: &str, workspace_root: Option<&Path>) -> Option { let stripped = strip_remote_workspace_path_prefix(raw); - - if is_remote_absolute_workspace_path(stripped) { - return Some(PathBuf::from(stripped)); - } - let workspace_root = workspace_root?; let canonical_root = std::fs::canonicalize(workspace_root).ok()?; - let candidate = canonical_root.join(stripped); + let candidate = if is_remote_absolute_workspace_path(stripped) { + PathBuf::from(stripped) + } else { + canonical_root.join(stripped) + }; let canonical_candidate = std::fs::canonicalize(candidate).ok()?; if canonical_candidate.starts_with(&canonical_root) { @@ -1465,12 +1474,6 @@ where #[async_trait::async_trait] pub trait RemoteInteractionRuntimeHost: Send + Sync { - async fn confirm_tool( - &self, - tool_id: &str, - updated_input: Option, - ) -> Result<(), String>; - async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String>; } @@ -1483,24 +1486,6 @@ where H: RemoteInteractionRuntimeHost + ?Sized, { match command { - RemoteCommand::ConfirmTool { - tool_id, - updated_input, - } => remote_interaction_accepted_response( - "confirm_tool", - tool_id.clone(), - host.confirm_tool(tool_id, updated_input.clone()).await, - ), - RemoteCommand::RejectTool { tool_id, reason } => { - let reject_reason = reason - .clone() - .unwrap_or_else(|| "User rejected".to_string()); - remote_interaction_accepted_response( - "reject_tool", - tool_id.clone(), - host.reject_tool(tool_id, reject_reason).await, - ) - } RemoteCommand::CancelTool { tool_id, reason } => { let cancel_reason = reason .clone() @@ -2103,14 +2088,6 @@ pub enum RemoteCommand { DeleteSession { session_id: String, }, - ConfirmTool { - tool_id: String, - updated_input: Option, - }, - RejectTool { - tool_id: String, - reason: Option, - }, CancelTool { tool_id: String, reason: Option, @@ -2436,10 +2413,9 @@ where | RemoteCommand::ReadFileChunk { .. } | RemoteCommand::GetFileInfo { .. } => host.handle_workspace_file_command(command).await, - RemoteCommand::ConfirmTool { .. } - | RemoteCommand::RejectTool { .. } - | RemoteCommand::CancelTool { .. } - | RemoteCommand::AnswerQuestion { .. } => host.handle_interaction_command(command).await, + RemoteCommand::CancelTool { .. } | RemoteCommand::AnswerQuestion { .. } => { + host.handle_interaction_command(command).await + } RemoteCommand::SendMessage { session_id, @@ -3713,28 +3689,10 @@ mod tests { } #[derive(Default)] - struct FakeInteractionHost { - rejected: Mutex>, - } + struct FakeInteractionHost; #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for FakeInteractionHost { - async fn confirm_tool( - &self, - _tool_id: &str, - _updated_input: Option, - ) -> Result<(), String> { - Ok(()) - } - - async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { - self.rejected - .lock() - .unwrap() - .push((tool_id.to_string(), reason)); - Ok(()) - } - async fn cancel_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { Ok(()) } @@ -3747,30 +3705,4 @@ mod tests { Ok(()) } } - - #[tokio::test] - async fn remote_interaction_handler_preserves_default_reject_reason() { - let host = FakeInteractionHost::default(); - - let response = handle_remote_interaction_command( - &host, - &RemoteCommand::RejectTool { - tool_id: "tool-1".to_string(), - reason: None, - }, - ) - .await; - - assert_eq!( - response, - RemoteResponse::InteractionAccepted { - action: "reject_tool".to_string(), - target_id: "tool-1".to_string(), - } - ); - assert_eq!( - host.rejected.lock().unwrap().as_slice(), - [("tool-1".to_string(), "User rejected".to_string())] - ); - } } diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index 134b5804c0..c7063206fd 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -14,6 +14,9 @@ use serde::{Deserialize, Serialize}; use crate::remote_connect::device::DeviceIdentity; use crate::remote_connect::encryption::{decrypt, encrypt}; +use crate::remote_connect::relay_http::{ + relay_http_client, send_with_retry, BufferedRelayResponse, RelayHttpRetry, +}; /// Salt length for provisioning (client-side account-blob export). Only used /// by tests until that tooling lands. @@ -47,6 +50,17 @@ impl Default for KdfParams { impl KdfParams { fn build(&self) -> Result> { + // KDF parameters come from a remote server. Bound them before Argon2 + // allocates memory so a malicious or corrupted relay cannot force the + // client into multi-gigabyte allocation or an excessive CPU loop. + if !(8 * 1024..=256 * 1024).contains(&self.m) + || !(1..=10).contains(&self.t) + || !(1..=16).contains(&self.p) + { + return Err(anyhow!( + "argon2 parameters are outside the supported safety range" + )); + } let params = Params::new(self.m, self.t, self.p, Some(MASTER_KEY_LEN)) .map_err(|e| anyhow!("invalid argon2 params: {e}"))?; Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) @@ -169,6 +183,8 @@ struct ChallengeResponse { kdf_salt: String, argon2_params: String, wrapped_master_key: String, + #[serde(default)] + login_idempotency_supported: bool, } #[derive(Deserialize)] @@ -183,6 +199,54 @@ pub struct AccountClient { http: reqwest::Client, } +/// Check whether an account/relay error message indicates an invalid or +/// expired account token (relay auth failure). Shared by Desktop, CLI, and +/// the settings sync engine so they all react to the same relay wording. +pub fn error_indicates_expired_token(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("http 401") + || lower.contains("unauthorized") + || lower.contains("invalid or expired token") + || lower.contains("relay auth error") +} + +/// Parse a user/config supplied relay base URL at the shared transport +/// boundary. Paths are allowed for reverse-proxy prefixes; credentials, +/// query strings, fragments, and non-HTTP schemes are not. +pub fn validate_relay_base_url(relay_url: &str) -> Result { + let url = reqwest::Url::parse(relay_url.trim()) + .map_err(|error| anyhow!("invalid relay URL: {error}"))?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(anyhow!( + "relay URL must be an http(s) server address without credentials, query, or fragment" + )); + } + Ok(url) +} + +/// Build the Relay WebSocket endpoint from the same validated base URL used by +/// account HTTP requests. This preserves reverse-proxy prefixes while avoiding +/// `//ws` when a user- or config-supplied base URL has a trailing slash. +pub fn build_relay_websocket_url(relay_url: &str) -> Result { + let mut url = validate_relay_base_url(relay_url)?; + let websocket_scheme = match url.scheme() { + "https" => "wss", + "http" => "ws", + _ => unreachable!("validate_relay_base_url accepts only http(s) schemes"), + }; + url.set_scheme(websocket_scheme) + .map_err(|_| anyhow!("failed to convert relay URL to WebSocket scheme"))?; + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}/ws")); + Ok(url.to_string()) +} + impl Default for AccountClient { fn default() -> Self { Self::new() @@ -192,23 +256,30 @@ impl Default for AccountClient { impl AccountClient { pub fn new() -> Self { Self { - // Sync uploads full encrypted session bundles; 30s is too short for - // large conversations over slower links. - http: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()), + http: relay_http_client(), } } - fn endpoint(relay_url: &str, path: &str) -> String { - let base = relay_url.trim_end_matches('/'); - format!("{base}{path}") + fn endpoint(relay_url: &str, path: &str) -> Result { + let mut url = validate_relay_base_url(relay_url)?; + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}{path}")); + Ok(url) } /// Map a non-2xx relay response into a human-readable error. async fn into_error(resp: reqwest::Response) -> anyhow::Error { let status = resp.status(); + let body = resp.bytes().await.unwrap_or_default(); + Self::error_from_response_parts(status, &body) + } + + fn into_buffered_error(resp: BufferedRelayResponse) -> anyhow::Error { + let (status, body) = resp.into_parts(); + Self::error_from_response_parts(status, &body) + } + + fn error_from_response_parts(status: reqwest::StatusCode, body: &[u8]) -> anyhow::Error { if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE { return anyhow!( "relay returned HTTP 413 Payload Too Large \ @@ -217,7 +288,12 @@ impl AccountClient { client_max_body_size)" ); } - match resp.json::().await { + if status == reqwest::StatusCode::INSUFFICIENT_STORAGE { + return anyhow!( + "relay returned HTTP 507 Insufficient Storage (the configured account or asset quota is full)" + ); + } + match serde_json::from_slice::(body) { Ok(body) => { let msg = body.error; if let Some(retry) = body.retry_after_secs { @@ -230,60 +306,116 @@ impl AccountClient { } } - /// Log in to an existing account. Fetches the KDF challenge, derives the KEK - /// locally, unwraps the master key (GCM failure ⇒ wrong password), then - /// verifies the password hash with the relay to obtain a token. - pub async fn login( + /// Fetch the login challenge and unwrap the master key locally. + /// Does not call `/api/auth/login` and does not mint a token. + async fn unwrap_master_key_for_credentials( &self, relay_url: &str, username: &str, password: &str, - device: &DeviceIdentity, - ) -> Result { - // 1. Challenge: fetch salts + wrapped master key. + ) -> Result<([u8; MASTER_KEY_LEN], ChallengeResponse)> { + if username.trim().is_empty() + || username.len() > 128 + || password.is_empty() + || password.len() > 1024 + { + return Err(anyhow!("invalid account credential fields")); + } let challenge_req = serde_json::json!({ "username": username }); - let resp = self - .http - .post(Self::endpoint(relay_url, "/api/auth/login/challenge")) - .json(&challenge_req) - .send() - .await?; + let resp = send_with_retry( + "login challenge", + self.http + .post(Self::endpoint(relay_url, "/api/auth/login/challenge")?) + .json(&challenge_req), + RelayHttpRetry::SafeRead, + ) + .await?; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } let challenge: ChallengeResponse = resp.json().await?; let salt = BASE64 .decode(&challenge.salt) .map_err(|e| anyhow!("b64 decode salt: {e}"))?; - let kdf_salt = BASE64 - .decode(&challenge.kdf_salt) - .map_err(|e| anyhow!("b64 decode kdf_salt: {e}"))?; + if salt.len() != SALT_LEN { + return Err(anyhow!("invalid account salt length")); + } let params: KdfParams = serde_json::from_str(&challenge.argon2_params) .map_err(|e| anyhow!("parse argon2_params: {e}"))?; - // 2. Derive KEK and unwrap the master key. A failure here means the - // password is wrong (the GCM tag won't verify). + // GCM tag failure means the password is wrong. let kek = derive_kek(password, &salt, ¶ms)?; let master_key = unwrap_master_key(&kek, &challenge.wrapped_master_key) .map_err(|_| anyhow!("invalid username or password"))?; + Ok((master_key, challenge)) + } + + /// Verify username/password against an existing session without minting a + /// new relay token. Uses the same challenge + KEK unwrap path as `login`. + /// Succeeds only when the unwrapped master key matches `expected_master_key`. + pub async fn verify_password_for_master_key( + &self, + relay_url: &str, + username: &str, + password: &str, + expected_master_key: &[u8; MASTER_KEY_LEN], + ) -> Result<()> { + let (master_key, _) = self + .unwrap_master_key_for_credentials(relay_url, username, password) + .await?; + if master_key != *expected_master_key { + return Err(anyhow!("invalid username or password")); + } + Ok(()) + } - // 3. Derive the server-verifiable hash and submit it. + /// Log in to an existing account. Fetches the KDF challenge, derives the KEK + /// locally, unwraps the master key (GCM failure ⇒ wrong password), then + /// verifies the password hash with the relay to obtain a token. + pub async fn login( + &self, + relay_url: &str, + username: &str, + password: &str, + device: &DeviceIdentity, + ) -> Result { + let (master_key, challenge) = self + .unwrap_master_key_for_credentials(relay_url, username, password) + .await?; + + let kdf_salt = BASE64 + .decode(&challenge.kdf_salt) + .map_err(|e| anyhow!("b64 decode kdf_salt: {e}"))?; + if kdf_salt.len() != SALT_LEN { + return Err(anyhow!("invalid account KDF salt length")); + } + let params: KdfParams = serde_json::from_str(&challenge.argon2_params) + .map_err(|e| anyhow!("parse argon2_params: {e}"))?; + + // Derive the server-verifiable hash and submit it. let password_hash = derive_password_hash(password, &kdf_salt, ¶ms)?; let login_req = serde_json::json!({ "username": username, "password_hash": password_hash, "device_id": device.device_id, "device_name": device.device_name, + "request_id": uuid::Uuid::new_v4().to_string(), }); - let resp = self + let request = self .http - .post(Self::endpoint(relay_url, "/api/auth/login")) - .json(&login_req) - .send() - .await?; + .post(Self::endpoint(relay_url, "/api/auth/login")?) + .json(&login_req); + // New relays persist request_id with the issued token, making an + // ambiguous response safe to replay. Older relays omit the challenge + // capability flag, so retain one-shot login behavior with them. + let resp = if challenge.login_idempotency_supported { + send_with_retry("account login", request, RelayHttpRetry::IdempotentWrite).await? + } else { + send_with_retry("account login", request, RelayHttpRetry::SingleAttempt).await? + }; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } let auth: AuthResponse = resp.json().await?; Ok(AccountSession { @@ -330,12 +462,50 @@ impl AccountClient { /// Upload (or replace) a single encrypted session blob for this device. /// Returns the `version` written into the upsert body (client-generated LWW clock). + /// + /// When the relay reports HTTP 507 (account sync quota full), evict the + /// oldest remote session backup (excluding this id) and retry. This keeps + /// recent local backups flowing on relays that do not yet auto-evict. pub async fn upload_session( &self, relay_url: &str, session: &AccountSession, session_id: &str, plaintext: &str, + ) -> Result { + const MAX_QUOTA_RELIEF_ATTEMPTS: usize = 32; + let mut last_quota_error: Option = None; + for _ in 0..MAX_QUOTA_RELIEF_ATTEMPTS { + match self + .upload_session_once(relay_url, session, session_id, plaintext) + .await + { + Ok(version) => return Ok(version), + Err(err) if is_insufficient_storage_error(&err) => { + last_quota_error = Some(err); + if !self + .evict_oldest_remote_session(relay_url, session, session_id) + .await? + { + break; + } + } + Err(err) => return Err(err), + } + } + Err(last_quota_error.unwrap_or_else(|| { + anyhow!( + "relay returned HTTP 507 Insufficient Storage (the configured account or asset quota is full)" + ) + })) + } + + async fn upload_session_once( + &self, + relay_url: &str, + session: &AccountSession, + session_id: &str, + plaintext: &str, ) -> Result { let (data, nonce) = Self::seal(session, plaintext)?; let version = chrono::Utc::now().timestamp_millis(); @@ -345,19 +515,49 @@ impl AccountClient { "nonce": nonce, "version": version, }); - let resp = self - .http - .post(Self::endpoint(relay_url, "/api/sync/sessions")) - .header("Authorization", Self::auth_header(session)) - .json(&body) - .send() - .await?; + let resp = send_with_retry( + "upload session", + self.http + .post(Self::endpoint(relay_url, "/api/sync/sessions")?) + .header("Authorization", Self::auth_header(session)) + .json(&body), + RelayHttpRetry::IdempotentWrite, + ) + .await?; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } Ok(version) } + /// Soft-delete the oldest remote sync session other than `keep_session_id`. + /// Returns `true` when a session was removed. + async fn evict_oldest_remote_session( + &self, + relay_url: &str, + session: &AccountSession, + keep_session_id: &str, + ) -> Result { + let mut entries = self + .list_session_entries(relay_url, session, 0) + .await? + .into_iter() + .filter(|entry| entry.session_id != keep_session_id) + .collect::>(); + if entries.is_empty() { + return Ok(false); + } + entries.sort_by(|a, b| { + a.version + .cmp(&b.version) + .then_with(|| a.session_id.cmp(&b.session_id)) + }); + let victim = entries.remove(0); + self.delete_session(relay_url, session, &victim.session_id) + .await?; + Ok(true) + } + /// List encrypted session blobs updated after `since` without decrypting. /// `session_id` / `version` are plaintext metadata; payload stays sealed. pub async fn list_session_entries( @@ -366,19 +566,19 @@ impl AccountClient { session: &AccountSession, since: i64, ) -> Result> { - let url = format!( - "{}?since={}", - Self::endpoint(relay_url, "/api/sync/sessions"), - since.max(0) - ); - let resp = self - .http - .get(url) - .header("Authorization", Self::auth_header(session)) - .send() - .await?; + let mut url = Self::endpoint(relay_url, "/api/sync/sessions")?; + url.query_pairs_mut() + .append_pair("since", &since.max(0).to_string()); + let resp = send_with_retry( + "list sessions", + self.http + .get(url) + .header("Authorization", Self::auth_header(session)), + RelayHttpRetry::SafeRead, + ) + .await?; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } let bytes = resp.bytes().await?; let payload: SessionsListResponse = serde_json::from_slice(&bytes) @@ -430,20 +630,22 @@ impl AccountClient { session: &AccountSession, session_id: &str, ) -> Result> { - let resp = self - .http - .get(Self::endpoint( - relay_url, - &format!("/api/sync/sessions/{session_id}"), - )) - .header("Authorization", Self::auth_header(session)) - .send() - .await?; + let resp = send_with_retry( + "fetch session", + self.http + .get(Self::endpoint( + relay_url, + &format!("/api/sync/sessions/{}", urlencoding::encode(session_id)), + )?) + .header("Authorization", Self::auth_header(session)), + RelayHttpRetry::SafeRead, + ) + .await?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } let entry: SessionEntry = resp.json().await?; let plaintext = Self::open(session, &entry.encrypted_data, &entry.nonce)?; @@ -461,45 +663,52 @@ impl AccountClient { session: &AccountSession, session_id: &str, ) -> Result<()> { - let resp = self - .http - .delete(Self::endpoint( - relay_url, - &format!("/api/sync/sessions/{session_id}"), - )) - .header("Authorization", Self::auth_header(session)) - .send() - .await?; + let resp = send_with_retry( + "delete session", + self.http + .delete(Self::endpoint( + relay_url, + &format!("/api/sync/sessions/{}", urlencoding::encode(session_id)), + )?) + .header("Authorization", Self::auth_header(session)), + RelayHttpRetry::IdempotentWrite, + ) + .await?; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } Ok(()) } /// Upload an encrypted settings blob (keyed by the user, not per-device). + /// Returns the version sent with the upload so callers can record a sync + /// cursor that matches what the relay actually stored. pub async fn upload_settings( &self, relay_url: &str, session: &AccountSession, plaintext: &str, - ) -> Result<()> { + ) -> Result { let (data, nonce) = Self::seal(session, plaintext)?; + let version = chrono::Utc::now().timestamp_millis(); let body = serde_json::json!({ "encrypted_data": data, "nonce": nonce, - "version": chrono::Utc::now().timestamp_millis(), + "version": version, }); - let resp = self - .http - .post(Self::endpoint(relay_url, "/api/sync/settings")) - .header("Authorization", Self::auth_header(session)) - .json(&body) - .send() - .await?; + let resp = send_with_retry( + "upload settings", + self.http + .post(Self::endpoint(relay_url, "/api/sync/settings")?) + .header("Authorization", Self::auth_header(session)) + .json(&body), + RelayHttpRetry::IdempotentWrite, + ) + .await?; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } - Ok(()) + Ok(version) } /// Fetch and decrypt the settings blob. Returns `None` if no settings exist. @@ -522,17 +731,19 @@ impl AccountClient { relay_url: &str, session: &AccountSession, ) -> Result> { - let resp = self - .http - .get(Self::endpoint(relay_url, "/api/sync/settings")) - .header("Authorization", Self::auth_header(session)) - .send() - .await?; + let resp = send_with_retry( + "fetch settings", + self.http + .get(Self::endpoint(relay_url, "/api/sync/settings")?) + .header("Authorization", Self::auth_header(session)), + RelayHttpRetry::SafeRead, + ) + .await?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } let opt: Option = resp.json().await?; match opt { @@ -557,14 +768,16 @@ impl AccountClient { relay_url: &str, session: &AccountSession, ) -> Result { - let resp = self - .http - .post(Self::endpoint(relay_url, "/api/auth/delegate")) - .header("Authorization", Self::auth_header(session)) - .send() - .await?; + let resp = send_with_retry( + "delegate account token", + self.http + .post(Self::endpoint(relay_url, "/api/auth/delegate")?) + .header("Authorization", Self::auth_header(session)), + RelayHttpRetry::IdempotentWrite, + ) + .await?; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } let auth: AuthResponse = resp.json().await?; Ok(DelegateToken { @@ -575,12 +788,14 @@ impl AccountClient { /// Revoke the account token on the relay (server-side logout). pub async fn revoke_token(&self, relay_url: &str, session: &AccountSession) -> Result<()> { - let resp = self - .http - .post(Self::endpoint(relay_url, "/api/auth/logout")) - .header("Authorization", Self::auth_header(session)) - .send() - .await?; + let resp = send_with_retry( + "revoke account token", + self.http + .post(Self::endpoint(relay_url, "/api/auth/logout")?) + .header("Authorization", Self::auth_header(session)), + RelayHttpRetry::IdempotentWrite, + ) + .await?; if !resp.status().is_success() { // Non-fatal — best-effort revocation log::warn!("revoke_token: relay returned {}", resp.status()); @@ -595,14 +810,16 @@ impl AccountClient { relay_url: &str, session: &AccountSession, ) -> Result> { - let resp = self - .http - .get(Self::endpoint(relay_url, "/api/devices")) - .header("Authorization", Self::auth_header(session)) - .send() - .await?; + let resp = send_with_retry( + "list devices", + self.http + .get(Self::endpoint(relay_url, "/api/devices")?) + .header("Authorization", Self::auth_header(session)), + RelayHttpRetry::SafeRead, + ) + .await?; if !resp.status().is_success() { - return Err(Self::into_error(resp).await); + return Err(Self::into_buffered_error(resp)); } let entries: Vec = resp.json().await?; Ok(entries @@ -629,8 +846,8 @@ impl AccountClient { .http .delete(Self::endpoint( relay_url, - &format!("/api/devices/{target_device_id}"), - )) + &format!("/api/devices/{}", urlencoding::encode(target_device_id)), + )?) .header("Authorization", Self::auth_header(session)) .send() .await?; @@ -662,8 +879,8 @@ impl AccountClient { .http .post(Self::endpoint( relay_url, - &format!("/api/devices/{target_device_id}/rpc"), - )) + &format!("/api/devices/{}/rpc", urlencoding::encode(target_device_id)), + )?) .header("Authorization", Self::auth_header(session)) .json(&body) .send() @@ -718,6 +935,13 @@ struct SessionEntry { version: i64, } +fn is_insufficient_storage_error(err: &anyhow::Error) -> bool { + let msg = err.to_string(); + msg.contains("HTTP 507") + || msg.contains("Insufficient Storage") + || msg.to_ascii_lowercase().contains("quota is full") +} + /// Decrypted session sync blob with relay version metadata. #[derive(Debug, Clone)] pub struct FetchedSession { @@ -794,5 +1018,72 @@ mod tests { assert_eq!(parsed.m, params.m); assert_eq!(parsed.t, params.t); assert_eq!(parsed.p, params.p); + assert!(params.build().is_ok()); + assert!(KdfParams { + m: u32::MAX, + t: 3, + p: 4, + } + .build() + .is_err()); + } + + #[test] + fn password_hash_matches_the_browser_argon2id_vector() { + let params = KdfParams { + m: 8 * 1024, + t: 1, + p: 1, + }; + let salt = std::array::from_fn::<_, SALT_LEN, _>(|index| index as u8); + assert_eq!( + derive_password_hash("correct horse battery staple", &salt, ¶ms).unwrap(), + "mu73UxPlhfSSwzxeEtgumtJTt914Yy1Tfomc1O3deJw=" + ); + } + + #[test] + fn detects_insufficient_storage_errors_for_quota_relief() { + assert!(is_insufficient_storage_error(&anyhow!( + "relay returned HTTP 507 Insufficient Storage (the configured account or asset quota is full)" + ))); + assert!(!is_insufficient_storage_error(&anyhow!( + "relay returned HTTP 413 Payload Too Large" + ))); + } + + #[test] + fn relay_endpoint_accepts_http_servers_and_rejects_ambiguous_urls() { + let endpoint = + AccountClient::endpoint("https://relay.example.com/prefix/", "/api/devices").unwrap(); + assert_eq!( + endpoint.as_str(), + "https://relay.example.com/prefix/api/devices" + ); + + for invalid in [ + "file:///tmp/relay", + "https://user:pass@relay.example.com", + "https://relay.example.com?target=other", + "https://relay.example.com/#fragment", + ] { + assert!(AccountClient::endpoint(invalid, "/api/devices").is_err()); + } + } + + #[test] + fn relay_websocket_endpoint_normalizes_root_and_prefixed_urls() { + assert_eq!( + build_relay_websocket_url("https://relay.example.com/").unwrap(), + "wss://relay.example.com/ws" + ); + assert_eq!( + build_relay_websocket_url("https://relay.example.com/prefix/").unwrap(), + "wss://relay.example.com/prefix/ws" + ); + assert_eq!( + build_relay_websocket_url("http://127.0.0.1:3000/relay").unwrap(), + "ws://127.0.0.1:3000/relay/ws" + ); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs index 4cb12ffc5e..4f452af384 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs @@ -104,6 +104,7 @@ pub struct BotStrings { pub resume_no_sessions: &'static str, pub resume_page_label: &'static str, + pub resume_untitled: &'static str, pub resume_msg_count_zero: &'static str, pub resume_msg_count_one: &'static str, pub resume_msg_count_many_fmt: &'static str, @@ -241,7 +242,7 @@ const STRINGS_ZH: BotStrings = BotStrings { footer_reply_or_menu: "回复编号,或发送 /menu 返回主菜单", footer_reply_workspace: "回复工作区编号,或发送 0 返回", footer_reply_assistant: "回复助理编号,或发送 0 返回", - footer_reply_session_or_next: "回复会话编号;发送 0 查看下一页或返回", + footer_reply_session_or_next: "回复会话编号;发送 0 查看下一页,或发送 /menu 返回", footer_reply_session: "回复会话编号,或发送 0 返回", footer_reply_model: "回复模型编号,或发送 0 返回", footer_question_single: "回复单个选项编号;发送 /menu 退出", @@ -261,6 +262,7 @@ const STRINGS_ZH: BotStrings = BotStrings { resume_no_sessions: "当前还没有会话,可以发送 /new 直接新建。", resume_page_label: "会话历史", + resume_untitled: "未命名会话", resume_msg_count_zero: "无消息", resume_msg_count_one: "1 条消息", resume_msg_count_many_fmt: "{n} 条消息", @@ -336,7 +338,7 @@ const STRINGS_ZH: BotStrings = BotStrings { devices_send_ok: "消息已发送到目标设备。", devices_send_failed_prefix: "发送到设备失败:", devices_local: "本地设备", - devices_pick_to_switch: "回复序号切换设备,之后所有操作在该设备上执行", + devices_pick_to_switch: "回复序号切换设备,之后所有操作在该设备上执行;发送 0 返回", devices_switched_to: "已切换到远程设备", devices_switched_local: "已切换回本地设备", devices_remote_prefix: "远程设备", @@ -397,7 +399,7 @@ const STRINGS_ZH_TW: BotStrings = BotStrings { footer_reply_or_menu: "回覆編號,或發送 /menu 返回主菜單", footer_reply_workspace: "回覆工作區編號,或發送 0 返回", footer_reply_assistant: "回覆助理編號,或發送 0 返回", - footer_reply_session_or_next: "回覆會話編號;發送 0 查看下一頁或返回", + footer_reply_session_or_next: "回覆會話編號;發送 0 查看下一頁,或發送 /menu 返回", footer_reply_session: "回覆會話編號,或發送 0 返回", footer_reply_model: "回覆模型編號,或發送 0 返回", footer_question_single: "回覆單個選項編號;發送 /menu 退出", @@ -417,6 +419,7 @@ const STRINGS_ZH_TW: BotStrings = BotStrings { resume_no_sessions: "當前還沒有會話,可以發送 /new 直接新建。", resume_page_label: "會話歷史", + resume_untitled: "未命名會話", resume_msg_count_zero: "無消息", resume_msg_count_one: "1 條消息", resume_msg_count_many_fmt: "{n} 條消息", @@ -492,7 +495,7 @@ const STRINGS_ZH_TW: BotStrings = BotStrings { devices_send_ok: "訊息已傳送到目標裝置。", devices_send_failed_prefix: "傳送到裝置失敗:", devices_local: "本地裝置", - devices_pick_to_switch: "回覆序號切換裝置,之後所有操作在該裝置上執行", + devices_pick_to_switch: "回覆序號切換裝置,之後所有操作在該裝置上執行;發送 0 返回", devices_switched_to: "已切換到遠端裝置", devices_switched_local: "已切換回本地裝置", devices_remote_prefix: "遠端裝置", @@ -553,7 +556,8 @@ Open Remote Connect in BitFun Desktop and send the 6-digit pairing code here to footer_reply_or_menu: "Reply with a number, or send /menu to return.", footer_reply_workspace: "Reply with a workspace number, or 0 to go back.", footer_reply_assistant: "Reply with an assistant number, or 0 to go back.", - footer_reply_session_or_next: "Reply with a session number; send 0 for next page or to go back.", + footer_reply_session_or_next: + "Reply with a session number; send 0 for the next page, or /menu to return.", footer_reply_session: "Reply with a session number, or 0 to go back.", footer_reply_model: "Reply with a model number, or 0 to go back.", footer_question_single: "Reply with one option number; send /menu to exit.", @@ -573,6 +577,7 @@ Open Remote Connect in BitFun Desktop and send the 6-digit pairing code here to resume_no_sessions: "No sessions yet. Send /new to create one.", resume_page_label: "Sessions", + resume_untitled: "Untitled session", resume_msg_count_zero: "no messages", resume_msg_count_one: "1 message", resume_msg_count_many_fmt: "{n} messages", @@ -648,7 +653,8 @@ Open Remote Connect in BitFun Desktop and send the 6-digit pairing code here to devices_send_ok: "Message sent to the target device.", devices_send_failed_prefix: "Failed to send to device: ", devices_local: "Local device", - devices_pick_to_switch: "Reply with a number to switch — all subsequent commands run on that device", + devices_pick_to_switch: + "Reply with a number to switch — all subsequent commands run on that device; send 0 to return", devices_switched_to: "Switched to remote device", devices_switched_local: "Switched back to local device", devices_remote_prefix: "Remote device", diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/menu.rs b/src/crates/services/services-integrations/src/remote_connect/bot/menu.rs index 3407d4523d..26b815aa94 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/menu.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/menu.rs @@ -69,8 +69,7 @@ pub struct MenuView { /// Optional secondary body text. pub body: Option, pub items: Vec, - /// Optional footer hint shown below items (telegram/feishu silently - /// drop this; weixin shows it as the last text line). + /// Optional footer hint shown below the body/items. pub footer_hint: Option, /// Whether text-only renderers should append `items` as numbered lines. /// Some selection prompts include richer numbered lines in `body` while @@ -95,6 +94,16 @@ impl MenuView { self } + /// Set a body that already contains the user-visible numbered choices. + /// + /// Native-button adapters still receive `items`, while plain-text + /// adapters must not append those same choices a second time. + pub fn with_numbered_body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self.render_items_in_plain_text = false; + self + } + pub fn with_items(mut self, items: Vec) -> Self { self.items = items; self @@ -143,11 +152,7 @@ impl MenuView { if i > 0 { out.push('\n'); } - if item.command.starts_with('/') { - out.push_str(&format!("{}. {} {}", i + 1, item.label, item.command)); - } else { - out.push_str(&format!("{}. {}", i + 1, item.label)); - } + out.push_str(&format!("{}. {}", i + 1, item.label)); } } let hint = self @@ -221,4 +226,32 @@ mod tests { fn text_block_never_returns_empty_message() { assert_eq!(MenuView::plain("").render_text_block(), " "); } + + #[test] + fn numbered_body_is_not_duplicated_in_plain_text() { + let view = MenuView::plain("Sessions") + .with_numbered_body("1. Alpha\n2. Beta") + .with_items(vec![ + MenuItem::default("Alpha", "1"), + MenuItem::default("Beta", "2"), + ]); + + let text = view.render_plain_text(BotLanguage::EnUS); + assert_eq!(text.matches("Alpha").count(), 1); + assert_eq!(text.matches("Beta").count(), 1); + } + + #[test] + fn plain_text_options_do_not_expose_internal_commands() { + let view = MenuView::plain("Menu").with_items(vec![ + MenuItem::primary("New", "/new"), + MenuItem::default("Resume", "/resume"), + ]); + + let text = view.render_plain_text(BotLanguage::EnUS); + assert!(text.contains("1. New")); + assert!(text.contains("2. Resume")); + assert!(!text.contains("/new")); + assert!(!text.contains("/resume")); + } } diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs index a1af531d10..15a9d0a7da 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs @@ -12,6 +12,7 @@ pub mod telegram; pub mod weixin; use serde::{Deserialize, Serialize}; +use std::sync::{Mutex as StdMutex, OnceLock}; pub use command::{parse_command, BotCommand}; pub use feishu::{FeishuBotApi, FeishuConfig}; @@ -486,6 +487,14 @@ pub fn auto_push_failed_message(language: BotLanguage, file_name: &str, err: &st const REMOTE_CONNECT_PERSISTENCE_FILENAME: &str = "remote_connect_persistence.json"; const LEGACY_BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; +static BOT_PERSISTENCE_LOCK: OnceLock> = OnceLock::new(); + +fn bot_persistence_lock() -> std::sync::MutexGuard<'static, ()> { + BOT_PERSISTENCE_LOCK + .get_or_init(|| StdMutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} fn bot_persistence_path() -> Option { dirs::home_dir().map(|home| { @@ -494,17 +503,31 @@ fn bot_persistence_path() -> Option { }) } +fn bot_persistence_backup_path(path: &std::path::Path) -> std::path::PathBuf { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| REMOTE_CONNECT_PERSISTENCE_FILENAME.to_string()); + path.with_file_name(format!("{file_name}.bak")) +} + fn legacy_bot_persistence_path() -> Option { dirs::home_dir().map(|home| home.join(".bitfun").join(LEGACY_BOT_PERSISTENCE_FILENAME)) } -pub fn load_bot_persistence() -> BotPersistenceData { +fn load_bot_persistence_unlocked() -> BotPersistenceData { let Some(path) = bot_persistence_path() else { return BotPersistenceData::default(); }; match std::fs::read_to_string(&path) { Ok(data) => serde_json::from_str(&data).unwrap_or_default(), Err(_) => { + // A backup without the canonical file means the process stopped + // during the Windows replace dance. Fail closed instead of + // restoring the legacy file or a pre-clear account context. + if bot_persistence_backup_path(&path).exists() { + return BotPersistenceData::default(); + } let Some(legacy_path) = legacy_bot_persistence_path() else { return BotPersistenceData::default(); }; @@ -516,23 +539,128 @@ pub fn load_bot_persistence() -> BotPersistenceData { } } -pub fn save_bot_persistence(data: &BotPersistenceData) { +fn write_bot_persistence_atomic(path: &std::path::Path, json: &[u8]) -> std::io::Result<()> { + use std::io::Write; + + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "bot persistence path has no parent directory", + ) + })?; + std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| REMOTE_CONNECT_PERSISTENCE_FILENAME.to_string()); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let temp_path = parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), nonce)); + let backup_path = bot_persistence_backup_path(path); + + let write_result = (|| -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut temp = options.open(&temp_path)?; + temp.write_all(json)?; + temp.sync_all()?; + drop(temp); + + // POSIX rename replaces atomically. On Windows std::fs::rename does + // not replace an existing destination, so retain the old file as a + // rollback backup until the new file is installed. + if std::fs::rename(&temp_path, path).is_ok() { + let _ = std::fs::remove_file(&backup_path); + return Ok(()); + } + + if !path.exists() { + return std::fs::rename(&temp_path, path); + } + if backup_path.exists() { + std::fs::remove_file(&backup_path)?; + } + std::fs::rename(path, &backup_path)?; + match std::fs::rename(&temp_path, path) { + Ok(()) => { + let _ = std::fs::remove_file(&backup_path); + Ok(()) + } + Err(install_error) => { + let _ = std::fs::rename(&backup_path, path); + Err(install_error) + } + } + })(); + + if write_result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + write_result +} + +fn save_bot_persistence_unlocked(data: &BotPersistenceData) { let Some(path) = bot_persistence_path() else { return; }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } if let Ok(json) = serde_json::to_string_pretty(data) { - if let Err(e) = std::fs::write(&path, json) { + if let Err(e) = write_bot_persistence_atomic(&path, json.as_bytes()) { log::error!("Failed to save bot persistence: {e}"); } } } +pub fn load_bot_persistence() -> BotPersistenceData { + let _persistence = bot_persistence_lock(); + load_bot_persistence_unlocked() +} + +pub fn save_bot_persistence(data: &BotPersistenceData) { + let _persistence = bot_persistence_lock(); + save_bot_persistence_unlocked(data); +} + +/// Apply one read-modify-write transaction to the shared bot persistence +/// document. Individual platform loops run concurrently, so separate +/// `load`/`save` calls can otherwise overwrite another bot's update. +pub fn update_bot_persistence(update: impl FnOnce(&mut BotPersistenceData) -> R) -> R { + let _persistence = bot_persistence_lock(); + let mut data = load_bot_persistence_unlocked(); + let result = update(&mut data); + save_bot_persistence_unlocked(&data); + result +} + +/// Remove persisted account-device context for every bot connection. This is +/// called as part of the desktop account epoch transition even when a bot is +/// between stop/restore and therefore has no live chat-state map to clear. +pub fn clear_persisted_bot_account_contexts() { + update_bot_persistence(|data| { + for connection in &mut data.connections { + connection.chat_state.clear_delegated_identity(); + } + }); +} + #[cfg(test)] mod tests { - use super::{collect_auto_push_files, extract_downloadable_file_paths, resolve_workspace_path}; + use super::{ + bot_persistence_backup_path, collect_auto_push_files, extract_downloadable_file_paths, + resolve_workspace_path, write_bot_persistence_atomic, + }; fn make_temp_workspace() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { let base = std::env::temp_dir().join(format!( @@ -547,6 +675,34 @@ mod tests { (base, workspace, report) } + #[test] + fn bot_persistence_replaces_existing_file_via_same_directory_temp() { + let root = tempfile::tempdir().expect("temporary persistence directory"); + let path = root.path().join("remote_connect_persistence.json"); + write_bot_persistence_atomic(&path, br#"{"version":1}"#) + .expect("initial persistence write"); + write_bot_persistence_atomic(&path, br#"{"version":2}"#) + .expect("replacement persistence write"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read installed persistence"), + r#"{"version":2}"# + ); + assert!(!bot_persistence_backup_path(&path).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path) + .expect("persistence metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + #[test] fn resolves_relative_paths_within_workspace_root() { let (base, workspace, report) = make_temp_workspace(); diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/state.rs b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs index 0ac61463ea..8e98df3351 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/state.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/state.rs @@ -168,6 +168,13 @@ pub struct BotChatState { /// Cleared by `/devices` → pick "local" or selecting an offline device. #[serde(skip, default)] pub active_remote_device: Option, + /// Records that the serializable workspace/session fields currently belong + /// to an account-routed device. The device target itself and delegated + /// credentials intentionally stay in memory only, but this marker must be + /// persisted so a restart cannot reinterpret a remote path/session as a + /// local desktop context. + #[serde(default, skip_serializing_if = "is_false")] + pub account_remote_context: bool, } /// A remote device the bot has switched to. All subsequent bot commands @@ -197,6 +204,7 @@ impl BotChatState { delegated_token: None, delegated_master_key: None, active_remote_device: None, + account_remote_context: false, } } @@ -252,6 +260,71 @@ impl BotChatState { pub fn has_delegated_identity(&self) -> bool { self.delegated_token.is_some() && self.delegated_master_key.is_some() } + + /// Switch command routing to an account device and fence every workspace + /// or session selection made for the previous target. + pub fn select_remote_device(&mut self, target: RemoteDeviceTarget) { + self.clear_device_scoped_context(); + self.active_remote_device = Some(target); + self.account_remote_context = true; + } + + /// Return command routing to this desktop without leaking a remote + /// workspace/session into local execution. + pub fn select_local_device(&mut self) { + let was_remote = self.active_remote_device.take().is_some() || self.account_remote_context; + self.account_remote_context = false; + if was_remote { + self.clear_device_scoped_context(); + } + } + + /// Sanitize state loaded from disk. Delegated authority is never restored, + /// and a persisted remote-context marker makes its workspace/session + /// selections invalid on a fresh process. + pub fn prepare_for_restore(&mut self) { + if self.account_remote_context { + self.clear_delegated_identity(); + } + } + + /// Remove every account-bound value when the desktop account changes. + /// A bot chat can outlive many desktop login sessions, so retaining these + /// fields would either keep controlling the previous account or replay an + /// old device selection with the replacement account's credentials. + pub fn clear_delegated_identity(&mut self) { + self.relay_url = None; + self.delegated_token = None; + self.delegated_master_key = None; + + let was_remote = self.active_remote_device.take().is_some() || self.account_remote_context; + self.account_remote_context = false; + let was_selecting_device = matches!( + self.pending_action, + Some(PendingAction::SelectDevice { .. }) + ); + if was_remote { + // Workspace/session selections made while targeting a remote + // device are owned by that account and must not fall through to + // local execution after the remote target is cleared. + self.clear_device_scoped_context(); + } + if was_remote || was_selecting_device { + self.clear_pending(); + self.last_menu_commands.clear(); + } + } + + fn clear_device_scoped_context(&mut self) { + self.current_workspace = None; + self.current_assistant = None; + self.current_assistant_name = None; + self.current_session_id = None; + } +} + +fn is_false(value: &bool) -> bool { + !*value } fn now_secs() -> i64 { @@ -406,6 +479,95 @@ mod tests { assert_eq!(state.pending_invalid_count, 0); } + #[test] + fn clearing_delegated_identity_drops_remote_account_state() { + let mut state = BotChatState::new("chat".into()); + state.relay_url = Some("https://relay-a.example".into()); + state.set_delegated_identity("token-a".into(), vec![7; 32]); + state.select_remote_device(RemoteDeviceTarget { + device_id: "device-a".into(), + device_name: "Device A".into(), + }); + state.current_workspace = Some(BotWorkspaceRef::local("/remote/a")); + state.current_assistant = Some("/remote/a".into()); + state.current_assistant_name = Some("Remote A".into()); + state.current_session_id = Some("session-a".into()); + state.set_pending(PendingAction::SelectSession { + options: vec![], + page: 0, + has_more: false, + }); + state.last_menu_commands = vec!["/sessions".into()]; + + state.clear_delegated_identity(); + + assert!(state.relay_url.is_none()); + assert!(!state.has_delegated_identity()); + assert!(state.active_remote_device.is_none()); + assert!(state.current_workspace.is_none()); + assert!(state.current_assistant.is_none()); + assert!(state.current_assistant_name.is_none()); + assert!(state.current_session_id.is_none()); + assert!(state.pending_action.is_none()); + assert!(state.last_menu_commands.is_empty()); + } + + #[test] + fn persisted_remote_context_is_sanitized_during_restore() { + let mut state = BotChatState::new("chat".into()); + state.paired = true; + state.select_remote_device(RemoteDeviceTarget { + device_id: "device-a".into(), + device_name: "Device A".into(), + }); + state.current_workspace = Some(BotWorkspaceRef::local("/remote/a")); + state.current_assistant = Some("/remote/a".into()); + state.current_assistant_name = Some("Remote A".into()); + state.current_session_id = Some("session-a".into()); + + let encoded = serde_json::to_string(&state).expect("state should serialize"); + assert!(encoded.contains("account_remote_context")); + assert!(!encoded.contains("device-a")); + + let mut restored: BotChatState = + serde_json::from_str(&encoded).expect("state should deserialize"); + assert!(restored.active_remote_device.is_none()); + assert!(restored.account_remote_context); + + restored.prepare_for_restore(); + + assert!(!restored.account_remote_context); + assert!(restored.current_workspace.is_none()); + assert!(restored.current_assistant.is_none()); + assert!(restored.current_assistant_name.is_none()); + assert!(restored.current_session_id.is_none()); + + let reencoded = serde_json::to_string(&restored).expect("restored state should serialize"); + let recovered_again: BotChatState = + serde_json::from_str(&reencoded).expect("clean state should deserialize"); + assert!(!recovered_again.account_remote_context); + assert!(recovered_again.current_workspace.is_none()); + assert!(recovered_again.current_session_id.is_none()); + } + + #[test] + fn switching_back_to_local_drops_remote_context() { + let mut state = BotChatState::new("chat".into()); + state.select_remote_device(RemoteDeviceTarget { + device_id: "device-a".into(), + device_name: "Device A".into(), + }); + state.current_workspace = Some(BotWorkspaceRef::local("/remote/a")); + state.current_session_id = Some("session-a".into()); + + state.select_local_device(); + + assert!(state.active_remote_device.is_none()); + assert!(!state.account_remote_context); + assert!(state.current_workspace.is_none()); + assert!(state.current_session_id.is_none()); + } + #[test] fn current_workspace_deserializes_legacy_path_string() { let state: BotChatState = serde_json::from_value(serde_json::json!({ diff --git a/src/crates/services/services-integrations/src/remote_connect/device.rs b/src/crates/services/services-integrations/src/remote_connect/device.rs index a7058d7e43..8b53a990de 100644 --- a/src/crates/services/services-integrations/src/remote_connect/device.rs +++ b/src/crates/services/services-integrations/src/remote_connect/device.rs @@ -1,12 +1,16 @@ -//! Device identity for Remote Connect pairing. +//! Device identity for Remote Connect pairing and account device routing. //! -//! Generates a stable `device_id` from `SHA-256(hostname + MAC address)`. -//! Falls back gracefully when MAC or hostname are unavailable. +//! `device_id` is generated once and persisted under `~/.bitfun/device_identity.json`. +//! Hostname/MAC are refreshed for display only — they must not rewrite `device_id`, +//! because macOS private Wi‑Fi addresses and interface order make MAC unstable. -use anyhow::Result; +use std::path::PathBuf; +use std::sync::Mutex; + +use anyhow::{anyhow, Context, Result}; use sha2::{Digest, Sha256}; -/// Represents a device's identity used for pairing. +/// Represents a device's identity used for pairing and account routing. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DeviceIdentity { pub device_id: String, @@ -14,27 +18,136 @@ pub struct DeviceIdentity { pub mac_address: String, } +static CACHED_IDENTITY: Mutex> = Mutex::new(None); + +#[cfg(test)] +thread_local! { + static TEST_IDENTITY_PATH: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + impl DeviceIdentity { - /// Build the device identity from the current machine. + /// Load the stable machine identity (persisted), creating it on first use. pub fn from_current_machine() -> Result { - let device_name = get_hostname(); - let mac_address = get_mac_address(); - - let mut hasher = Sha256::new(); - hasher.update(device_name.as_bytes()); - hasher.update(b":"); - hasher.update(mac_address.as_bytes()); - let hash = hasher.finalize(); - let device_id = hash[..16] - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); - - Ok(Self { + if let Ok(guard) = CACHED_IDENTITY.lock() { + if let Some(cached) = guard.as_ref() { + let mut identity = cached.clone(); + identity.device_name = get_hostname(); + identity.mac_address = get_mac_address(); + return Ok(identity); + } + } + + let mut identity = load_persisted()?.unwrap_or_else(compute_initial_identity); + identity.device_name = get_hostname(); + identity.mac_address = get_mac_address(); + save_persisted(&identity)?; + cache_identity(identity.clone()); + Ok(identity) + } + + /// Align the local identity with the account-bound `device_id` from a + /// login token / `AuthOk`. Needed when a prior MAC-derived id drifted + /// while an existing session still authenticates as the old id. + pub fn adopt_account_device_id(device_id: &str) -> Result { + let device_id = device_id.trim(); + if !is_valid_device_id(device_id) { + return Err(anyhow!("invalid account device_id")); + } + + let mut identity = Self::from_current_machine()?; + if identity.device_id == device_id { + return Ok(identity); + } + + log::info!( + "Adopting account device_id {} (was {})", device_id, - device_name, - mac_address, - }) + identity.device_id + ); + identity.device_id = device_id.to_string(); + identity.device_name = get_hostname(); + identity.mac_address = get_mac_address(); + save_persisted(&identity)?; + cache_identity(identity.clone()); + Ok(identity) + } +} + +fn is_valid_device_id(device_id: &str) -> bool { + device_id.len() == 32 && device_id.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn compute_initial_identity() -> DeviceIdentity { + let device_name = get_hostname(); + let mac_address = get_mac_address(); + + let mut hasher = Sha256::new(); + hasher.update(device_name.as_bytes()); + hasher.update(b":"); + hasher.update(mac_address.as_bytes()); + let hash = hasher.finalize(); + let device_id = hash[..16] + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + + DeviceIdentity { + device_id, + device_name, + mac_address, + } +} + +fn identity_file_path() -> Result { + #[cfg(test)] + { + let override_path = TEST_IDENTITY_PATH.with(|cell| cell.borrow().clone()); + if let Some(path) = override_path { + return Ok(path); + } + } + + if let Ok(path) = std::env::var("BITFUN_DEVICE_IDENTITY_PATH") { + let path = path.trim(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + + let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; + Ok(home.join(".bitfun").join("device_identity.json")) +} + +fn load_persisted() -> Result> { + let path = identity_file_path()?; + let json = match std::fs::read_to_string(&path) { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(anyhow!("read device identity: {e}").context(path.display().to_string())) + } + }; + let identity: DeviceIdentity = + serde_json::from_str(&json).context("parse device identity file")?; + if !is_valid_device_id(&identity.device_id) { + return Err(anyhow!("persisted device_id is invalid")); + } + Ok(Some(identity)) +} + +fn save_persisted(identity: &DeviceIdentity) -> Result<()> { + let path = identity_file_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).context("create device identity dir")?; + } + let json = serde_json::to_string_pretty(identity).context("serialize device identity")?; + std::fs::write(&path, json).context("write device identity file")?; + Ok(()) +} + +fn cache_identity(identity: DeviceIdentity) { + if let Ok(mut guard) = CACHED_IDENTITY.lock() { + *guard = Some(identity); } } @@ -52,19 +165,84 @@ fn get_mac_address() -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn with_temp_identity_path(f: F) { + let _guard = TEST_LOCK.lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "bitfun-device-identity-{}-{}.json", + std::process::id(), + uuid_like() + )); + let _ = std::fs::remove_file(&path); + TEST_IDENTITY_PATH.with(|cell| *cell.borrow_mut() = Some(path.clone())); + if let Ok(mut cache) = CACHED_IDENTITY.lock() { + *cache = None; + } + f(); + let _ = std::fs::remove_file(&path); + TEST_IDENTITY_PATH.with(|cell| *cell.borrow_mut() = None); + if let Ok(mut cache) = CACHED_IDENTITY.lock() { + *cache = None; + } + } + + fn uuid_like() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") + } #[test] fn test_device_identity_creation() { - let identity = DeviceIdentity::from_current_machine().unwrap(); - assert!(!identity.device_id.is_empty()); - assert!(!identity.device_name.is_empty()); - assert_eq!(identity.device_id.len(), 32); // 16 bytes hex = 32 chars + with_temp_identity_path(|| { + let identity = DeviceIdentity::from_current_machine().unwrap(); + assert!(!identity.device_id.is_empty()); + assert!(!identity.device_name.is_empty()); + assert_eq!(identity.device_id.len(), 32); + }); } #[test] - fn test_device_identity_stable() { - let id1 = DeviceIdentity::from_current_machine().unwrap(); - let id2 = DeviceIdentity::from_current_machine().unwrap(); - assert_eq!(id1.device_id, id2.device_id); + fn test_device_identity_stable_across_calls() { + with_temp_identity_path(|| { + let id1 = DeviceIdentity::from_current_machine().unwrap(); + let id2 = DeviceIdentity::from_current_machine().unwrap(); + assert_eq!(id1.device_id, id2.device_id); + }); + } + + #[test] + fn test_device_identity_persists_across_cache_clear() { + with_temp_identity_path(|| { + let id1 = DeviceIdentity::from_current_machine().unwrap(); + if let Ok(mut cache) = CACHED_IDENTITY.lock() { + *cache = None; + } + let id2 = DeviceIdentity::from_current_machine().unwrap(); + assert_eq!(id1.device_id, id2.device_id); + }); + } + + #[test] + fn test_adopt_account_device_id_rewrites_persisted_id() { + with_temp_identity_path(|| { + let original = DeviceIdentity::from_current_machine().unwrap(); + let adopted_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let adopted = DeviceIdentity::adopt_account_device_id(adopted_id).unwrap(); + assert_eq!(adopted.device_id, adopted_id); + assert_ne!(adopted.device_id, original.device_id); + + if let Ok(mut cache) = CACHED_IDENTITY.lock() { + *cache = None; + } + let reloaded = DeviceIdentity::from_current_machine().unwrap(); + assert_eq!(reloaded.device_id, adopted_id); + }); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs new file mode 100644 index 0000000000..18b74f8112 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs @@ -0,0 +1,1268 @@ +//! BitFun Page incremental upload client (Save Version → Deploy). + +use anyhow::{anyhow, Result}; +use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::remote_connect::relay_http::{relay_http_client, send_with_retry, RelayHttpRetry}; + +const MAX_UPLOAD_BATCH_BASE64_BYTES: usize = 256 * 1024; +const MAX_PAGE_BYTES: u64 = 100 * 1024 * 1024; +const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; +const MAX_PAGE_FILES: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PageUploadManifestEntry { + path: String, + hash: String, + size: u64, +} + +#[derive(Debug)] +struct CollectedPageFile { + rel_path: String, + content: Vec, + hash: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UploadSessionMode { + ManifestBound, + LegacyRelay, +} + +fn validate_upload_session_echo( + response: &serde_json::Value, + expected_upload_id: &str, +) -> Result { + match response + .get("upload_id") + .and_then(serde_json::Value::as_str) + { + Some(actual) if actual == expected_upload_id => Ok(UploadSessionMode::ManifestBound), + Some(_) => Err(anyhow!("check-files returned a mismatched upload session")), + // Relays predating manifest-bound upload sessions ignore the request's + // unknown `upload_id` field and omit it from the response. Their upload + // and freeze DTOs likewise ignore the extra field, so retaining the + // legacy flow preserves rolling-upgrade compatibility. + None => Ok(UploadSessionMode::LegacyRelay), + } +} + +fn validate_generation_intent_echo( + response: &serde_json::Value, + expected_generation: Option<&str>, + create: bool, +) -> Result { + match (response.get("expected_generation"), response.get("create")) { + // Relays with upload-id sessions but predating the Page generation + // protocol omit both fields. Their DTOs ignore the extra request + // fields throughout upload/freeze, so this remains a valid rolling + // upgrade path (without claiming generation protection). + (None, None) => Ok(false), + (Some(actual_generation), Some(actual_create)) => { + let expected_generation = expected_generation + .map_or(serde_json::Value::Null, |generation| { + serde_json::Value::String(generation.to_string()) + }); + if actual_generation == &expected_generation && actual_create.as_bool() == Some(create) + { + Ok(true) + } else { + Err(anyhow!( + "check-files returned a mismatched Page generation intent" + )) + } + } + // A partial echo is neither an old contract nor a trustworthy new one. + _ => Err(anyhow!( + "check-files returned an incomplete Page generation intent" + )), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageInfo { + pub slug: String, + #[serde(default)] + pub generation: String, + pub visibility: String, + pub title: String, + pub file_count: i64, + pub total_bytes: i64, + pub created_at: i64, + pub updated_at: i64, + pub url_path: String, + #[serde(default)] + pub preview_url_path: Option, + #[serde(default)] + pub deployed_version_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageVersionInfo { + #[serde(default)] + pub generation: String, + pub version_id: String, + pub title: String, + pub file_count: i64, + pub total_bytes: i64, + pub has_worker: bool, + pub note: String, + pub created_at: i64, + pub deployed: bool, + pub preview_url_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageOpenLink { + pub open_url: String, + pub page_url: String, + pub expires_in_seconds: u64, +} + +#[derive(Debug, Deserialize)] +struct PageOpenLinkRelayResponse { + open_url_path: String, + #[serde(default)] + page_url_path: String, + expires_in_seconds: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageSaveVersionResult { + pub slug: String, + pub generation: String, + pub visibility: String, + pub title: String, + pub version_id: String, + pub file_count: u64, + pub total_bytes: u64, + pub has_worker: bool, + pub preview_url_path: String, + pub deployed: bool, +} + +/// Result of Agent/session publish: save version, optionally deploy to production. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageContentPublishResult { + pub slug: String, + pub generation: String, + pub visibility: String, + pub title: String, + pub version_id: String, + pub file_count: u64, + pub total_bytes: u64, + pub has_worker: bool, + pub preview_url_path: String, + pub deployed: bool, + #[serde(default)] + pub url_path: String, + #[serde(default)] + pub deployed_version_id: Option, + /// Absolute production URL (`{relay}{url_path}`), empty when not deployed. + #[serde(default)] + pub url: String, + /// Absolute preview URL (`{relay}{preview_url_path}`). + #[serde(default)] + pub preview_url: String, +} + +/// Backward-compatible alias used by older callers; now means save version only. +pub type PagePublishResult = PageSaveVersionResult; + +/// Save a new immutable version from a local directory (does not deploy). +pub async fn save_page_version_to_relay( + relay_url: &str, + token: &str, + web_dir: &str, + slug: &str, + visibility: &str, + title: Option<&str>, + note: Option<&str>, +) -> Result { + let all_files = collect_page_files(Path::new(web_dir))?; + save_page_version_from_collected_files( + relay_url, token, slug, visibility, title, note, all_files, + ) + .await +} + +/// Save a new immutable version from inline path→UTF-8 content map (does not deploy). +pub async fn save_page_version_from_inline_files( + relay_url: &str, + token: &str, + slug: &str, + visibility: &str, + title: Option<&str>, + note: Option<&str>, + files: &HashMap, +) -> Result { + let all_files = collect_inline_page_files(files)?; + save_page_version_from_collected_files( + relay_url, token, slug, visibility, title, note, all_files, + ) + .await +} + +/// Save (and optionally deploy) a page from either a local directory or inline files. +/// +/// Exactly one of `directory` / `files` must be provided. +pub async fn publish_page_content_on_relay( + relay_url: &str, + token: &str, + slug: &str, + visibility: &str, + title: Option<&str>, + note: Option<&str>, + deploy: bool, + directory: Option<&str>, + files: Option<&HashMap>, +) -> Result { + let saved = match (directory, files) { + (Some(dir), None) => { + save_page_version_to_relay(relay_url, token, dir, slug, visibility, title, note).await? + } + (None, Some(map)) => { + save_page_version_from_inline_files( + relay_url, token, slug, visibility, title, note, map, + ) + .await? + } + (Some(_), Some(_)) => { + return Err(anyhow!("provide either directory or files, not both")); + } + (None, None) => { + return Err(anyhow!("either directory or files is required")); + } + }; + + if !deploy { + let preview_url = join_relay_url(relay_url, &saved.preview_url_path); + return Ok(PageContentPublishResult { + slug: saved.slug, + generation: saved.generation, + visibility: saved.visibility, + title: saved.title, + version_id: saved.version_id, + file_count: saved.file_count, + total_bytes: saved.total_bytes, + has_worker: saved.has_worker, + preview_url_path: saved.preview_url_path, + deployed: false, + url_path: String::new(), + deployed_version_id: None, + url: String::new(), + preview_url, + }); + } + + let info = deploy_page_version_on_relay( + relay_url, + token, + &saved.slug, + &saved.version_id, + &saved.generation, + ) + .await + .map_err(|e| { + anyhow!( + "deploy failed: {e}. Version {} was saved on the relay but is not live; \ + retry deploying version {} instead of re-publishing from scratch", + saved.version_id, + saved.version_id + ) + })?; + let preview_url = join_relay_url(relay_url, &saved.preview_url_path); + let url = join_relay_url(relay_url, &info.url_path); + Ok(PageContentPublishResult { + slug: saved.slug, + generation: info.generation, + visibility: info.visibility, + title: info.title, + version_id: saved.version_id.clone(), + file_count: saved.file_count, + total_bytes: saved.total_bytes, + has_worker: saved.has_worker, + preview_url_path: saved.preview_url_path, + deployed: true, + url_path: info.url_path, + deployed_version_id: info.deployed_version_id.or(Some(saved.version_id)), + url, + preview_url, + }) +} + +/// Join account relay base URL with a page path into an absolute URL. +pub fn join_relay_url(relay_url: &str, path: &str) -> String { + let path = path.trim(); + if path.is_empty() { + return String::new(); + } + if path.starts_with("http://") || path.starts_with("https://") { + return path.to_string(); + } + let base = relay_url.trim().trim_end_matches('/'); + if base.is_empty() { + return path.to_string(); + } + if path.starts_with('/') { + format!("{base}{path}") + } else { + format!("{base}/{path}") + } +} + +async fn save_page_version_from_collected_files( + relay_url: &str, + token: &str, + slug: &str, + visibility: &str, + title: Option<&str>, + note: Option<&str>, + all_files: Vec, +) -> Result { + validate_slug(slug)?; + validate_visibility(visibility)?; + + let total_bytes: u64 = all_files.iter().map(|f| f.content.len() as u64).sum(); + if total_bytes > MAX_PAGE_BYTES { + return Err(anyhow!( + "page exceeds size limit ({total_bytes} > {MAX_PAGE_BYTES} bytes)" + )); + } + + let title = title + .map(str::trim) + .filter(|t| !t.is_empty()) + .unwrap_or(slug) + .to_string(); + + info!( + "Collected {} page files ({} bytes) for slug {slug}", + all_files.len(), + total_bytes + ); + + let client = relay_http_client(); + let relay_base = relay_url.trim_end_matches('/'); + let auth = format!("Bearer {token}"); + + let manifest: Vec = all_files + .iter() + .map(|f| PageUploadManifestEntry { + path: f.rel_path.clone(), + hash: f.hash.clone(), + size: f.content.len() as u64, + }) + .collect(); + let upload_id = uuid::Uuid::new_v4().simple().to_string(); + let existing_page = list_pages_from_relay(relay_url, token) + .await? + .into_iter() + .find(|page| page.slug == slug); + let create = existing_page.is_none(); + let expected_generation = existing_page + .as_ref() + .map(|page| page.generation.as_str()) + .filter(|generation| !generation.is_empty()) + .map(str::to_string); + + let check_url = format!("{relay_base}/api/pages/check-files"); + let check_resp = send_with_retry( + "check page files", + client + .post(&check_url) + .header("Authorization", &auth) + .json(&serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "files": manifest, + })) + .timeout(std::time::Duration::from_secs(30)), + RelayHttpRetry::IdempotentWrite, + ) + .await + .map_err(|e| anyhow!("check-files request failed: {e}"))?; + if !check_resp.status().is_success() { + let status = check_resp.status(); + let body = check_resp.text().await.unwrap_or_default(); + return Err(anyhow!("check-files failed: HTTP {status} — {body}")); + } + let check_body: serde_json::Value = check_resp + .json() + .await + .map_err(|e| anyhow!("parse check-files response: {e}"))?; + let freeze_idempotency_supported = check_body["freeze_idempotency_supported"] + .as_bool() + .unwrap_or(false); + let upload_mode = validate_upload_session_echo(&check_body, &upload_id)?; + if upload_mode == UploadSessionMode::LegacyRelay { + warn!( + "Page relay does not support manifest-bound upload sessions; using the legacy upload flow" + ); + } else if !validate_generation_intent_echo(&check_body, expected_generation.as_deref(), create)? + { + warn!( + "Page relay does not support generation-bound uploads; using its legacy generation flow" + ); + } + let needed: Vec = check_body["needed"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + if !needed.is_empty() { + upload_needed_page_files( + &client, + relay_base, + &auth, + slug, + &upload_id, + expected_generation.as_deref(), + create, + &title, + visibility, + &all_files, + &needed, + ) + .await?; + } else { + // Touch draft metadata with empty finalize batch. + post_upload_batch( + &client, + &format!("{relay_base}/api/pages/upload-files"), + &auth, + slug, + &upload_id, + expected_generation.as_deref(), + create, + &title, + visibility, + &HashMap::new(), + 0, + true, + ) + .await?; + } + + let freeze_url = format!("{relay_base}/api/pages/{slug}/versions"); + let freeze_request = client + .post(&freeze_url) + .header("Authorization", &auth) + .json(&serde_json::json!({ + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "title": title, + "note": note.unwrap_or(""), + })) + .timeout(std::time::Duration::from_secs(30)); + let freeze_policy = if freeze_idempotency_supported { + RelayHttpRetry::IdempotentWrite + } else { + RelayHttpRetry::SingleAttempt + }; + let freeze_resp = send_with_retry("freeze page version", freeze_request, freeze_policy) + .await + .map_err(|e| anyhow!("freeze version failed: {e}"))?; + if !freeze_resp.status().is_success() { + let status = freeze_resp.status(); + let body = freeze_resp.text().await.unwrap_or_default(); + return Err(anyhow!("freeze version failed: HTTP {status} — {body}")); + } + let version: PageVersionInfo = freeze_resp + .json() + .await + .map_err(|e| anyhow!("parse freeze response: {e}"))?; + + Ok(PageSaveVersionResult { + slug: slug.to_string(), + generation: version.generation, + visibility: visibility.to_string(), + title, + version_id: version.version_id, + file_count: all_files.len() as u64, + total_bytes, + has_worker: version.has_worker, + preview_url_path: version.preview_url_path, + deployed: false, + }) +} + +/// Legacy name: save version only (does not deploy). +pub async fn publish_page_to_relay( + relay_url: &str, + token: &str, + web_dir: &str, + slug: &str, + visibility: &str, + title: Option<&str>, +) -> Result { + save_page_version_to_relay(relay_url, token, web_dir, slug, visibility, title, None).await +} + +pub async fn list_pages_from_relay(relay_url: &str, token: &str) -> Result> { + let client = relay_http_client(); + let url = format!("{}/api/pages", relay_url.trim_end_matches('/')); + let resp = send_with_retry( + "list pages", + client + .get(&url) + .header("Authorization", format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(15)), + RelayHttpRetry::SafeRead, + ) + .await + .map_err(|e| anyhow!("list pages failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("list pages failed: HTTP {status} — {body}")); + } + Ok(resp + .json() + .await + .map_err(|e| anyhow!("parse list pages: {e}"))?) +} + +pub async fn list_page_versions_from_relay( + relay_url: &str, + token: &str, + slug: &str, + expected_generation: &str, +) -> Result> { + validate_slug(slug)?; + let client = relay_http_client(); + let url = format!( + "{}/api/pages/{}/versions?expected_generation={}", + relay_url.trim_end_matches('/'), + slug, + expected_generation + ); + let resp = send_with_retry( + "list page versions", + client + .get(&url) + .header("Authorization", format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(15)), + RelayHttpRetry::SafeRead, + ) + .await + .map_err(|e| anyhow!("list versions failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("list versions failed: HTTP {status} — {body}")); + } + Ok(resp + .json() + .await + .map_err(|e| anyhow!("parse list versions: {e}"))?) +} + +/// Resolve browser and canonical URLs for a production Page or immutable +/// preview. Page access authentication is performed by the Relay at the Page +/// URL; the management bearer token is never embedded in either returned URL. +pub async fn create_page_open_link_on_relay( + relay_url: &str, + token: &str, + slug: &str, + version_id: Option<&str>, + expected_generation: &str, +) -> Result { + validate_slug(slug)?; + let client = relay_http_client(); + let url = format!("{}/api/pages/{}", relay_url.trim_end_matches('/'), slug); + let resp = send_with_retry( + "create page open link", + client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "version_id": version_id, + "expected_generation": expected_generation, + })) + .timeout(std::time::Duration::from_secs(15)), + RelayHttpRetry::SafeRead, + ) + .await + .map_err(|e| anyhow!("create page open link failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!( + "create page open link failed: HTTP {status} — {body}" + )); + } + let result: PageOpenLinkRelayResponse = resp + .json() + .await + .map_err(|e| anyhow!("parse page open link response: {e}"))?; + let page_url_path = if result.page_url_path.is_empty() { + &result.open_url_path + } else { + &result.page_url_path + }; + Ok(PageOpenLink { + open_url: join_relay_url(relay_url, &result.open_url_path), + page_url: join_relay_url(relay_url, page_url_path), + expires_in_seconds: result.expires_in_seconds, + }) +} + +pub async fn deploy_page_version_on_relay( + relay_url: &str, + token: &str, + slug: &str, + version_id: &str, + expected_generation: &str, +) -> Result { + validate_slug(slug)?; + let client = relay_http_client(); + let url = format!( + "{}/api/pages/{}/deploy", + relay_url.trim_end_matches('/'), + slug + ); + let resp = send_with_retry( + "deploy page version", + client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "version_id": version_id, + "expected_generation": expected_generation, + })) + .timeout(std::time::Duration::from_secs(15)), + RelayHttpRetry::IdempotentWrite, + ) + .await + .map_err(|e| anyhow!("deploy failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("deploy failed: HTTP {status} — {body}")); + } + Ok(resp + .json() + .await + .map_err(|e| anyhow!("parse deploy: {e}"))?) +} + +pub async fn delete_page_version_on_relay( + relay_url: &str, + token: &str, + slug: &str, + version_id: &str, + expected_generation: &str, +) -> Result<()> { + validate_slug(slug)?; + let client = relay_http_client(); + let url = format!( + "{}/api/pages/{}/versions/{}?expected_generation={}", + relay_url.trim_end_matches('/'), + slug, + version_id, + expected_generation + ); + let resp = client + .delete(&url) + .header("Authorization", format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| anyhow!("delete version failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("delete version failed: HTTP {status} — {body}")); + } + Ok(()) +} + +pub async fn update_page_on_relay( + relay_url: &str, + token: &str, + slug: &str, + expected_generation: &str, + visibility: Option<&str>, + title: Option<&str>, +) -> Result { + validate_slug(slug)?; + if let Some(v) = visibility { + validate_visibility(v)?; + } + let client = relay_http_client(); + let url = format!("{}/api/pages/{}", relay_url.trim_end_matches('/'), slug); + let mut body = serde_json::Map::new(); + body.insert( + "expected_generation".into(), + serde_json::json!(expected_generation), + ); + if let Some(v) = visibility { + body.insert("visibility".into(), serde_json::json!(v)); + } + if let Some(t) = title { + body.insert("title".into(), serde_json::json!(t)); + } + let resp = send_with_retry( + "update page", + client + .patch(&url) + .header("Authorization", format!("Bearer {token}")) + .json(&body) + .timeout(std::time::Duration::from_secs(15)), + RelayHttpRetry::IdempotentWrite, + ) + .await + .map_err(|e| anyhow!("update page failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("update page failed: HTTP {status} — {body}")); + } + Ok(resp + .json() + .await + .map_err(|e| anyhow!("parse update page: {e}"))?) +} + +pub async fn unpublish_page_from_relay( + relay_url: &str, + token: &str, + slug: &str, + expected_generation: &str, +) -> Result<()> { + validate_slug(slug)?; + let client = relay_http_client(); + let url = format!( + "{}/api/pages/{}/unpublish?expected_generation={}", + relay_url.trim_end_matches('/'), + slug, + expected_generation + ); + let resp = send_with_retry( + "unpublish page", + client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(15)), + RelayHttpRetry::IdempotentWrite, + ) + .await + .map_err(|e| anyhow!("unpublish page failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("unpublish page failed: HTTP {status} — {body}")); + } + Ok(()) +} + +pub async fn delete_page_from_relay( + relay_url: &str, + token: &str, + slug: &str, + expected_generation: &str, +) -> Result<()> { + validate_slug(slug)?; + let client = relay_http_client(); + let url = format!( + "{}/api/pages/{}?expected_generation={}", + relay_url.trim_end_matches('/'), + slug, + expected_generation + ); + let resp = client + .delete(&url) + .header("Authorization", format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| anyhow!("delete page failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("delete page failed: HTTP {status} — {body}")); + } + Ok(()) +} + +fn validate_slug(slug: &str) -> Result<()> { + let bytes = slug.as_bytes(); + if bytes.is_empty() || bytes.len() > 64 { + return Err(anyhow!("invalid slug: must be 1-64 characters")); + } + let Some(first) = bytes.first() else { + return Err(anyhow!("invalid slug")); + }; + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return Err(anyhow!( + "invalid slug: must start with a lowercase letter or digit" + )); + } + if !bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') + { + return Err(anyhow!( + "invalid slug: only lowercase letters, digits, and hyphens allowed" + )); + } + Ok(()) +} + +fn validate_visibility(v: &str) -> Result<()> { + match v { + "private" | "relay" | "public" => Ok(()), + _ => Err(anyhow!( + "invalid visibility: expected private, relay, or public" + )), + } +} + +fn collect_page_files(base: &Path) -> Result> { + let base_metadata = std::fs::symlink_metadata(base) + .map_err(|_| anyhow!("page directory does not exist: {}", base.display()))?; + if base_metadata.file_type().is_symlink() || !base_metadata.is_dir() { + return Err(anyhow!("page directory does not exist: {}", base.display())); + } + let has_index = is_regular_page_source_file(&base.join("index.html"))?; + let has_worker = is_regular_page_source_file(&base.join("server").join("worker.js"))?; + if !has_index && !has_worker { + return Err(anyhow!( + "page directory must contain index.html and/or server/worker.js: {}", + base.display() + )); + } + let canonical_base = std::fs::canonicalize(base)?; + let mut all_files = Vec::new(); + collect_files_with_hash(&canonical_base, &canonical_base, &mut all_files)?; + Ok(all_files) +} + +fn collect_inline_page_files(files: &HashMap) -> Result> { + use sha2::{Digest, Sha256}; + + if files.is_empty() { + return Err(anyhow!("files map must not be empty")); + } + if files.len() > MAX_PAGE_FILES { + return Err(anyhow!( + "page exceeds file count limit ({} > {MAX_PAGE_FILES})", + files.len() + )); + } + + let has_index = files.contains_key("index.html"); + let has_worker = files.contains_key("server/worker.js"); + if !has_index && !has_worker { + return Err(anyhow!( + "files must include index.html and/or server/worker.js" + )); + } + + let mut all_files = Vec::with_capacity(files.len()); + for (raw_path, content) in files { + let rel = raw_path.replace('\\', "/"); + let rel = rel.trim_start_matches('/'); + if rel.is_empty() || rel.contains("..") || rel.starts_with('/') { + return Err(anyhow!("invalid file path: {raw_path}")); + } + if Path::new(rel).is_absolute() { + return Err(anyhow!("absolute file paths are not allowed: {raw_path}")); + } + let bytes = content.as_bytes(); + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err(anyhow!( + "file exceeds size limit: {rel} ({} > {MAX_FILE_BYTES} bytes)", + bytes.len() + )); + } + let mut hasher = Sha256::new(); + hasher.update(bytes); + let hash = format!("{:x}", hasher.finalize()); + all_files.push(CollectedPageFile { + rel_path: rel.to_string(), + content: bytes.to_vec(), + hash, + }); + } + Ok(all_files) +} + +fn collect_files_with_hash( + base: &Path, + dir: &Path, + out: &mut Vec, +) -> Result<()> { + use sha2::{Digest, Sha256}; + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_symlink() { + return Err(anyhow!( + "symbolic links are not allowed in Page sources: {}", + path.display() + )); + } + let canonical_path = std::fs::canonicalize(&path)?; + if !canonical_path.starts_with(base) { + return Err(anyhow!( + "Page source entry resolves outside its directory: {}", + path.display() + )); + } + if file_type.is_dir() { + collect_files_with_hash(base, &canonical_path, out)?; + } else if file_type.is_file() { + if out.len() >= MAX_PAGE_FILES { + return Err(anyhow!("page exceeds file count limit ({MAX_PAGE_FILES})")); + } + let rel = canonical_path + .strip_prefix(base) + .unwrap_or(&canonical_path) + .to_string_lossy() + .replace('\\', "/"); + if rel.contains("..") { + continue; + } + let content = std::fs::read(&canonical_path)?; + if content.len() as u64 > MAX_FILE_BYTES { + return Err(anyhow!( + "file exceeds size limit: {rel} ({} > {MAX_FILE_BYTES} bytes)", + content.len() + )); + } + let mut hasher = Sha256::new(); + hasher.update(&content); + let hash = format!("{:x}", hasher.finalize()); + out.push(CollectedPageFile { + rel_path: rel, + content, + hash, + }); + } else { + return Err(anyhow!( + "unsupported Page source entry type: {}", + path.display() + )); + } + } + Ok(()) +} + +fn is_regular_page_source_file(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!( + "symbolic links are not allowed in Page sources: {}", + path.display() + )), + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +#[allow(clippy::too_many_arguments)] +async fn upload_needed_page_files( + client: &reqwest::Client, + relay_base: &str, + auth: &str, + slug: &str, + upload_id: &str, + expected_generation: Option<&str>, + create: bool, + title: &str, + visibility: &str, + all_files: &[CollectedPageFile], + needed: &[String], +) -> Result<()> { + use base64::{engine::general_purpose::STANDARD as B64, Engine}; + let needed_set: HashSet<&str> = needed.iter().map(String::as_str).collect(); + let mut files_payload: Vec<(String, serde_json::Value, usize)> = Vec::new(); + for file in all_files { + if needed_set.contains(file.rel_path.as_str()) { + let encoded = B64.encode(&file.content); + let encoded_len = encoded.len(); + files_payload.push(( + file.rel_path.clone(), + serde_json::json!({ "content": encoded, "hash": file.hash }), + encoded_len, + )); + } + } + let url = format!("{relay_base}/api/pages/upload-files"); + let mut current_batch: HashMap = HashMap::new(); + let mut current_batch_b64_bytes = 0usize; + let mut batch_index = 0usize; + + for (path, entry, entry_len) in files_payload { + let should_flush = !current_batch.is_empty() + && current_batch_b64_bytes + entry_len > MAX_UPLOAD_BATCH_BASE64_BYTES; + if should_flush { + post_upload_batch( + client, + &url, + auth, + slug, + upload_id, + expected_generation, + create, + title, + visibility, + ¤t_batch, + batch_index, + false, + ) + .await?; + batch_index += 1; + current_batch = HashMap::new(); + current_batch_b64_bytes = 0; + } + current_batch.insert(path, entry); + current_batch_b64_bytes += entry_len; + } + if !current_batch.is_empty() { + post_upload_batch( + client, + &url, + auth, + slug, + upload_id, + expected_generation, + create, + title, + visibility, + ¤t_batch, + batch_index, + true, + ) + .await?; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn post_upload_batch( + client: &reqwest::Client, + url: &str, + auth: &str, + slug: &str, + upload_id: &str, + expected_generation: Option<&str>, + create: bool, + title: &str, + visibility: &str, + files: &HashMap, + batch_index: usize, + finalize: bool, +) -> Result<()> { + let resp = send_with_retry( + "upload page files", + client + .post(url) + .header("Authorization", auth) + .json(&serde_json::json!({ + "slug": slug, + "upload_id": upload_id, + "expected_generation": expected_generation, + "create": create, + "title": title, + "visibility": visibility, + "files": files, + "finalize": finalize, + })) + .timeout(std::time::Duration::from_secs(60)), + RelayHttpRetry::IdempotentWrite, + ) + .await + .map_err(|e| anyhow!("upload-files batch {batch_index}: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!( + "upload-files batch {batch_index} failed: HTTP {status} — {body}" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_rules() { + assert!(validate_slug("my-site").is_ok()); + assert!(validate_slug("Bad").is_err()); + } + + #[test] + fn join_relay_url_builds_absolute_links() { + assert_eq!( + join_relay_url("https://watchitrun.com/", "/p/user/site"), + "https://watchitrun.com/p/user/site" + ); + assert_eq!( + join_relay_url("https://watchitrun.com", "p/user/site"), + "https://watchitrun.com/p/user/site" + ); + assert_eq!( + join_relay_url("https://x.test", "https://already.example/p"), + "https://already.example/p" + ); + } + + #[test] + fn upload_session_echo_accepts_legacy_relay_but_rejects_mismatch() { + let legacy = serde_json::json!({ "needed": ["index.html"] }); + assert_eq!( + validate_upload_session_echo(&legacy, "abc").unwrap(), + UploadSessionMode::LegacyRelay + ); + + let current = serde_json::json!({ "upload_id": "abc", "needed": [] }); + assert_eq!( + validate_upload_session_echo(¤t, "abc").unwrap(), + UploadSessionMode::ManifestBound + ); + + let mismatch = serde_json::json!({ "upload_id": "other", "needed": [] }); + assert!(validate_upload_session_echo(&mismatch, "abc").is_err()); + } + + #[test] + fn generation_intent_echo_accepts_old_relay_only_when_both_fields_are_absent() { + let old_relay = serde_json::json!({ "upload_id": "abc", "needed": [] }); + assert!(!validate_generation_intent_echo(&old_relay, Some("generation-a"), false).unwrap()); + + let current = serde_json::json!({ + "upload_id": "abc", + "expected_generation": "generation-a", + "create": false, + "needed": [], + }); + assert!(validate_generation_intent_echo(¤t, Some("generation-a"), false).unwrap()); + + let create = serde_json::json!({ + "upload_id": "abc", + "expected_generation": null, + "create": true, + "needed": [], + }); + assert!(validate_generation_intent_echo(&create, None, true).unwrap()); + + let partial = serde_json::json!({ + "upload_id": "abc", + "expected_generation": "generation-a", + "needed": [], + }); + assert!(validate_generation_intent_echo(&partial, Some("generation-a"), false).is_err()); + + let mismatch = serde_json::json!({ + "upload_id": "abc", + "expected_generation": "generation-b", + "create": false, + "needed": [], + }); + assert!(validate_generation_intent_echo(&mismatch, Some("generation-a"), false).is_err()); + } + + #[test] + fn collect_allows_worker_without_index() { + let base = + std::env::temp_dir().join(format!("bitfun-page-worker-only-{}", uuid::Uuid::new_v4())); + let server = base.join("server"); + std::fs::create_dir_all(&server).unwrap(); + std::fs::write( + server.join("worker.js"), + b"function fetch(){return {status:200,body:'ok'};}", + ) + .unwrap(); + let files = collect_page_files(&base).unwrap(); + assert!(files.iter().any(|f| f.rel_path == "server/worker.js")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn inline_files_require_entry_and_reject_traversal() { + let mut ok = HashMap::new(); + ok.insert("index.html".into(), "hi".into()); + let files = collect_inline_page_files(&ok).unwrap(); + assert_eq!(files.len(), 1); + + let mut bad = HashMap::new(); + bad.insert("../secret".into(), "x".into()); + assert!(collect_inline_page_files(&bad).is_err()); + + let mut empty_entry = HashMap::new(); + empty_entry.insert("styles.css".into(), "body{}".into()); + assert!(collect_inline_page_files(&empty_entry).is_err()); + } + + #[cfg(unix)] + #[test] + fn directory_source_rejects_symlinks_to_external_files() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "bitfun-page-symlink-external-{}", + uuid::Uuid::new_v4() + )); + let page = root.join("page"); + std::fs::create_dir_all(&page).unwrap(); + std::fs::write(page.join("index.html"), b"").unwrap(); + std::fs::write(root.join("secret.txt"), b"secret").unwrap(); + symlink(root.join("secret.txt"), page.join("secret.txt")).unwrap(); + + let error = collect_page_files(&page).expect_err("external symlink must be rejected"); + assert!(error.to_string().contains("symbolic links are not allowed")); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn directory_source_rejects_symlink_loops() { + use std::os::unix::fs::symlink; + + let root = + std::env::temp_dir().join(format!("bitfun-page-symlink-loop-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("index.html"), b"").unwrap(); + symlink(&root, root.join("loop")).unwrap(); + + let error = collect_page_files(&root).expect_err("symlink loop must be rejected"); + assert!(error.to_string().contains("symbolic links are not allowed")); + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn publish_source_xor_is_enforced() { + let err = publish_page_content_on_relay( + "http://127.0.0.1:9", + "tok", + "demo", + "public", + None, + None, + false, + None, + None, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("directory or files")); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/pairing.rs b/src/crates/services/services-integrations/src/remote_connect/pairing.rs index cb061b9f62..133b9a00c3 100644 --- a/src/crates/services/services-integrations/src/remote_connect/pairing.rs +++ b/src/crates/services/services-integrations/src/remote_connect/pairing.rs @@ -13,6 +13,8 @@ use tokio::sync::RwLock; use super::device::DeviceIdentity; use super::encryption::{self, KeyPair}; +const PAIRING_CHALLENGE_TTL_SECS: i64 = 120; + /// Current state of the pairing process. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -52,8 +54,55 @@ pub struct PairingResponse { pub device_name: String, #[serde(default)] pub mobile_install_id: Option, + /// Local pairing user id, or BitFun account username when account auth is required. #[serde(default)] pub user_id: Option, + /// BitFun account password when the paired desktop is logged in. Never log this field. + #[serde(default)] + pub password: Option, +} + +impl PairingResponse { + /// Validate the decrypted but still untrusted mobile payload before it is + /// cloned, persisted, or passed into password verification. + pub fn validate_untrusted(&self) -> Result<()> { + fn portable_id(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + } + fn bounded_text(value: &str, max_bytes: usize) -> bool { + !value.trim().is_empty() + && value.len() <= max_bytes + && !value.chars().any(char::is_control) + } + + if self.challenge_echo.len() != 32 + || !self + .challenge_echo + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !portable_id(&self.device_id, 128) + || !bounded_text(&self.device_name, 256) + || self + .mobile_install_id + .as_deref() + .is_some_and(|value| !portable_id(value.trim(), 128)) + || self + .user_id + .as_deref() + .is_some_and(|value| !bounded_text(value, 128)) + || self + .password + .as_deref() + .is_some_and(|value| value.len() > 1024 || value.chars().any(char::is_control)) + { + return Err(anyhow!("invalid pairing response fields")); + } + Ok(()) + } } /// Manages the pairing state machine. @@ -64,6 +113,7 @@ pub struct PairingProtocol { room_id: Option, device_identity: DeviceIdentity, challenge: Option, + challenge_issued_at: Option, peer_device_id: Option, peer_device_name: Option, } @@ -77,6 +127,7 @@ impl PairingProtocol { room_id: None, device_identity, challenge: None, + challenge_issued_at: None, peer_device_id: None, peer_device_name: None, } @@ -121,6 +172,18 @@ impl PairingProtocol { /// Step 2 (Desktop): Peer joined with their public key — derive shared secret. pub async fn on_peer_joined(&mut self, peer_public_key_b64: &str) -> Result { + let state = self.state().await; + // Mobile browsers do not persist the ephemeral ECDH private key. A + // refresh, a duplicated URL, or a reload during verification therefore + // starts a fresh handshake in the same still-valid QR room. Let the + // latest handshake supersede the previous one; identity authorization + // is still enforced after the encrypted challenge response. + if !matches!( + state, + PairingState::WaitingForScan | PairingState::Verifying | PairingState::Connected + ) { + return Err(anyhow!("pairing request is not valid in the current state")); + } let keypair = self .keypair .as_ref() @@ -132,10 +195,12 @@ impl PairingProtocol { let challenge = generate_challenge(); self.challenge = Some(challenge.clone()); + let issued_at = chrono::Utc::now().timestamp(); + self.challenge_issued_at = Some(issued_at); let challenge_payload = PairingChallenge { challenge, - timestamp: chrono::Utc::now().timestamp(), + timestamp: issued_at, }; *self.state.write().await = PairingState::Verifying; @@ -144,12 +209,28 @@ impl PairingProtocol { /// Step 3 (Desktop): Verify the peer's challenge response. pub async fn verify_response(&mut self, response: &PairingResponse) -> Result { + if self.state().await != PairingState::Verifying { + return Err(anyhow!( + "pairing response is not valid in the current state" + )); + } let expected = self .challenge - .as_ref() + .take() .ok_or_else(|| anyhow!("no challenge issued"))?; + let issued_at = self + .challenge_issued_at + .take() + .ok_or_else(|| anyhow!("challenge timestamp missing"))?; - if response.challenge_echo != *expected { + if chrono::Utc::now().timestamp().saturating_sub(issued_at) > PAIRING_CHALLENGE_TTL_SECS { + *self.state.write().await = PairingState::Failed { + reason: "challenge expired".to_string(), + }; + return Ok(false); + } + + if response.challenge_echo != expected { *self.state.write().await = PairingState::Failed { reason: "challenge mismatch".to_string(), }; @@ -168,6 +249,23 @@ impl PairingProtocol { device_identity: &DeviceIdentity, mobile_install_id: Option, user_id: Option, + ) -> PairingResponse { + Self::answer_challenge_with_password( + challenge, + device_identity, + mobile_install_id, + user_id, + None, + ) + } + + /// Mobile side: pairing response that may include an account password. + pub fn answer_challenge_with_password( + challenge: &PairingChallenge, + device_identity: &DeviceIdentity, + mobile_install_id: Option, + user_id: Option, + password: Option, ) -> PairingResponse { PairingResponse { challenge_echo: challenge.challenge.clone(), @@ -175,6 +273,7 @@ impl PairingProtocol { device_name: device_identity.device_name.clone(), mobile_install_id, user_id, + password, } } @@ -182,8 +281,23 @@ impl PairingProtocol { *self.state.write().await = PairingState::Disconnected; self.shared_secret = None; self.challenge = None; + self.challenge_issued_at = None; + self.peer_device_id = None; + self.peer_device_name = None; + } + + /// Keep the QR room/keypair but discard the failed mobile handshake so the + /// user can correct account credentials without restarting Remote Connect. + /// Call only after an authenticated, encrypted pairing response fails an + /// identity/account policy check; cryptographic challenge mismatches remain + /// terminal for the current QR. + pub async fn retry_after_identity_rejection(&mut self) { + self.shared_secret = None; + self.challenge = None; + self.challenge_issued_at = None; self.peer_device_id = None; self.peer_device_name = None; + *self.state.write().await = PairingState::WaitingForScan; } pub async fn reset(&mut self) { @@ -192,6 +306,7 @@ impl PairingProtocol { self.shared_secret = None; self.room_id = None; self.challenge = None; + self.challenge_issued_at = None; self.peer_device_id = None; self.peer_device_name = None; } @@ -271,6 +386,124 @@ mod tests { let desktop_pub = encryption::parse_public_key(&qr.public_key).unwrap(); let mobile_shared = mobile_keypair.derive_shared_secret(&desktop_pub); assert_eq!(*desktop_secret, mobile_shared); + + // Challenges are single-use even when a response is replayed verbatim. + assert!(protocol.verify_response(&response).await.is_err()); + } + + #[tokio::test] + async fn connected_pairing_accepts_a_fresh_browser_handshake() { + let device = DeviceIdentity { + device_id: "test-desktop-id".into(), + device_name: "TestDesktop".into(), + mac_address: "AA:BB:CC:DD:EE:FF".into(), + }; + let first_mobile = DeviceIdentity { + device_id: "first-mobile-id".into(), + device_name: "FirstMobile".into(), + mac_address: "11:22:33:44:55:66".into(), + }; + let second_mobile = DeviceIdentity { + device_id: "second-mobile-id".into(), + device_name: "SecondMobile".into(), + mac_address: "22:33:44:55:66:77".into(), + }; + let mut protocol = PairingProtocol::new(device); + let qr = protocol.initiate("wss://relay.example.com").await.unwrap(); + let desktop_pub = encryption::parse_public_key(&qr.public_key).unwrap(); + + let first_keypair = KeyPair::generate(); + let first_challenge = protocol + .on_peer_joined(&first_keypair.public_key_base64()) + .await + .unwrap(); + let first_response = + PairingProtocol::answer_challenge(&first_challenge, &first_mobile, None, None); + assert!(protocol.verify_response(&first_response).await.unwrap()); + assert_eq!(protocol.state().await, PairingState::Connected); + + let second_keypair = KeyPair::generate(); + let second_challenge = protocol + .on_peer_joined(&second_keypair.public_key_base64()) + .await + .expect("a valid QR room must allow a browser refresh or another browser"); + assert_eq!(protocol.state().await, PairingState::Verifying); + assert_eq!( + *protocol.shared_secret().unwrap(), + second_keypair.derive_shared_secret(&desktop_pub) + ); + + let second_response = + PairingProtocol::answer_challenge(&second_challenge, &second_mobile, None, None); + assert!(protocol.verify_response(&second_response).await.unwrap()); + assert_eq!(protocol.state().await, PairingState::Connected); + assert_eq!(protocol.peer_device_name(), Some("SecondMobile")); + } + + #[tokio::test] + async fn fresh_pair_request_supersedes_an_abandoned_browser_handshake() { + let device = DeviceIdentity { + device_id: "test-desktop-id".into(), + device_name: "TestDesktop".into(), + mac_address: "AA:BB:CC:DD:EE:FF".into(), + }; + let latest_mobile = DeviceIdentity { + device_id: "latest-mobile-id".into(), + device_name: "LatestMobile".into(), + mac_address: "33:44:55:66:77:88".into(), + }; + let mut protocol = PairingProtocol::new(device); + let qr = protocol.initiate("wss://relay.example.com").await.unwrap(); + let desktop_pub = encryption::parse_public_key(&qr.public_key).unwrap(); + + let abandoned_keypair = KeyPair::generate(); + protocol + .on_peer_joined(&abandoned_keypair.public_key_base64()) + .await + .unwrap(); + assert_eq!(protocol.state().await, PairingState::Verifying); + + let latest_keypair = KeyPair::generate(); + let latest_challenge = protocol + .on_peer_joined(&latest_keypair.public_key_base64()) + .await + .expect("a reload must supersede an unfinished handshake"); + assert_eq!( + *protocol.shared_secret().unwrap(), + latest_keypair.derive_shared_secret(&desktop_pub) + ); + + let latest_response = + PairingProtocol::answer_challenge(&latest_challenge, &latest_mobile, None, None); + assert!(protocol.verify_response(&latest_response).await.unwrap()); + assert_eq!(protocol.state().await, PairingState::Connected); + } + + #[tokio::test] + async fn expired_pairing_challenge_is_rejected_and_consumed() { + let device = DeviceIdentity { + device_id: "test-desktop-id".into(), + device_name: "TestDesktop".into(), + mac_address: "AA:BB:CC:DD:EE:FF".into(), + }; + let mobile_device = DeviceIdentity { + device_id: "test-mobile-id".into(), + device_name: "TestMobile".into(), + mac_address: "11:22:33:44:55:66".into(), + }; + let mut protocol = PairingProtocol::new(device); + protocol.initiate("wss://relay.example.com").await.unwrap(); + let mobile_keypair = KeyPair::generate(); + let challenge = protocol + .on_peer_joined(&mobile_keypair.public_key_base64()) + .await + .unwrap(); + protocol.challenge_issued_at = + Some(chrono::Utc::now().timestamp() - PAIRING_CHALLENGE_TTL_SECS - 1); + let response = PairingProtocol::answer_challenge(&challenge, &mobile_device, None, None); + + assert!(!protocol.verify_response(&response).await.unwrap()); + assert!(protocol.verify_response(&response).await.is_err()); } #[test] diff --git a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs index 4746936791..9eae6900b9 100644 --- a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs +++ b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs @@ -12,12 +12,19 @@ impl QrGenerator { /// Build the URL that the QR code points to. /// `web_app_url` = where the mobile web app is hosted. /// `payload.url` = the relay server that the mobile WebSocket should connect to. - pub fn build_url(payload: &QrPayload, web_app_url: &str, language: &str) -> String { + /// When `account_username` is set, the URL requests account password pairing + /// (`auth=account&user=...`) so mobile can prefill the logged-in username. + pub fn build_url( + payload: &QrPayload, + web_app_url: &str, + language: &str, + account_username: Option<&str>, + ) -> String { let relay_ws = payload .url .replace("https://", "wss://") .replace("http://", "ws://"); - format!( + let mut url = format!( "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}&lang={lang}", web_app = web_app_url.trim_end_matches('/'), room = urlencoding::encode(&payload.room_id), @@ -27,7 +34,18 @@ impl QrGenerator { relay = urlencoding::encode(&relay_ws), v = payload.version, lang = urlencoding::encode(language), - ) + ); + // `Some(_)` enables account-password pairing even when username prefill + // is unavailable (e.g. restored session without a credential hint). + if let Some(username) = account_username { + url.push_str("&auth=account"); + let trimmed = username.trim(); + if !trimmed.is_empty() { + url.push_str("&user="); + url.push_str(&urlencoding::encode(trimmed)); + } + } + url } /// Generate a QR code as a base64-encoded PNG from a pre-built URL. @@ -76,7 +94,34 @@ mod tests { version: 1, }; - let url = QrGenerator::build_url(&payload, "https://mobile.example.com", "en-US"); + let url = QrGenerator::build_url(&payload, "https://mobile.example.com", "en-US", None); assert!(url.contains("lang=en-US")); + assert!(!url.contains("auth=account")); + } + + #[test] + fn build_url_includes_account_auth_when_username_provided() { + let payload = QrPayload { + room_id: "room_123".to_string(), + url: "https://relay.example.com".to_string(), + device_id: "device_123".to_string(), + device_name: "BitFun Desktop".to_string(), + public_key: "public_key_value".to_string(), + version: 1, + }; + + let url = QrGenerator::build_url( + &payload, + "https://mobile.example.com", + "zh-CN", + Some("alice"), + ); + assert!(url.contains("auth=account")); + assert!(url.contains("user=alice")); + + let auth_only = + QrGenerator::build_url(&payload, "https://mobile.example.com", "zh-CN", Some("")); + assert!(auth_only.contains("auth=account")); + assert!(!auth_only.contains("user=")); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index b269a2b73d..e7fad23361 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -35,6 +35,12 @@ pub fn ensure_rustls_crypto_provider() { type WsStream = tokio_tungstenite::WebSocketStream>; +const RELAY_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +/// Heartbeats are sent every 30 seconds. Two missed acknowledgements plus +/// scheduling/network slack indicates a half-open socket that should be +/// replaced even when the OS has not surfaced a read error yet. +const RELAY_INBOUND_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75); + /// Messages in the relay protocol (both directions). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -252,7 +258,19 @@ impl RelayClient { let mut ws_read = ws_read; tokio::spawn(async move { 'outer: loop { - while let Some(res) = ws_read.next().await { + loop { + let res = match await_relay_inbound(ws_read.next()).await { + Ok(Some(res)) => res, + Ok(None) => break, + Err(_) => { + warn!( + "Relay connection received no traffic for {} seconds; \ + treating socket as stale", + RELAY_INBOUND_IDLE_TIMEOUT.as_secs() + ); + break; + } + }; match res { Ok(Message::Text(text)) => { match serde_json::from_str::(&text) { @@ -566,38 +584,108 @@ async fn dial(ws_url: &str) -> Result { #[cfg(windows)] { - let request = ws_url - .into_client_request() - .map_err(|e| anyhow!("dial {ws_url}: build request failed: {e}"))?; - - // Wrap TLS connector construction in catch_unwind so that a panic - // (e.g. duplicate CryptoProvider install) is converted to an error - // instead of unwinding the tokio task and potentially crashing the - // process. - let connector = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - build_windows_rustls_connector() - })) - .map_err(|_| anyhow!("dial {ws_url}: TLS connector construction panicked"))??; - - let (stream, _) = tokio_tungstenite::connect_async_tls_with_config( - request, - Some(config), - false, - Some(connector), - ) + await_dial(ws_url, async move { + let request = ws_url + .into_client_request() + .map_err(|e| anyhow!("dial {ws_url}: build request failed: {e}"))?; + + // Wrap TLS connector construction in catch_unwind so that a panic + // (e.g. duplicate CryptoProvider install) is converted to an error + // instead of unwinding the tokio task and potentially crashing the + // process. + let connector = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + build_windows_rustls_connector() + })) + .map_err(|_| anyhow!("dial {ws_url}: TLS connector construction panicked"))??; + + let (stream, _) = tokio_tungstenite::connect_async_tls_with_config( + request, + Some(config), + false, + Some(connector), + ) + .await + .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; + Ok(stream) + }) .await - .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; - Ok(stream) } #[cfg(not(windows))] { // Non-Windows uses tokio-tungstenite's built-in rustls connector. // CryptoProvider must already be installed (see ensure_rustls_crypto_provider). - let (stream, _) = tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) - .await - .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; - Ok(stream) + await_dial(ws_url, async move { + let (stream, _) = + tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) + .await + .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; + Ok(stream) + }) + .await + } +} + +async fn await_dial(ws_url: &str, dial_future: F) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(RELAY_DIAL_TIMEOUT, dial_future) + .await + .map_err(|_| { + anyhow!( + "dial {ws_url}: connection timed out after {} seconds", + RELAY_DIAL_TIMEOUT.as_secs() + ) + })? +} + +async fn await_relay_inbound(inbound_future: F) -> std::result::Result +where + F: std::future::Future, +{ + tokio::time::timeout(RELAY_INBOUND_IDLE_TIMEOUT, inbound_future) + .await + .map_err(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn dial_timeout_bounds_a_pending_connection_attempt() { + let result = await_dial( + "wss://relay.example.invalid/ws", + std::future::pending::>(), + ) + .await; + + let error = result.expect_err("pending dial must be bounded by the connection timeout"); + assert_eq!( + error.to_string(), + "dial wss://relay.example.invalid/ws: connection timed out after 15 seconds" + ); + } + + #[tokio::test] + async fn dial_timeout_preserves_connection_errors() { + let result = await_dial::<(), _>( + "wss://relay.example.invalid/ws", + std::future::ready(Err(anyhow!("dial failed before timeout"))), + ) + .await; + + assert_eq!( + result.expect_err("dial error must be returned").to_string(), + "dial failed before timeout" + ); + } + + #[tokio::test(start_paused = true)] + async fn inbound_idle_timeout_detects_a_half_open_socket() { + let result = await_relay_inbound(std::future::pending::<()>()).await; + assert!(result.is_err(), "an idle relay stream must time out"); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_http.rs b/src/crates/services/services-integrations/src/remote_connect/relay_http.rs new file mode 100644 index 0000000000..48ff10e1c7 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/relay_http.rs @@ -0,0 +1,404 @@ +use anyhow::{anyhow, Result}; +use log::warn; +use reqwest::{ + header::{HeaderMap, RETRY_AFTER}, + RequestBuilder, StatusCode, +}; +use serde::de::DeserializeOwned; +use std::{ + error::Error as StdError, + sync::OnceLock, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +const RELAY_HTTP_TIMEOUT: Duration = Duration::from_secs(120); +const RELAY_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const RELAY_HTTP_READ_TIMEOUT: Duration = Duration::from_secs(30); +const RELAY_HTTP_RETRY_BUDGET: Duration = Duration::from_secs(120); +const MAX_RETRY_AFTER: Duration = Duration::from_secs(5); + +static RELAY_HTTP_CLIENT: OnceLock = OnceLock::new(); + +/// Retry classes are intentionally narrow. Callers must opt in only when the +/// same request can be replayed without duplicating a user-visible action. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RelayHttpRetry { + /// Compatibility path for an operation that an older relay cannot dedupe. + SingleAttempt, + /// GET-like reads and challenge probes that do not mutate relay state. + SafeRead, + /// Writes with a stable final state or a relay-persisted idempotency key. + IdempotentWrite, +} + +impl RelayHttpRetry { + const fn max_attempts(self) -> usize { + match self { + Self::SingleAttempt => 1, + Self::SafeRead => 5, + Self::IdempotentWrite => 5, + } + } +} + +pub(crate) struct BufferedRelayResponse { + status: StatusCode, + body: Vec, +} + +impl BufferedRelayResponse { + pub(crate) fn status(&self) -> StatusCode { + self.status + } + + pub(crate) async fn json(self) -> Result { + serde_json::from_slice(&self.body) + .map_err(|error| anyhow!("decode relay JSON response: {error}")) + } + + pub(crate) async fn bytes(self) -> Result> { + Ok(self.body) + } + + pub(crate) async fn text(self) -> Result { + Ok(String::from_utf8_lossy(&self.body).into_owned()) + } + + pub(crate) fn into_parts(self) -> (StatusCode, Vec) { + (self.status, self.body) + } +} + +/// Reuse one reqwest pool across account, sync, and Page management calls. +/// Rebuilding a client for every poll forces a fresh proxy CONNECT + TLS +/// handshake and makes short proxy/node disturbances much more visible. +pub(crate) fn relay_http_client() -> reqwest::Client { + RELAY_HTTP_CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .timeout(RELAY_HTTP_TIMEOUT) + .connect_timeout(RELAY_HTTP_CONNECT_TIMEOUT) + .read_timeout(RELAY_HTTP_READ_TIMEOUT) + .pool_idle_timeout(Duration::from_secs(90)) + .build() + .unwrap_or_else(|error| { + warn!( + "Failed to build shared relay HTTP client; using reqwest defaults: {error}" + ); + reqwest::Client::new() + }) + }) + .clone() +} + +pub(crate) async fn send_with_retry( + operation: &'static str, + request: RequestBuilder, + policy: RelayHttpRetry, +) -> Result { + tokio::time::timeout( + RELAY_HTTP_RETRY_BUDGET, + send_with_retry_within_budget(operation, request, policy), + ) + .await + .map_err(|_| { + anyhow!( + "relay HTTP {operation} exceeded the {}s total retry budget", + RELAY_HTTP_RETRY_BUDGET.as_secs() + ) + })? +} + +async fn send_with_retry_within_budget( + operation: &'static str, + request: RequestBuilder, + policy: RelayHttpRetry, +) -> Result { + let max_attempts = policy.max_attempts(); + for attempt in 1..=max_attempts { + let request = request.try_clone().ok_or_else(|| { + anyhow!("relay HTTP {operation} request body cannot be replayed safely") + })?; + + match request.send().await { + Ok(response) if is_transient_status(response.status()) && attempt < max_attempts => { + let status = response.status(); + let delay = retry_delay(attempt, Some(response.headers())); + warn!( + "Relay HTTP request will retry: operation={operation} \ + attempt={attempt}/{max_attempts} status={status} delay_ms={}", + delay.as_millis() + ); + drop(response); + tokio::time::sleep(delay).await; + } + Ok(response) => { + let status = response.status(); + match response.bytes().await { + Ok(body) => { + return Ok(BufferedRelayResponse { + status, + body: body.to_vec(), + }); + } + Err(error) + if is_retryable_transport_error(&error) && attempt < max_attempts => + { + // Once a definitive non-retryable HTTP status arrives, + // replaying a write just to recover its error body can + // repeat authentication/validation side effects. + if !status.is_success() && !is_transient_status(status) { + return Ok(BufferedRelayResponse { + status, + body: Vec::new(), + }); + } + let delay = retry_delay(attempt, None); + warn!( + "Relay HTTP response body will retry: operation={operation} \ + attempt={attempt}/{max_attempts} delay_ms={} error={}", + delay.as_millis(), + reqwest_error_summary(&error) + ); + tokio::time::sleep(delay).await; + } + Err(error) => { + return Err(anyhow!( + "relay HTTP {operation} response body failed after {attempt} attempt(s): {}", + reqwest_error_summary(&error) + )); + } + } + } + Err(error) if is_retryable_transport_error(&error) && attempt < max_attempts => { + let delay = retry_delay(attempt, None); + warn!( + "Relay HTTP request will retry: operation={operation} \ + attempt={attempt}/{max_attempts} delay_ms={} error={}", + delay.as_millis(), + reqwest_error_summary(&error) + ); + tokio::time::sleep(delay).await; + } + Err(error) => { + return Err(anyhow!( + "relay HTTP {operation} failed after {attempt} attempt(s): {}", + reqwest_error_summary(&error) + )); + } + } + } + + unreachable!("relay HTTP retry loop always returns") +} + +fn is_transient_status(status: StatusCode) -> bool { + matches!( + status, + StatusCode::REQUEST_TIMEOUT + | StatusCode::TOO_EARLY + | StatusCode::INTERNAL_SERVER_ERROR + | StatusCode::BAD_GATEWAY + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::GATEWAY_TIMEOUT + ) +} + +fn is_retryable_transport_error(error: &reqwest::Error) -> bool { + error.is_connect() || error.is_timeout() || error.is_body() || error.is_decode() +} + +fn retry_delay(attempt: usize, headers: Option<&HeaderMap>) -> Duration { + if let Some(delay) = headers + .and_then(|headers| headers.get(RETRY_AFTER)) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(Duration::from_secs) + { + return delay.min(MAX_RETRY_AFTER); + } + + let exponent = attempt.saturating_sub(1).min(4) as u32; + let base_ms = 300u64.saturating_mul(2u64.pow(exponent)); + // Small local jitter prevents many clients from retrying a recovered relay + // or proxy at exactly the same instant without adding another dependency. + let jitter_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| u64::from(duration.subsec_nanos()) % 151) + .unwrap_or_default(); + Duration::from_millis(base_ms + jitter_ms) +} + +fn reqwest_error_summary(error: &reqwest::Error) -> String { + let mut details = vec![format!( + "{} [connect={}, timeout={}, body={}, decode={}]", + error, + error.is_connect(), + error.is_timeout(), + error.is_body(), + error.is_decode() + )]; + let mut source = error.source(); + for _ in 0..4 { + let Some(cause) = source else { + break; + }; + let cause_text = cause.to_string(); + if !details.iter().any(|detail| detail == &cause_text) { + details.push(cause_text); + } + source = cause.source(); + } + details.join("; caused by: ") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + + #[test] + fn retries_only_transient_http_statuses() { + assert_eq!(RelayHttpRetry::SingleAttempt.max_attempts(), 1); + assert_eq!(RelayHttpRetry::SafeRead.max_attempts(), 5); + assert_eq!(RelayHttpRetry::IdempotentWrite.max_attempts(), 5); + for status in [ + StatusCode::REQUEST_TIMEOUT, + StatusCode::TOO_EARLY, + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_GATEWAY, + StatusCode::SERVICE_UNAVAILABLE, + StatusCode::GATEWAY_TIMEOUT, + ] { + assert!(is_transient_status(status), "{status} should be transient"); + } + for status in [ + StatusCode::BAD_REQUEST, + StatusCode::UNAUTHORIZED, + StatusCode::NOT_FOUND, + StatusCode::CONFLICT, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::PAYLOAD_TOO_LARGE, + ] { + assert!(!is_transient_status(status), "{status} must not be retried"); + } + } + + #[test] + fn retry_after_is_bounded() { + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, "60".parse().unwrap()); + assert_eq!(retry_delay(1, Some(&headers)), MAX_RETRY_AFTER); + } + + #[tokio::test] + async fn safe_read_retries_a_transient_response() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + let server_attempts = attempts.clone(); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0u8; 2048]; + let _ = stream.read(&mut request).await.unwrap(); + let attempt = server_attempts.fetch_add(1, Ordering::SeqCst); + let response = if attempt == 0 { + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + } else { + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok" + }; + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + + let response = send_with_retry( + "test-safe-read", + client.get(format!("http://{address}/health")), + RelayHttpRetry::SafeRead, + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + server.await.unwrap(); + } + + #[tokio::test] + async fn safe_read_retries_a_truncated_response_body() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + let server_attempts = attempts.clone(); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0u8; 2048]; + let _ = stream.read(&mut request).await.unwrap(); + let attempt = server_attempts.fetch_add(1, Ordering::SeqCst); + let response = if attempt == 0 { + "HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nshort" + } else { + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok" + }; + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + + let response = send_with_retry( + "test-truncated-body", + client.get(format!("http://{address}/health")), + RelayHttpRetry::SafeRead, + ) + .await + .unwrap(); + + assert_eq!(response.text().await.unwrap(), "ok"); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + server.await.unwrap(); + } + + #[tokio::test] + async fn non_retryable_status_does_not_replay_for_a_truncated_error_body() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let attempts = Arc::new(AtomicUsize::new(0)); + let server_attempts = attempts.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0u8; 2048]; + let _ = stream.read(&mut request).await.unwrap(); + server_attempts.fetch_add(1, Ordering::SeqCst); + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 10\r\nConnection: close\r\n\r\nshort", + ) + .await + .unwrap(); + }); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + + let response = send_with_retry( + "test-truncated-error-body", + client.post(format!("http://{address}/login")), + RelayHttpRetry::IdempotentWrite, + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.bytes().await.unwrap().is_empty()); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + server.await.unwrap(); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/session_store.rs b/src/crates/services/services-integrations/src/remote_connect/session_store.rs index c80f4fe286..dddcc72eee 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_store.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_store.rs @@ -1,15 +1,19 @@ //! Machine-bound persistent session store. //! -//! Saves the full `AccountSession` (token + master_key + user_id) and -//! relay URL to disk, encrypted with a machine-derived key so the file -//! is useless if copied to another machine. This lets Desktop / CLI -//! restart without requiring a fresh password entry. +//! Saves the full `AccountSession` (token + master_key + user_id) and relay +//! URL to disk, encrypted with a key that combines machine identity and a +//! random per-install secret. Secret files are owner-only on Unix and replaced +//! through a private temporary file. This lets Desktop / CLI restart without +//! requiring a fresh password entry while keeping copied session ciphertext +//! unusable without the separate install key. //! //! File location: `~/.bitfun/account_session.enc` //! Format: base64(nonce || ciphertext) where the plaintext is a JSON //! payload `{ token, user_id, master_key_b64, relay_url }`. use std::path::PathBuf; +use std::sync::{OnceLock, RwLock}; +use std::{fs::OpenOptions, io::Write}; use aes_gcm::aead::{Aead, KeyInit, OsRng}; use aes_gcm::{Aes256Gcm, Nonce}; @@ -21,6 +25,33 @@ use sha2::{Digest, Sha256}; const NONCE_SIZE: usize = 12; +fn session_store_directory_override() -> &'static RwLock> { + static OVERRIDE: OnceLock>> = OnceLock::new(); + OVERRIDE.get_or_init(|| RwLock::new(None)) +} + +/// Redirects session, local-key, and credential-hint files for integration +/// tests. Tests must use this instead of changing HOME or touching real login +/// state shared by Desktop and CLI. +pub fn set_session_store_directory_for_test(path: PathBuf) { + let mut override_path = session_store_directory_override() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *override_path = Some(path); +} + +fn session_store_directory() -> Result { + let override_path = session_store_directory_override() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(path) = override_path.as_ref() { + return Ok(path.clone()); + } + drop(override_path); + let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; + Ok(home.join(".bitfun")) +} + /// The on-disk JSON payload (plaintext before encryption). #[derive(Serialize, Deserialize)] struct SessionPayload { @@ -29,12 +60,82 @@ struct SessionPayload { /// Base64-encoded 32-byte master key. master_key_b64: String, relay_url: String, + /// Account-bound device id used when the session token was issued. + /// Optional for backward compatibility with sessions saved before this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + device_id: Option, } /// Resolve the persistent session file path. fn session_file_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; - Ok(home.join(".bitfun").join("account_session.enc")) + Ok(session_store_directory()?.join("account_session.enc")) +} + +fn session_key_file_path() -> Result { + Ok(session_store_directory()?.join("account_session.key")) +} + +/// Atomically replace a secret-bearing file and restrict it to the current +/// OS user where Unix permission bits are available. The parent is also made +/// private because older releases may have created `~/.bitfun` under a broad +/// process umask. +fn write_private_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("secret file has no parent directory"))?; + std::fs::create_dir_all(parent).context("create private data directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .context("restrict private data directory permissions")?; + } + + let mut random = [0u8; 8]; + OsRng.fill_bytes(&mut random); + let suffix = random + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("secret file has an invalid name"))?; + let temp_path = parent.join(format!(".{file_name}.{suffix}.tmp")); + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let write_result = (|| -> Result<()> { + let mut file = options + .open(&temp_path) + .context("create temporary secret file")?; + file.write_all(contents) + .context("write temporary secret file")?; + file.sync_all().context("flush temporary secret file")?; + drop(file); + + #[cfg(windows)] + if path.exists() { + std::fs::remove_file(path).context("replace existing secret file")?; + } + std::fs::rename(&temp_path, path).context("install private secret file")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .context("restrict secret file permissions")?; + } + Ok(()) + })(); + if write_result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + write_result } /// Derive a machine-bound AES-256 key from stable machine identifiers. @@ -42,7 +143,7 @@ fn session_file_path() -> Result { /// Combines hostname, OS username, OS type, and platform constant into /// SHA-256 to produce a 32-byte key. The file is useless on a different /// machine (different hostname / username combination). -fn derive_machine_key() -> [u8; 32] { +fn derive_legacy_machine_key() -> [u8; 32] { let mut hasher = Sha256::new(); // Hostname / machine name @@ -70,6 +171,66 @@ fn derive_machine_key() -> [u8; 32] { key } +fn derive_machine_key(local_secret: &[u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(derive_legacy_machine_key()); + hasher.update(b"|BitFun::session_store::v2|"); + hasher.update(local_secret); + let result = hasher.finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&result); + key +} + +fn load_or_create_local_session_secret() -> Result<[u8; 32]> { + let path = session_key_file_path()?; + match std::fs::read(&path) { + Ok(bytes) => { + return bytes + .try_into() + .map_err(|_| anyhow!("local account session key has an invalid length")); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(anyhow!("read local account session key: {error}")), + } + + let mut secret = [0u8; 32]; + OsRng.fill_bytes(&mut secret); + let parent = path + .parent() + .ok_or_else(|| anyhow!("session key has no parent directory"))?; + std::fs::create_dir_all(parent).context("create session key directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .context("restrict session key directory permissions")?; + } + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(mut file) => { + file.write_all(&secret) + .context("write local account session key")?; + file.sync_all().context("flush local account session key")?; + Ok(secret) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let bytes = std::fs::read(&path).context("read concurrently created session key")?; + bytes + .try_into() + .map_err(|_| anyhow!("local account session key has an invalid length")) + } + Err(error) => Err(anyhow!("create local account session key: {error}")), + } +} + /// Best-effort hostname retrieval (cross-platform). fn hostname_string() -> Option { // `hostname::get()` from the `hostname` crate would be cleaner, but @@ -120,16 +281,32 @@ pub fn save_session( user_id: &str, master_key: &[u8; 32], relay_url: &str, +) -> Result<()> { + save_session_with_device(token, user_id, master_key, relay_url, None) +} + +/// Persist the session including the account-bound `device_id`. +pub fn save_session_with_device( + token: &str, + user_id: &str, + master_key: &[u8; 32], + relay_url: &str, + device_id: Option<&str>, ) -> Result<()> { let payload = SessionPayload { token: token.to_string(), user_id: user_id.to_string(), master_key_b64: BASE64.encode(master_key), relay_url: relay_url.to_string(), + device_id: device_id + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string), }; let json = serde_json::to_string(&payload).context("serialize session payload")?; - let key = derive_machine_key(); + let local_secret = load_or_create_local_session_secret()?; + let key = derive_machine_key(&local_secret); let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("cipher init: {e}"))?; let mut nonce_bytes = [0u8; NONCE_SIZE]; @@ -147,18 +324,30 @@ pub fn save_session( let encoded = BASE64.encode(&packed); let path = session_file_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).context("create session dir")?; - } - std::fs::write(&path, encoded).context("write session file")?; + write_private_file(&path, encoded.as_bytes()).context("write session file")?; log::debug!("Session persisted to {:?}", path); Ok(()) } +/// Loaded account session fields from disk. +#[derive(Debug, Clone)] +pub struct LoadedSession { + pub token: String, + pub user_id: String, + pub master_key: [u8; 32], + pub relay_url: String, + pub device_id: Option, +} + /// Load and decrypt the session from disk. /// Returns `Ok(None)` if the file doesn't exist (not an error). pub fn load_session() -> Result> { + Ok(load_session_detailed()?.map(|s| (s.token, s.user_id, s.master_key, s.relay_url))) +} + +/// Load and decrypt the session, including optional account-bound `device_id`. +pub fn load_session_detailed() -> Result> { let path = session_file_path()?; let encoded = match std::fs::read_to_string(&path) { Ok(data) => data, @@ -182,11 +371,24 @@ pub fn load_session() -> Result> { .try_into() .map_err(|_| anyhow!("nonce conversion"))?; - let key = derive_machine_key(); + let local_secret = load_or_create_local_session_secret()?; + let key = derive_machine_key(&local_secret); let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("cipher init: {e}"))?; - let plaintext = cipher - .decrypt(Nonce::from_slice(&nonce_arr), ciphertext) - .map_err(|e| anyhow!("decrypt session: {e}"))?; + let (plaintext, used_legacy_key) = + match cipher.decrypt(Nonce::from_slice(&nonce_arr), ciphertext) { + Ok(plaintext) => (plaintext, false), + Err(_) => { + // Compatibility with v1 sessions. Successful legacy loads are + // immediately re-encrypted with the per-install random secret. + let legacy_key = derive_legacy_machine_key(); + let legacy_cipher = Aes256Gcm::new_from_slice(&legacy_key) + .map_err(|e| anyhow!("legacy cipher init: {e}"))?; + let plaintext = legacy_cipher + .decrypt(Nonce::from_slice(&nonce_arr), ciphertext) + .map_err(|e| anyhow!("decrypt session: {e}"))?; + (plaintext, true) + } + }; let payload: SessionPayload = serde_json::from_slice(&plaintext).context("deserialize session payload")?; @@ -200,12 +402,25 @@ pub fn load_session() -> Result> { } master_key.copy_from_slice(&master_key_bytes); - Ok(Some(( - payload.token, - payload.user_id, + let loaded = LoadedSession { + token: payload.token, + user_id: payload.user_id, master_key, - payload.relay_url, - ))) + relay_url: payload.relay_url, + device_id: payload.device_id, + }; + if used_legacy_key { + if let Err(error) = save_session_with_device( + &loaded.token, + &loaded.user_id, + &loaded.master_key, + &loaded.relay_url, + loaded.device_id.as_deref(), + ) { + log::warn!("Failed to migrate legacy account session encryption: {error}"); + } + } + Ok(Some(loaded)) } /// Remove the persisted session file (called on logout). @@ -219,8 +434,7 @@ pub fn clear_session() { // Shared by Desktop and CLI so login forms pre-fill the same values. fn credential_hint_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; - Ok(home.join(".bitfun").join("account_hint.json")) + Ok(session_store_directory()?.join("account_hint.json")) } /// Non-secret login pre-fill (never stores password or master key). @@ -243,7 +457,7 @@ pub fn save_credential_hint(username: &str, relay_url: &str) { let _ = std::fs::create_dir_all(parent); } if let Ok(json) = serde_json::to_string(&hint) { - let _ = std::fs::write(&path, json); + let _ = write_private_file(&path, json.as_bytes()); } } @@ -260,3 +474,36 @@ pub fn clear_credential_hint() { let _ = std::fs::remove_file(&path); } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn private_file_writer_uses_owner_only_permissions_and_replaces_atomically() { + let root = tempfile::tempdir().unwrap(); + let private_dir = root.path().join("private"); + let path = private_dir.join("session.enc"); + + write_private_file(&path, b"first").unwrap(); + write_private_file(&path, b"second").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"second"); + assert_eq!( + std::fs::metadata(&private_dir) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert!(std::fs::read_dir(&private_dir) + .unwrap() + .all(|entry| entry.unwrap().path() == path)); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs index 1643c71c47..660944afa2 100644 --- a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs +++ b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs @@ -3,6 +3,11 @@ //! Persists per-user state under `~/.bitfun/account_sync/` so incremental //! `?since=` pulls and upload dedupe survive app restarts. Not secret — //! hashes are of plaintext session bundles; cursors are relay version ints. +//! +//! Session sync state (`.json`) and the settings sync cursor +//! (`.settings.json`) live in separate files on purpose: the session +//! backup loop and the settings sync engine are independent writers, so a +//! shared read-modify-write file could drop one writer's update. use std::collections::HashMap; use std::path::PathBuf; @@ -22,6 +27,18 @@ pub struct AccountSyncState { pub uploaded_hashes: HashMap, } +/// Settings sync progress for one account: the cloud settings blob version +/// this device last uploaded or applied, plus the content hash of that blob. +/// Lets the periodic pull skip unchanged blobs across restarts and the push +/// path skip unchanged content. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SettingsCursor { + #[serde(default)] + pub version: i64, + #[serde(default)] + pub hash: String, +} + /// SHA-256 hex digest of session bundle plaintext (stable skip key). pub fn content_hash(plaintext: &str) -> String { let digest = Sha256::digest(plaintext.as_bytes()); @@ -33,8 +50,8 @@ fn sync_dir() -> Result { Ok(home.join(".bitfun").join("account_sync")) } -fn sync_state_path(user_id: &str) -> Result { - let safe: String = user_id +fn safe_user_id(user_id: &str) -> String { + user_id .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { @@ -43,8 +60,15 @@ fn sync_state_path(user_id: &str) -> Result { '_' } }) - .collect(); - Ok(sync_dir()?.join(format!("{safe}.json"))) + .collect() +} + +fn sync_state_path(user_id: &str) -> Result { + Ok(sync_dir()?.join(format!("{}.json", safe_user_id(user_id)))) +} + +fn settings_cursor_path(user_id: &str) -> Result { + Ok(sync_dir()?.join(format!("{}.settings.json", safe_user_id(user_id)))) } /// Load sync state for `user_id`, or a default empty state if missing/corrupt. @@ -71,6 +95,30 @@ pub fn save(user_id: &str, state: &AccountSyncState) -> Result<()> { Ok(()) } +/// Load the settings cursor for `user_id`, defaulting when missing/corrupt. +pub fn load_settings_cursor(user_id: &str) -> SettingsCursor { + let path = match settings_cursor_path(user_id) { + Ok(p) => p, + Err(_) => return SettingsCursor::default(), + }; + match std::fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(), + Err(_) => SettingsCursor::default(), + } +} + +/// Persist the settings cursor for `user_id`. +pub fn save_settings_cursor(user_id: &str, cursor: &SettingsCursor) -> Result<()> { + let dir = sync_dir()?; + std::fs::create_dir_all(&dir)?; + let path = settings_cursor_path(user_id)?; + let raw = serde_json::to_string_pretty(cursor)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, raw)?; + std::fs::rename(&tmp, &path)?; + Ok(()) +} + impl AccountSyncState { pub fn uploaded_hash(&self, session_id: &str) -> Option<&str> { self.uploaded_hashes.get(session_id).map(String::as_str) @@ -95,3 +143,35 @@ impl AccountSyncState { self.last_session_since = max_v; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_state_deserializes() { + let legacy = r#"{"last_session_since":7,"uploaded_hashes":{"s1":"abc"}}"#; + let state: AccountSyncState = serde_json::from_str(legacy).unwrap(); + assert_eq!(state.last_session_since, 7); + assert_eq!(state.uploaded_hash("s1"), Some("abc")); + } + + #[test] + fn settings_cursor_round_trips() { + let cursor = SettingsCursor { + version: 42, + hash: "deadbeef".to_string(), + }; + let raw = serde_json::to_string(&cursor).unwrap(); + let back: SettingsCursor = serde_json::from_str(&raw).unwrap(); + assert_eq!(back.version, 42); + assert_eq!(back.hash, "deadbeef"); + } + + #[test] + fn settings_cursor_defaults_when_missing_fields() { + let cursor: SettingsCursor = serde_json::from_str("{}").unwrap(); + assert_eq!(cursor.version, 0); + assert!(cursor.hash.is_empty()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_ssh/mod.rs b/src/crates/services/services-integrations/src/remote_ssh/mod.rs index d0a49607c3..5433c530af 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/mod.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/mod.rs @@ -19,6 +19,8 @@ pub mod manager; #[cfg(feature = "remote-ssh-concrete")] mod password_vault; #[cfg(feature = "remote-ssh-concrete")] +pub mod relay_deploy; +#[cfg(feature = "remote-ssh-concrete")] mod remote_exec; #[cfg(feature = "remote-ssh-concrete")] mod remote_exec_runtime_port; diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs new file mode 100644 index 0000000000..12b6ab7513 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -0,0 +1,1395 @@ +//! One-click relay server self-deploy orchestration over an existing SSH connection. +//! +//! Drives the open-source relay-server deployment (`src/apps/relay-server/deploy.sh`) +//! on a user-owned server: +//! +//! 1. `run_preflight` — probe OS/arch, Docker access mode, memory, port, existing installs. +//! 2. `start_task` — stage an interactive driver script (run inside a remote PTY so sudo +//! passwords work) that prepares Docker access, then launches the long build via +//! `nohup` and `tail -f`s the log. +//! 3. `poll_task` — detect completion via marker/pid for wizard state transitions. +//! 4. `cancel_task` — stop a running task when the wizard closes (kill process tree + +//! best-effort compose teardown for in-progress deploys). +//! 5. `import_account` — hand a locally-provisioned account to `relay-admin import-user`. +//! +//! Remote deploy state lives under `~/.bitfun/relay-deploy/`; the cloned source +//! tree lives under `~/.bitfun/relay-src/` (never `$HOME/bitfun`, which may be +//! the user's own project). +//! +//! Product / regression invariants (wizard + entry points): +//! `src/web-ui/src/features/relay-deploy/README.md`. Do not change clone destination, +//! password handoff, or “already deployed” semantics without updating that doc. + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; + +use super::manager::SSHConnectionManager; +use super::remote_git::shell_quote_posix; + +/// Default public relay port, matching `src/apps/relay-server/docker-compose.yml`. +pub const RELAY_PORT: u16 = 9700; + +/// Validate a user-selected relay listen port (1–65535; 0 → default). +pub fn normalize_relay_port(port: u16) -> Result { + if port == 0 { + return Ok(RELAY_PORT); + } + // u16 already caps at 65535; reject only the zero case above. + Ok(port) +} +/// Relay container name, matching docker-compose.yml. +const RELAY_CONTAINER_NAME: &str = "bitfun-relay"; +/// Account DB path inside the relay container (RELAY_DB_PATH in docker-compose.yml). +const RELAY_CONTAINER_DB: &str = "/app/data/bitfun_relay.db"; +/// Canonical git remote for incremental source updates on the target server. +const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; +/// Branch tracked by one-click deploy. +const REPO_GIT_BRANCH: &str = "main"; +/// Tarball fallback when git is unavailable or clone/fetch fails. +const REPO_TARBALL_URL: &str = "https://github.com/GCWing/BitFun/archive/refs/heads/main.tar.gz"; +/// Remote directory (relative to the SSH user's home) holding deploy state. +const DEPLOY_STATE_DIR: &str = ".bitfun/relay-deploy"; +/// BitFun-managed source checkout (relative to home). Must stay under `.bitfun/` +/// so deploy never deletes or overwrites a user directory named `bitfun`/`BitFun`. +const SOURCE_DIR: &str = ".bitfun/relay-src"; +/// Line printed by task scripts on success; polled to detect completion. +const TASK_DONE_MARKER: &str = "RELAY_TASK_DONE"; + +/// Long-running remote operations that run detached and are polled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayDeployTask { + InstallDocker, + Deploy, +} + +impl RelayDeployTask { + fn stem(self) -> &'static str { + match self { + Self::InstallDocker => "install-docker", + Self::Deploy => "deploy", + } + } +} + +/// Fine-grained Docker access classification for the current SSH session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DockerAccessMode { + Ok, + GroupInactive, + SudoNopass, + SudoNeedsPassword, + BrokenDockerHome, + DaemonDown, + Missing, +} + +/// Result of the remote environment probe. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayPreflight { + /// `uname -s`, e.g. "Linux". + pub os: String, + /// `uname -m`, e.g. "x86_64" / "aarch64". + pub arch: String, + /// True for Linux x86_64/aarch64, the architectures deploy.sh supports. + pub arch_supported: bool, + pub docker_installed: bool, + /// `docker compose` (v2) or legacy `docker-compose` available (direct or via sudo). + pub compose_available: bool, + /// Legacy coarse daemon string: "ok" | "sudo" | "unreachable". + pub docker_daemon: String, + /// Structured access mode for the wizard / interactive driver. + pub docker_access_mode: DockerAccessMode, + pub active_has_docker_group: bool, + pub in_docker_group_file: bool, + pub docker_home_writable: bool, + pub tar_available: bool, + pub curl_available: bool, + /// Root or passwordless sudo. + pub sudo_available: bool, + /// `sudo` exists but `sudo -n` fails (password required). + pub sudo_needs_password: bool, + pub mem_total_mb: u64, + /// Selected listen port already bound by another process. + pub port_busy: bool, + /// Port that was probed (`port_busy` / selected-port health). + pub probed_port: u16, + /// Selected port is published by the existing `bitfun-relay` container (or + /// answers `/health` as that relay). Used to distinguish "our relay" from + /// an unrelated occupant when the user changes the listen port. + pub port_owned_by_relay: bool, + /// A `bitfun-relay` container already exists (any state). + pub container_exists: bool, + /// A `bitfun-relay` container is currently running. + pub container_running: bool, + /// Host port published by the running relay (0 if unknown / not running). + pub existing_relay_port: u16, + /// Relay answers `/health` on the selected port and/or the existing + /// container port (independent of which port the user typed). + pub relay_healthy: bool, + pub home_dir: String, +} + +/// Result of staging an interactive driver script for a PTY session. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayTaskStart { + /// Absolute remote path of the interactive driver to run in a PTY. + pub script_path: String, +} + +/// Incremental poll result for a detached task. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayTaskPoll { + /// Byte offset to pass to the next poll. + pub cursor: u64, + /// Log output appended since the previous cursor. + pub output: String, + pub status: RelayTaskStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayTaskStatus { + Running, + Succeeded, + Failed, +} + +/// Probe the target server. Never fails on individual checks: probe errors +/// surface as `false`/empty fields so the UI can render them. +pub async fn run_preflight( + manager: &SSHConnectionManager, + connection_id: &str, + port: u16, +) -> Result { + let port = normalize_relay_port(port)?; + let script = format!( + r#" +PORT="{port}" +echo "probed_port=$PORT" +echo "os=$(uname -s 2>/dev/null)" +echo "arch=$(uname -m 2>/dev/null)" +echo "home=$HOME" +if command -v docker >/dev/null 2>&1; then echo "docker=1"; else echo "docker=0"; fi +COMPOSE=0 +if docker compose version >/dev/null 2>&1 || command -v docker-compose >/dev/null 2>&1; then COMPOSE=1; fi +if [ "$COMPOSE" = "0" ] && sudo -n docker compose version >/dev/null 2>&1; then COMPOSE=1; fi +echo "compose=$COMPOSE" +if docker info >/dev/null 2>&1; then echo "daemon=ok" +elif sudo -n docker info >/dev/null 2>&1; then echo "daemon=sudo" +elif command -v docker >/dev/null 2>&1 && (systemctl is-active docker >/dev/null 2>&1 || service docker status >/dev/null 2>&1); then echo "daemon=down" +else echo "daemon=unreachable"; fi +if command -v curl >/dev/null 2>&1; then echo "curl=1"; else echo "curl=0"; fi +if command -v tar >/dev/null 2>&1; then echo "tar=1"; else echo "tar=0"; fi +if [ "$(id -u)" = "0" ]; then echo "sudo=1"; elif sudo -n true >/dev/null 2>&1; then echo "sudo=1"; else echo "sudo=0"; fi +if [ "$(id -u)" != "0" ] && command -v sudo >/dev/null 2>&1 && ! sudo -n true >/dev/null 2>&1; then echo "sudo_needs_password=1"; else echo "sudo_needs_password=0"; fi +if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx docker; then echo "active_docker_group=1"; else echo "active_docker_group=0"; fi +U=$(id -un 2>/dev/null || true) +if getent group docker 2>/dev/null | grep -qE "(^|:|,)${{U}}(,|$)"; then echo "in_docker_group_file=1"; else echo "in_docker_group_file=0"; fi +if [ ! -e "$HOME/.docker" ]; then echo "docker_home_writable=1" +elif [ -w "$HOME/.docker" ] && {{ [ ! -e "$HOME/.docker/buildx" ] || [ -w "$HOME/.docker/buildx" ]; }}; then echo "docker_home_writable=1" +else echo "docker_home_writable=0"; fi +echo "mem_kb=$(awk '/MemTotal/ {{print $2}}' /proc/meminfo 2>/dev/null || echo 0)" +if command -v ss >/dev/null 2>&1; then PORTS=$(ss -ltn 2>/dev/null); else PORTS=$(netstat -ltn 2>/dev/null); fi +if printf '%s\n' "$PORTS" | awk '{{print $4}}' | grep -q ":${{PORT}}$"; then echo "port_busy=1"; else echo "port_busy=0"; fi +# Prefer a docker CLI that can talk to the daemon (plain or passwordless sudo). +D=docker +if ! docker info >/dev/null 2>&1; then + if sudo -n docker info >/dev/null 2>&1; then D="sudo -n docker"; else D=""; fi +fi +CONTAINER=0 +RUNNING=0 +EXISTING_PORT=0 +if [ -n "$D" ]; then + if $D ps -a --format '{{{{.Names}}}}' 2>/dev/null | grep -qx bitfun-relay; then CONTAINER=1; fi + if $D ps --format '{{{{.Names}}}}' 2>/dev/null | grep -qx bitfun-relay; then RUNNING=1; fi + if [ "$CONTAINER" = "1" ]; then + # First published host port on the container (compose maps RELAY_PORT:RELAY_PORT). + EXISTING_PORT=$($D inspect -f '{{{{range $p, $conf := .NetworkSettings.Ports}}}}{{{{range $conf}}}}{{{{if .HostPort}}}}{{{{.HostPort}}}}{{{{end}}}}{{{{end}}}}{{{{end}}}}' bitfun-relay 2>/dev/null | awk 'NF {{print $1; exit}}') + EXISTING_PORT=$(printf '%s' "$EXISTING_PORT" | tr -cd '0-9') + fi +fi +# Fallback: last deploy wrote ~/.bitfun/relay-deploy/relay.port +if [ -z "$EXISTING_PORT" ] || [ "$EXISTING_PORT" = "0" ]; then + if [ -f "$HOME/.bitfun/relay-deploy/relay.port" ]; then + EXISTING_PORT=$(tr -cd '0-9' < "$HOME/.bitfun/relay-deploy/relay.port") + fi +fi +[ -n "$EXISTING_PORT" ] || EXISTING_PORT=0 +echo "container=$CONTAINER" +echo "container_running=$RUNNING" +echo "existing_port=$EXISTING_PORT" +HEALTHY=0 +SELECTED_HEALTHY=0 +if curl -fsS -m 3 "http://127.0.0.1:${{PORT}}/health" >/dev/null 2>&1; then + HEALTHY=1 + SELECTED_HEALTHY=1 +fi +if [ "$HEALTHY" = "0" ] && [ "$EXISTING_PORT" != "0" ] && [ "$EXISTING_PORT" != "$PORT" ]; then + if curl -fsS -m 3 "http://127.0.0.1:${{EXISTING_PORT}}/health" >/dev/null 2>&1; then HEALTHY=1; fi +fi +echo "healthy=$HEALTHY" +PORT_OWNED=0 +if [ "$SELECTED_HEALTHY" = "1" ]; then PORT_OWNED=1 +elif [ "$EXISTING_PORT" != "0" ] && [ "$EXISTING_PORT" = "$PORT" ] && [ "$RUNNING" = "1" ]; then PORT_OWNED=1 +fi +echo "port_owned=$PORT_OWNED" +"#, + port = port, + ); + let (stdout, _stderr, code) = manager.execute_command(connection_id, &script).await?; + if code != 0 { + return Err(anyhow!("preflight probe failed (exit {code})")); + } + Ok(parse_preflight(&stdout, port)) +} + +fn parse_preflight(out: &str, fallback_port: u16) -> RelayPreflight { + let get = |key: &str| -> String { + out.lines() + .find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('='))) + .unwrap_or("") + .trim() + .to_string() + }; + let probed_port: u16 = get("probed_port").parse().unwrap_or(fallback_port); + let os = get("os"); + let arch = get("arch"); + let arch_supported = os == "Linux" + && (arch == "x86_64" || arch == "amd64" || arch == "aarch64" || arch == "arm64"); + let mem_kb: u64 = get("mem_kb").parse().unwrap_or(0); + let docker_installed = get("docker") == "1"; + let active_has_docker_group = get("active_docker_group") == "1"; + let in_docker_group_file = get("in_docker_group_file") == "1"; + let docker_home_writable = get("docker_home_writable") != "0"; + let sudo_available = get("sudo") == "1"; + let sudo_needs_password = get("sudo_needs_password") == "1"; + let daemon_raw = { + let d = get("daemon"); + if d.is_empty() { + "unreachable".into() + } else { + d + } + }; + let docker_access_mode = classify_docker_access( + docker_installed, + &daemon_raw, + active_has_docker_group, + in_docker_group_file, + docker_home_writable, + sudo_available, + sudo_needs_password, + ); + let docker_daemon = match docker_access_mode { + DockerAccessMode::Ok | DockerAccessMode::BrokenDockerHome => "ok".into(), + DockerAccessMode::SudoNopass + | DockerAccessMode::SudoNeedsPassword + | DockerAccessMode::GroupInactive => "sudo".into(), + DockerAccessMode::DaemonDown | DockerAccessMode::Missing => "unreachable".into(), + }; + RelayPreflight { + os, + arch, + arch_supported, + docker_installed, + compose_available: get("compose") == "1", + docker_daemon, + docker_access_mode, + active_has_docker_group, + in_docker_group_file, + docker_home_writable, + tar_available: get("tar") == "1", + curl_available: get("curl") == "1", + sudo_available, + sudo_needs_password, + mem_total_mb: mem_kb / 1024, + port_busy: get("port_busy") == "1", + probed_port, + port_owned_by_relay: get("port_owned") == "1", + container_exists: get("container") == "1", + container_running: get("container_running") == "1", + existing_relay_port: get("existing_port").parse().unwrap_or(0), + relay_healthy: get("healthy") == "1", + home_dir: get("home"), + } +} + +fn classify_docker_access( + docker_installed: bool, + daemon_raw: &str, + active_has_docker_group: bool, + in_docker_group_file: bool, + docker_home_writable: bool, + sudo_available: bool, + sudo_needs_password: bool, +) -> DockerAccessMode { + if !docker_installed { + return DockerAccessMode::Missing; + } + if daemon_raw == "ok" { + if !docker_home_writable { + return DockerAccessMode::BrokenDockerHome; + } + return DockerAccessMode::Ok; + } + if daemon_raw == "down" { + return DockerAccessMode::DaemonDown; + } + if in_docker_group_file && !active_has_docker_group { + return DockerAccessMode::GroupInactive; + } + if daemon_raw == "sudo" || sudo_available { + return DockerAccessMode::SudoNopass; + } + if sudo_needs_password { + return DockerAccessMode::SudoNeedsPassword; + } + if daemon_raw == "unreachable" { + return DockerAccessMode::DaemonDown; + } + DockerAccessMode::Missing +} + +/// Stage an interactive driver script for the task. Does **not** launch it — +/// the wizard runs the script inside a remote PTY so sudo can prompt. +/// +/// `port` is used for deploy (written to `relay.port` + compose `.env`); ignored +/// for Docker install. +pub async fn start_task( + manager: &SSHConnectionManager, + connection_id: &str, + task: RelayDeployTask, + port: u16, +) -> Result { + let home = resolve_home(manager, connection_id).await?; + let dir = format!("{home}/{DEPLOY_STATE_DIR}"); + let stem = task.stem(); + let port = normalize_relay_port(port)?; + + // Stop any leftover task from a previous attempt / closed wizard. + let _ = cancel_task(manager, connection_id, task).await; + + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {} && chmod 700 {}", + shell_quote_posix(&dir), + shell_quote_posix(&dir) + ), + ) + .await?; + + let body = match task { + RelayDeployTask::InstallDocker => install_docker_body_script(), + RelayDeployTask::Deploy => deploy_body_script(port), + }; + let driver = match task { + RelayDeployTask::InstallDocker => interactive_driver_script(stem, "install"), + RelayDeployTask::Deploy => interactive_driver_script(stem, "deploy"), + }; + + let body_path = format!("{dir}/{stem}-body.sh"); + let script_path = format!("{dir}/{stem}.sh"); + let port_path = format!("{dir}/relay.port"); + manager + .sftp_write(connection_id, &body_path, body.as_bytes()) + .await?; + manager + .sftp_write(connection_id, &script_path, driver.as_bytes()) + .await?; + if matches!(task, RelayDeployTask::Deploy) { + manager + .sftp_write(connection_id, &port_path, format!("{port}\n").as_bytes()) + .await?; + } + // Seed preparing flag before the PTY runs the driver so early polls do not + // race into "failed" (no pid / no flag yet). + let prepare_flag = format!("{dir}/{stem}.preparing"); + let log_path = format!("{dir}/{stem}.log"); + let pid_path = format!("{dir}/{stem}.pid"); + exec_ok( + manager, + connection_id, + &format!( + "chmod 700 {} {} && rm -f {} {} && : > {} && touch {}", + shell_quote_posix(&body_path), + shell_quote_posix(&script_path), + shell_quote_posix(&pid_path), + shell_quote_posix(&log_path), + shell_quote_posix(&log_path), + shell_quote_posix(&prepare_flag), + ), + ) + .await?; + + Ok(RelayTaskStart { script_path }) +} + +/// Poll a detached task: incremental log output plus liveness/completion status. +pub async fn poll_task( + manager: &SSHConnectionManager, + connection_id: &str, + task: RelayDeployTask, + cursor: u64, +) -> Result { + let stem = task.stem(); + let script = format!( + r#" +D="$HOME/{DEPLOY_STATE_DIR}" +LOG="$D/{stem}.log" +PIDF="$D/{stem}.pid" +PREPF="$D/{stem}.preparing" +running=0 +if [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF" 2>/dev/null)" 2>/dev/null; then running=1; fi +# Interactive prepare phase (sudo prompts) before nohup starts. +preparing=0 +if [ -f "$PREPF" ]; then preparing=1; fi +log_exists=0 +size=0 +if [ -f "$LOG" ]; then log_exists=1; size=$(wc -c < "$LOG" | tr -d ' '); fi +marker=0 +if [ -f "$LOG" ] && grep -q {TASK_DONE_MARKER} "$LOG"; then marker=1; fi +# Build may still be progressing via docker/buildkit even if the wrapper pid +# briefly looks gone; treat a growing log without a marker as running. +echo "running=$running" +echo "preparing=$preparing" +echo "log_exists=$log_exists" +echo "size=$size" +echo "marker=$marker" +echo "---" +if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi +"#, + from = cursor.saturating_add(1), + ); + let (stdout, _stderr, code) = manager.execute_command(connection_id, &script).await?; + if code != 0 { + return Err(anyhow!("poll failed (exit {code})")); + } + let (head, output) = split_poll_stdout(&stdout); + let get = |key: &str| -> String { + head.lines() + .find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('='))) + .unwrap_or("") + .trim() + .to_string() + }; + let running = get("running") == "1"; + let preparing = get("preparing") == "1"; + let log_exists = get("log_exists") == "1"; + let marker = get("marker") == "1"; + let size: u64 = get("size").parse().unwrap_or(cursor); + let status = decide_task_status( + marker, + running, + preparing, + log_exists, + size, + cursor, + !output.is_empty(), + ); + Ok(RelayTaskPoll { + cursor: size, + output: output.to_string(), + status, + }) +} + +/// Cancel a running install/deploy task (wizard close / back / retry). +/// +/// Kills the nohup body process tree, clears pid/preparing flags, appends a +/// cancel marker to the log, and for deploy best-effort stops an in-progress +/// compose build. Safe to call when nothing is running. +pub async fn cancel_task( + manager: &SSHConnectionManager, + connection_id: &str, + task: RelayDeployTask, +) -> Result<()> { + let stem = task.stem(); + // Only tear down compose when we interrupt an in-progress deploy — never when + // cancel is a no-op cleanup before start_task (would stop a healthy relay). + let compose_teardown = if matches!(task, RelayDeployTask::Deploy) { + format!( + r#" +if [ "$was_active" = "1" ]; then + SRC="$HOME/{SOURCE_DIR}/src/apps/relay-server" + stop_compose() {{ + if [ ! -d "$SRC" ]; then return 0; fi + ( + cd "$SRC" || exit 0 + "$@" compose kill >/dev/null 2>&1 || true + for id in $("$@" ps -aq --filter "label=com.docker.compose.project=relay-server" 2>/dev/null); do + # Skip the already-running production container name only when we are + # not mid-redeploy; during cancel of an active build, tear builders down. + "$@" kill -s KILL "$id" >/dev/null 2>&1 || true + done + # BuildKit workers often outlive the compose CLI — stop the default builder. + "$@" buildx stop >/dev/null 2>&1 || true + "$@" builder stop >/dev/null 2>&1 || true + ) || true + }} + if command -v docker >/dev/null 2>&1; then + stop_compose docker + fi + if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + stop_compose sudo -n docker + fi +fi +"#, + SOURCE_DIR = SOURCE_DIR, + ) + } else { + String::new() + }; + let script = format!( + r#" +set +e +D="$HOME/{DEPLOY_STATE_DIR}" +STEM="{stem}" +LOG="$D/$STEM.log" +PIDF="$D/$STEM.pid" +PREPF="$D/$STEM.preparing" +BODY="$D/$STEM-body.sh" +mkdir -p "$D" 2>/dev/null +was_active=0 +[ -f "$PREPF" ] && was_active=1 +rm -f "$PREPF" +kill_tree() {{ + local p="$1" + local sig="$2" + [ -n "$p" ] || return 0 + for c in $(pgrep -P "$p" 2>/dev/null); do + kill_tree "$c" "$sig" + done + kill "-$sig" "$p" 2>/dev/null || true +}} +if [ -f "$PIDF" ]; then + pid="$(cat "$PIDF" 2>/dev/null | tr -d '[:space:]')" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + was_active=1 + kill_tree "$pid" TERM + sleep 1 + if kill -0 "$pid" 2>/dev/null; then + kill_tree "$pid" KILL + fi + fi + rm -f "$PIDF" +fi +# Body may have been reparented to init after nohup; match the body script only +# (do not pkill broad relay-deploy patterns — that can kill this cancel script). +if [ -n "$BODY" ] && pgrep -f "$BODY" >/dev/null 2>&1; then + was_active=1 + pkill -TERM -f "$BODY" 2>/dev/null || true + sleep 1 + pkill -KILL -f "$BODY" 2>/dev/null || true +fi +{compose_teardown} +if [ "$was_active" = "1" ]; then + echo "" >>"$LOG" 2>/dev/null + echo ">>> Cancelled by client (wizard closed)" >>"$LOG" 2>/dev/null +fi +exit 0 +"#, + DEPLOY_STATE_DIR = DEPLOY_STATE_DIR, + stem = stem, + compose_teardown = compose_teardown, + ); + let (_stdout, stderr, code) = manager.execute_command(connection_id, &script).await?; + if code != 0 { + return Err(anyhow!("cancel failed (exit {code}): {stderr}")); + } + Ok(()) +} + +/// Decide poll status from remote probe fields. +/// +/// Pending (PTY not started yet) and active prepare/build must not look like +/// failure — the wizard polls immediately after staging scripts. +fn decide_task_status( + marker: bool, + running: bool, + preparing: bool, + log_exists: bool, + size: u64, + cursor: u64, + got_new_output: bool, +) -> RelayTaskStatus { + if marker { + return RelayTaskStatus::Succeeded; + } + if running || preparing || !log_exists || size == 0 { + return RelayTaskStatus::Running; + } + // Log still growing since last poll — keep running even if pid check flaked. + if got_new_output || cursor < size { + return RelayTaskStatus::Running; + } + RelayTaskStatus::Failed +} + +/// Split poll script stdout into the metadata head and incremental log body. +/// +/// Accepts LF, CRLF, or a standalone `---` line so SSH/OS line endings cannot +/// drop the entire log payload. +fn split_poll_stdout(stdout: &str) -> (&str, &str) { + if let Some((head, output)) = stdout.split_once("---\r\n") { + return (head, output); + } + if let Some((head, output)) = stdout.split_once("---\n") { + return (head, output); + } + let mut offset = 0usize; + for line in stdout.split_inclusive('\n') { + if line.trim_end_matches(['\r', '\n']) == "---" { + return (&stdout[..offset], &stdout[offset + line.len()..]); + } + offset += line.len(); + } + (stdout, "") +} + +/// Import a locally-provisioned account into the running relay container. +/// +/// `account_json` is the serialized `ImportableAccount` produced client-side +/// by `bitfun_relay_service::admin::provision` — it contains only derived +/// artifacts (salts, Argon2id hash, wrapped master key). The file is written +/// with 0600 permissions and removed immediately after the import attempt. +pub async fn import_account( + manager: &SSHConnectionManager, + connection_id: &str, + account_json: &str, +) -> Result<()> { + let home = resolve_home(manager, connection_id).await?; + let dir = format!("{home}/{DEPLOY_STATE_DIR}"); + exec_ok( + manager, + connection_id, + &format!( + "mkdir -p {} && chmod 700 {}", + shell_quote_posix(&dir), + shell_quote_posix(&dir) + ), + ) + .await?; + let path = format!("{dir}/import-{}.json", uuid::Uuid::new_v4().as_simple()); + manager + .sftp_write(connection_id, &path, account_json.as_bytes()) + .await?; + + let quoted = shell_quote_posix(&path); + let cmd = format!( + "chmod 600 {q}; \ + dps() {{ docker ps --format '{{{{.Names}}}}' 2>/dev/null; }}; \ + dexec() {{ docker exec -i {name} /app/relay-admin --db {db} import-user; }}; \ + if docker info >/dev/null 2>&1; then :; \ + elif sg docker -c 'docker info' >/dev/null 2>&1; then \ + dps() {{ sg docker -c \"docker ps --format '{{{{.Names}}}}'\" 2>/dev/null; }}; \ + dexec() {{ sg docker -c \"docker exec -i {name} /app/relay-admin --db {db} import-user\"; }}; \ + elif sudo -n docker info >/dev/null 2>&1; then \ + dps() {{ sudo -n docker ps --format '{{{{.Names}}}}' 2>/dev/null; }}; \ + dexec() {{ sudo -n docker exec -i {name} /app/relay-admin --db {db} import-user; }}; \ + else \ + dps() {{ sudo docker ps --format '{{{{.Names}}}}' 2>/dev/null; }}; \ + dexec() {{ sudo docker exec -i {name} /app/relay-admin --db {db} import-user; }}; \ + fi; \ + if dps | grep -qx {name}; then \ + cat {q} | dexec; rc=$?; rm -f {q}; exit $rc; \ + else \ + echo 'relay container {name} is not running' >&2; rm -f {q}; exit 1; \ + fi", + q = quoted, + name = RELAY_CONTAINER_NAME, + db = RELAY_CONTAINER_DB, + ); + let (stdout, stderr, code) = manager.execute_command(connection_id, &cmd).await?; + if code != 0 { + let detail = relay_admin_error(&stdout, &stderr); + return Err(anyhow!(detail)); + } + Ok(()) +} + +/// Health-check the relay from the server itself (loopback). +pub async fn check_relay_health( + manager: &SSHConnectionManager, + connection_id: &str, + port: u16, +) -> Result { + let port = normalize_relay_port(port)?; + let (_o, _e, code) = manager + .execute_command( + connection_id, + &format!("curl -fsS -m 5 http://127.0.0.1:{port}/health >/dev/null 2>&1"), + ) + .await?; + Ok(code == 0) +} + +/// Extract the meaningful relay-admin failure line, if present. +fn relay_admin_error(stdout: &str, stderr: &str) -> String { + for line in stderr.lines().chain(stdout.lines()) { + let l = line.trim(); + if l.contains("already exists") || l.contains("Error") || l.contains("error") { + return l.trim_start_matches("Error: ").to_string(); + } + } + let tail = stderr.trim(); + if tail.is_empty() { + "account import failed".to_string() + } else { + tail.chars().take(300).collect() + } +} + +async fn resolve_home(manager: &SSHConnectionManager, connection_id: &str) -> Result { + let (out, _e, code) = manager + .execute_command(connection_id, "printf %s \"$HOME\"") + .await?; + let home = out.trim(); + if code != 0 || home.is_empty() { + return Err(anyhow!("could not resolve remote $HOME")); + } + Ok(home.to_string()) +} + +async fn exec_ok(manager: &SSHConnectionManager, connection_id: &str, command: &str) -> Result<()> { + let (stdout, stderr, code) = manager.execute_command(connection_id, command).await?; + if code != 0 { + return Err(anyhow!( + "remote command failed (exit {code}): {}", + if stderr.trim().is_empty() { + stdout.trim().chars().take(300).collect::() + } else { + stderr.trim().chars().take(300).collect::() + } + )); + } + Ok(()) +} + +/// Shared interactive prepare helpers embedded in driver scripts. +fn prepare_helpers_bash() -> &'static str { + r#" +# Privilege helpers: +# - Never use `sudo -v` when NOPASSWD is set — on many cloud images `sudo -v` +# still demands a password even though `sudo -n true` works. +# - Prefer already-root → passwordless sudo → interactive sudo / sudo su -. +# - When elevating via `su -`, keep the original HOME so ~/.bitfun paths stay valid. + +bitfun_have_passwordless_sudo() { + [ "$(id -u)" != "0" ] && sudo -n true >/dev/null 2>&1 +} + +# Run a command with the best available privilege (root / sudo -n / sudo). +bitfun_priv() { + if [ "$(id -u)" = "0" ]; then + "$@" + elif sudo -n true >/dev/null 2>&1; then + sudo -n "$@" + else + sudo "$@" + fi +} + +# For Docker install: if not root, re-exec this driver as root once. +# Passwordless path uses `sudo su -` (no prompt). Interactive path prompts once. +# Sets BITFUN_ELEVATED=1 to avoid loops. Preserves HOME for ~/.bitfun/*. +bitfun_elevate_install_driver() { + local self="$1" + if [ "$(id -u)" = "0" ] || [ "${BITFUN_ELEVATED:-0}" = "1" ]; then + return 0 + fi + local keep_home="${BITFUN_KEEP_HOME:-$HOME}" + local q_self q_home + q_self=$(printf '%q' "$self") + q_home=$(printf '%q' "$keep_home") + if bitfun_have_passwordless_sudo; then + echo ">>> Root needed for Docker install; elevating via passwordless sudo su -..." + exec sudo -n su - -c "export BITFUN_ELEVATED=1 BITFUN_KEEP_HOME=$q_home HOME=$q_home; cd $q_home 2>/dev/null || cd /; bash $q_self" + fi + echo ">>> Root needed for Docker install; elevating via sudo su - (password may be required)..." + exec sudo su - -c "export BITFUN_ELEVATED=1 BITFUN_KEEP_HOME=$q_home HOME=$q_home; cd $q_home 2>/dev/null || cd /; bash $q_self" +} + +bitfun_ensure_tools() { + local pkgs=() + command -v git >/dev/null 2>&1 || pkgs+=(git) + command -v curl >/dev/null 2>&1 || pkgs+=(curl) + command -v tar >/dev/null 2>&1 || pkgs+=(tar) + if [ "${#pkgs[@]}" -eq 0 ]; then return 0; fi + echo ">>> Installing missing tools (${pkgs[*]})..." + if [ "$(id -u)" = "0" ]; then + if command -v apt-get >/dev/null 2>&1; then apt-get update -y && apt-get install -y "${pkgs[@]}" + elif command -v dnf >/dev/null 2>&1; then dnf install -y "${pkgs[@]}" + elif command -v yum >/dev/null 2>&1; then yum install -y "${pkgs[@]}" + else echo "ERROR: missing tools (${pkgs[*]}) and no supported package manager" >&2; return 1; fi + else + if command -v apt-get >/dev/null 2>&1; then bitfun_priv apt-get update -y && bitfun_priv apt-get install -y "${pkgs[@]}" + elif command -v dnf >/dev/null 2>&1; then bitfun_priv dnf install -y "${pkgs[@]}" + elif command -v yum >/dev/null 2>&1; then bitfun_priv yum install -y "${pkgs[@]}" + else echo "ERROR: missing tools (${pkgs[*]}); install them then retry" >&2; return 1; fi + fi +} + +bitfun_fix_docker_home() { + export DOCKER_CONFIG="${DOCKER_CONFIG:-$HOME/.bitfun/docker-config}" + mkdir -p "$DOCKER_CONFIG" + chmod 700 "$DOCKER_CONFIG" 2>/dev/null || true + if [ -e "$HOME/.docker" ] && [ ! -w "$HOME/.docker" ]; then + echo ">>> $HOME/.docker is not writable (often root-owned buildx lock)." + echo ">>> Fixing ownership..." + if [ "$(id -u)" = "0" ]; then + # Prefer original deploy user if HOME still points at their tree. + local owner + owner="$(stat -c '%U:%G' "$HOME" 2>/dev/null || echo root:root)" + chown -R "$owner" "$HOME/.docker" 2>/dev/null \ + || chown -R "$(id -un):$(id -gn)" "$HOME/.docker" + else + bitfun_priv chown -R "$(id -un):$(id -gn)" "$HOME/.docker" + fi + fi + if [ -e "$HOME/.docker" ] && [ ! -w "$HOME/.docker" ]; then + echo ">>> Still not writable; using isolated DOCKER_CONFIG=$DOCKER_CONFIG" + fi +} + +bitfun_start_docker_daemon() { + if docker info >/dev/null 2>&1 || sudo -n docker info >/dev/null 2>&1; then return 0; fi + echo ">>> Starting Docker daemon..." + if [ "$(id -u)" = "0" ]; then + systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true + elif sudo -n true >/dev/null 2>&1; then + sudo -n systemctl enable --now docker 2>/dev/null || sudo -n service docker start 2>/dev/null || true + else + echo ">>> sudo password may be required to start Docker..." + sudo systemctl enable --now docker 2>/dev/null || sudo service docker start 2>/dev/null || true + fi + sleep 1 +} + +# Sets BITFUN_DOCKER_MODE to: direct | sg | sudo +bitfun_resolve_docker_mode() { + bitfun_fix_docker_home + bitfun_start_docker_daemon + if docker info >/dev/null 2>&1; then + BITFUN_DOCKER_MODE=direct + return 0 + fi + if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx docker; then + if sg docker -c 'docker info' >/dev/null 2>&1; then + BITFUN_DOCKER_MODE=sg + return 0 + fi + elif getent group docker 2>/dev/null | grep -qE "(^|:|,)$(id -un)(,|$)"; then + echo ">>> User is in docker group but session has not activated it; using sg docker." + if sg docker -c 'docker info' >/dev/null 2>&1; then + BITFUN_DOCKER_MODE=sg + return 0 + fi + fi + if sudo -n docker info >/dev/null 2>&1; then + echo ">>> Using passwordless sudo for Docker." + BITFUN_DOCKER_MODE=sudo + return 0 + fi + echo ">>> Docker needs interactive sudo (enter password if prompted)..." + if sudo docker info >/dev/null 2>&1; then + BITFUN_DOCKER_MODE=sudo + return 0 + fi + echo "ERROR: cannot reach Docker daemon" >&2 + return 1 +} + +bitfun_docker() { + case "${BITFUN_DOCKER_MODE:-direct}" in + sg) sg docker -c "docker $*" ;; + sudo) + if sudo -n true >/dev/null 2>&1; then sudo -n docker "$@"; else sudo docker "$@"; fi + ;; + *) docker "$@" ;; + esac +} + +bitfun_run_deploy_sh() { + local dir="$1" + local port="${RELAY_PORT:-9700}" + # DOCKER_BUILDKIT is required for Dockerfile cargo registry/git/target mounts. + case "${BITFUN_DOCKER_MODE:-direct}" in + sudo) + if sudo -n true >/dev/null 2>&1; then + sudo -n -E env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ + DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ + bash "$dir/deploy.sh" + else + sudo -E env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ + DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ + bash "$dir/deploy.sh" + fi + ;; + sg) + sg docker -c "env RELAY_PORT='$port' RELAY_CARGO_BUILD_JOBS='${RELAY_CARGO_BUILD_JOBS:-}' DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain DOCKER_CONFIG='${DOCKER_CONFIG:-}' bash '$dir/deploy.sh'" + ;; + *) + env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ + bash "$dir/deploy.sh" + ;; + esac +} +"# +} + +/// Interactive driver: prepare (TTY/sudo OK) → nohup body → tail -f log. +fn interactive_driver_script(stem: &str, kind: &str) -> String { + let helpers = prepare_helpers_bash(); + format!( + r#"#!/usr/bin/env bash +set -euo pipefail +D="$HOME/{DEPLOY_STATE_DIR}" +STEM="{stem}" +LOG="$D/$STEM.log" +PIDF="$D/$STEM.pid" +BODY="$D/$STEM-body.sh" +mkdir -p "$D" +chmod 700 "$D" +{helpers} + +echo ">>> BitFun relay {kind}: interactive prepare" +echo ">>> Closing the wizard stops this task." +# Preserve the SSH user's home across root elevation (su - would otherwise use /root). +export BITFUN_KEEP_HOME="${{BITFUN_KEEP_HOME:-$HOME}}" +# install: elevate to root first (passwordless sudo su - when available). +if [ "{kind}" = "install" ]; then + bitfun_elevate_install_driver "$D/$STEM.sh" +fi +# After elevation HOME may need restoring from BITFUN_KEEP_HOME. +if [ -n "${{BITFUN_KEEP_HOME:-}}" ]; then + export HOME="$BITFUN_KEEP_HOME" + D="$HOME/{DEPLOY_STATE_DIR}" + LOG="$D/$STEM.log" + PIDF="$D/$STEM.pid" + BODY="$D/$STEM-body.sh" + PREPARE_FLAG="$D/$STEM.preparing" +fi +PREPARE_FLAG="$D/$STEM.preparing" +# Keep/refresh the preparing flag seeded by start_task — do not clear it first +# or early polls can race into "failed". +rm -f "$PIDF" +: >"$LOG" +touch "$PREPARE_FLAG" +echo ">>> prepare starting (uid=$(id -u) home=$HOME)" | tee -a "$LOG" +cleanup_prepare() {{ rm -f "$PREPARE_FLAG"; }} +trap cleanup_prepare EXIT +bitfun_ensure_tools +export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" +mkdir -p "$DOCKER_CONFIG" + +# install: Docker is not present yet — do NOT resolve daemon access here. +if [ "{kind}" = "install" ]; then + BITFUN_DOCKER_MODE=direct +else + bitfun_resolve_docker_mode +fi +export BITFUN_DOCKER_MODE + +# Ensure compose plugin when deploying +if [ "{kind}" = "deploy" ]; then + if ! docker compose version >/dev/null 2>&1 \ + && ! command -v docker-compose >/dev/null 2>&1 \ + && ! sudo -n docker compose version >/dev/null 2>&1 \ + && ! sudo docker compose version >/dev/null 2>&1; then + echo ">>> docker compose missing; attempting install..." + if [ "$(id -u)" = "0" ]; then + apt-get update -y && apt-get install -y docker-compose-plugin 2>/dev/null \ + || yum install -y docker-compose-plugin 2>/dev/null || true + else + bitfun_priv apt-get update -y && bitfun_priv apt-get install -y docker-compose-plugin 2>/dev/null \ + || bitfun_priv yum install -y docker-compose-plugin 2>/dev/null || true + fi + fi +fi + +# Docker install: run in foreground as (elevated) root when possible. +# Long deploy builds still go through nohup so the wizard can follow the log. +if [ "{kind}" = "install" ]; then + echo ">>> Installing Docker..." | tee -a "$LOG" + export BITFUN_KEEP_HOME="${{BITFUN_KEEP_HOME:-$HOME}}" + set +e + if command -v stdbuf >/dev/null 2>&1; then + stdbuf -oL -eL env BITFUN_KEEP_HOME="$BITFUN_KEEP_HOME" bash "$BODY" 2>&1 | tee -a "$LOG" + else + env BITFUN_KEEP_HOME="$BITFUN_KEEP_HOME" bash "$BODY" 2>&1 | tee -a "$LOG" + fi + code=${{PIPESTATUS[0]}} + set -e + rm -f "$PREPARE_FLAG" "$PIDF" + trap - EXIT + if [ "$code" -ne 0 ]; then + echo "ERROR: Docker install failed (exit $code)" | tee -a "$LOG" + exit "$code" + fi + echo ">>> Docker install finished." | tee -a "$LOG" + exit 0 +fi + +if command -v stdbuf >/dev/null 2>&1; then RUNNER=(stdbuf -oL -eL bash); else RUNNER=(bash); fi +echo ">>> Starting background task (log: $LOG)" | tee -a "$LOG" +nohup env BITFUN_DOCKER_MODE="$BITFUN_DOCKER_MODE" DOCKER_CONFIG="$DOCKER_CONFIG" \ + RELAY_CARGO_BUILD_JOBS="${{RELAY_CARGO_BUILD_JOBS:-}}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ + "${{RUNNER[@]}}" "$BODY" >"$LOG" 2>&1 < /dev/null & +echo $! >"$PIDF" +rm -f "$PREPARE_FLAG" +trap - EXIT +echo ">>> Following log..." +exec tail -n +1 -f "$LOG" +"#, + DEPLOY_STATE_DIR = DEPLOY_STATE_DIR, + stem = stem, + kind = kind, + helpers = helpers, + ) +} + +/// Docker install body (usually run as root after driver elevation). +fn install_docker_body_script() -> String { + let helpers = prepare_helpers_bash(); + format!( + r#"#!/usr/bin/env bash +set -euo pipefail +{helpers} +# Prefer the original SSH user's home (set by elevated driver). +if [ -n "${{BITFUN_KEEP_HOME:-}}" ]; then export HOME="$BITFUN_KEEP_HOME"; fi +export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" +mkdir -p "$DOCKER_CONFIG" +# When elevated as root, add the original login user to the docker group. +DEPLOY_USER="${{SUDO_USER:-}}" +if [ -z "$DEPLOY_USER" ] || [ "$DEPLOY_USER" = "root" ]; then + if [ -n "${{BITFUN_KEEP_HOME:-}}" ] && [ -d "${{BITFUN_KEEP_HOME}}" ]; then + DEPLOY_USER="$(stat -c '%U' "$BITFUN_KEEP_HOME" 2>/dev/null || true)" + fi +fi +if [ -z "$DEPLOY_USER" ] || [ "$DEPLOY_USER" = "root" ]; then + DEPLOY_USER="$(id -un)" +fi +echo ">>> Installing Docker (get.docker.com) as uid=$(id -u) for user=$DEPLOY_USER ..." +curl -fsSL --retry 3 https://get.docker.com -o /tmp/bitfun-get-docker.sh +if [ "$(id -u)" = "0" ]; then + sh /tmp/bitfun-get-docker.sh + systemctl enable --now docker + usermod -aG docker "$DEPLOY_USER" || true +else + bitfun_priv sh /tmp/bitfun-get-docker.sh + bitfun_priv systemctl enable --now docker + bitfun_priv usermod -aG docker "$DEPLOY_USER" +fi +rm -f /tmp/bitfun-get-docker.sh +bitfun_fix_docker_home +# Verify without relying on a new login session +if docker info >/dev/null 2>&1 \ + || sg docker -c 'docker info' >/dev/null 2>&1 \ + || sudo -n docker info >/dev/null 2>&1 \ + || sudo docker info >/dev/null 2>&1; then + echo ">>> Docker installed and reachable: $(docker --version 2>/dev/null || sudo -n docker --version 2>/dev/null || true)" + echo {TASK_DONE_MARKER} +else + echo "ERROR: Docker installed but daemon is not reachable" >&2 + exit 1 +fi +"#, + helpers = helpers, + TASK_DONE_MARKER = TASK_DONE_MARKER, + ) +} + +/// Sync BitFun source: prefer shallow git update, fall back to tarball. +/// +/// `src` must be the BitFun-managed path (`~/.bitfun/relay-src`). Destructive +/// replace is safe only there — never use `$HOME/bitfun` / `$HOME/BitFun`. +fn sync_source_bash() -> String { + format!( + r#" +bitfun_sync_source() {{ + # Destination is always ~/.bitfun/relay-src (repo ROOT), never $HOME/BitFun. + # `git clone ` without a path would create ./BitFun — we always pass "$src". + # Tarball extracts BitFun-main/; we use --strip-components=1 into "$src". + local src="$1" + local git_url="{REPO_GIT_URL}" + local branch="{REPO_GIT_BRANCH}" + local tarball_url="{REPO_TARBALL_URL}" + local managed_prefix="$HOME/.bitfun/" + local relay_deploy_sh="src/apps/relay-server/deploy.sh" + + # Refuse to touch anything outside ~/.bitfun/ (protect user project dirs). + case "$src" in + "$managed_prefix"*) ;; + *) + echo "ERROR: refusing to sync source outside ~/.bitfun/: $src" >&2 + return 1 + ;; + esac + + bitfun_replace_managed_src() {{ + rm -rf "$src" + mkdir -p "$(dirname "$src")" + }} + + # Ensure "$src" is the repo root (contains src/apps/relay-server), not a + # nested BitFun/ or BitFun-main/ from a mistaken clone/extract. + bitfun_assert_source_layout() {{ + if [ -f "$src/$relay_deploy_sh" ]; then + return 0 + fi + local nested="" + if [ -f "$src/BitFun/$relay_deploy_sh" ]; then + nested="$src/BitFun" + elif [ -f "$src/BitFun-main/$relay_deploy_sh" ]; then + nested="$src/BitFun-main" + elif [ -f "$src/bitfun/$relay_deploy_sh" ]; then + nested="$src/bitfun" + fi + if [ -n "$nested" ]; then + echo ">>> Flattening nested checkout ($(basename "$nested")) into $src..." + # Move nested repo root contents up one level inside the managed dir only. + shopt -s dotglob nullglob + local tmp="$src.__flatten_$$" + mv "$nested" "$tmp" + rm -rf "$src" + mv "$tmp" "$src" + shopt -u dotglob nullglob + fi + if [ ! -f "$src/$relay_deploy_sh" ]; then + echo "ERROR: source layout invalid under $src (missing $relay_deploy_sh)" >&2 + return 1 + fi + }} + + bitfun_fetch_tarball() {{ + echo ">>> Downloading BitFun source (tarball fallback)..." + command -v curl >/dev/null 2>&1 || bitfun_ensure_tools + command -v tar >/dev/null 2>&1 || bitfun_ensure_tools + bitfun_replace_managed_src + mkdir -p "$src" + # Archive root is BitFun-main/; strip so files land directly in "$src". + curl -fsSL --retry 3 "$tarball_url" | tar xz -C "$src" --strip-components=1 + bitfun_assert_source_layout + }} + + if ! command -v git >/dev/null 2>&1; then + bitfun_ensure_tools || true + fi + + if command -v git >/dev/null 2>&1; then + if [ -d "$src/.git" ]; then + echo ">>> Updating BitFun source (git fetch)..." + git -C "$src" remote set-url origin "$git_url" 2>/dev/null || true + if git -C "$src" fetch --depth 1 origin "$branch" \ + && git -C "$src" checkout -f -B "$branch" "origin/$branch" \ + && git -C "$src" clean -fd \ + && bitfun_assert_source_layout; then + echo ">>> Source updated to $(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)" + return 0 + fi + echo ">>> git update failed; recloning managed source..." + bitfun_replace_managed_src + elif [ -e "$src" ]; then + echo ">>> Managed source exists but is not a git checkout; replacing..." + bitfun_replace_managed_src + fi + echo ">>> Cloning into $src (explicit path; not default BitFun/)..." + mkdir -p "$(dirname "$src")" + # Explicit destination avoids creating $PWD/BitFun from the repo name. + if git clone --depth 1 --branch "$branch" "$git_url" "$src" \ + && bitfun_assert_source_layout; then + echo ">>> Source cloned at $(git -C "$src" rev-parse --short HEAD 2>/dev/null || echo unknown)" + return 0 + fi + echo ">>> git clone failed; falling back to tarball" + else + echo ">>> git unavailable; using tarball fallback" + fi + bitfun_fetch_tarball +}} +"#, + REPO_GIT_URL = REPO_GIT_URL, + REPO_GIT_BRANCH = REPO_GIT_BRANCH, + REPO_TARBALL_URL = REPO_TARBALL_URL, + ) +} + +/// Non-interactive body for deploy (runs under nohup after prepare). +fn deploy_body_script(port: u16) -> String { + let helpers = prepare_helpers_bash(); + let sync = sync_source_bash(); + format!( + r#"#!/usr/bin/env bash +set -euo pipefail +{helpers} +{sync} +export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" +export DOCKER_BUILDKIT=1 +export COMPOSE_DOCKER_CLI_BUILD=1 +export BUILDKIT_PROGRESS=plain +BITFUN_DOCKER_MODE="${{BITFUN_DOCKER_MODE:-direct}}" +if [ "$BITFUN_DOCKER_MODE" = "direct" ] && ! docker info >/dev/null 2>&1; then + bitfun_resolve_docker_mode +fi +# Prefer the port staged by the desktop wizard; fall back to embedded default. +PORT_FILE="$HOME/{DEPLOY_STATE_DIR}/relay.port" +if [ -f "$PORT_FILE" ]; then + RELAY_PORT="$(tr -d '[:space:]' < "$PORT_FILE")" +fi +RELAY_PORT="${{RELAY_PORT:-{port}}}" +export RELAY_PORT +echo ">>> Using RELAY_PORT=$RELAY_PORT" +SRC="$HOME/{SOURCE_DIR}" +bitfun_sync_source "$SRC" +cd "$SRC/src/apps/relay-server" +# Persist for compose interpolation (and subsequent start/restart scripts). +printf 'RELAY_PORT=%s\n' "$RELAY_PORT" > .env +chmod 600 .env 2>/dev/null || true +# Until main ships templated compose, rewrite hardcoded 9700 for custom ports. +if [ -f docker-compose.yml ] && ! grep -q '\${{RELAY_PORT' docker-compose.yml; then + sed -i.bak \ + -e "s/:9700:9700/:${{RELAY_PORT}}:${{RELAY_PORT}}/g" \ + -e "s/RELAY_PORT=9700/RELAY_PORT=${{RELAY_PORT}}/g" \ + -e "s|127\\.0\\.0\\.1:9700/health|127.0.0.1:${{RELAY_PORT}}/health|g" \ + docker-compose.yml 2>/dev/null || true +fi +MEM_KB=$(awk '/MemTotal/ {{print $2}}' /proc/meminfo 2>/dev/null || echo 0) +if [ "${{RELAY_CARGO_BUILD_JOBS:-}}" = "" ] && [ "$MEM_KB" -lt 2097152 ]; then + export RELAY_CARGO_BUILD_JOBS=1 + echo ">>> Low memory detected; using RELAY_CARGO_BUILD_JOBS=1" +fi +echo ">>> Building and starting the relay container on port $RELAY_PORT (this can take a while)..." +bitfun_run_deploy_sh "$(pwd)" +echo {TASK_DONE_MARKER} +"#, + helpers = helpers, + sync = sync, + DEPLOY_STATE_DIR = DEPLOY_STATE_DIR, + SOURCE_DIR = SOURCE_DIR, + port = port, + TASK_DONE_MARKER = TASK_DONE_MARKER, + ) +} + +#[cfg(test)] +mod tests { + use super::{ + classify_docker_access, decide_task_status, parse_preflight, split_poll_stdout, + DockerAccessMode, RelayTaskStatus, + }; + + #[test] + fn decide_status_pending_before_pty_is_running() { + assert_eq!( + decide_task_status(false, false, true, true, 0, 0, false), + RelayTaskStatus::Running + ); + assert_eq!( + decide_task_status(false, false, false, false, 0, 0, false), + RelayTaskStatus::Running + ); + } + + #[test] + fn decide_status_growing_log_without_pid_is_running() { + assert_eq!( + decide_task_status(false, false, false, true, 1000, 100, true), + RelayTaskStatus::Running + ); + } + + #[test] + fn decide_status_dead_pid_stale_log_is_failed() { + assert_eq!( + decide_task_status(false, false, false, true, 1000, 1000, false), + RelayTaskStatus::Failed + ); + } + + #[test] + fn split_poll_stdout_accepts_lf() { + let (head, out) = split_poll_stdout("running=1\nsize=12\nmarker=0\n---\nhello\n"); + assert!(head.contains("running=1")); + assert_eq!(out, "hello\n"); + } + + #[test] + fn split_poll_stdout_accepts_crlf() { + let (head, out) = split_poll_stdout("running=1\r\nsize=12\r\nmarker=0\r\n---\r\nworld\r\n"); + assert!(head.contains("running=1")); + assert_eq!(out, "world\r\n"); + } + + #[test] + fn split_poll_stdout_missing_marker_yields_empty_body() { + let (head, out) = split_poll_stdout("running=0\nsize=0\nmarker=0\n"); + assert!(head.contains("running=0")); + assert_eq!(out, ""); + } + + #[test] + fn classify_broken_docker_home() { + assert_eq!( + classify_docker_access(true, "ok", true, true, false, true, false), + DockerAccessMode::BrokenDockerHome + ); + } + + #[test] + fn classify_group_inactive() { + assert_eq!( + classify_docker_access(true, "unreachable", false, true, true, false, true), + DockerAccessMode::GroupInactive + ); + } + + #[test] + fn parse_preflight_reads_new_fields() { + let out = r#" +os=Linux +arch=x86_64 +home=/home/ubuntu +docker=1 +compose=1 +daemon=ok +curl=1 +tar=1 +sudo=0 +sudo_needs_password=1 +active_docker_group=0 +in_docker_group_file=1 +docker_home_writable=0 +mem_kb=2097152 +port_busy=0 +container=1 +container_running=1 +existing_port=9700 +healthy=1 +port_owned=0 +"#; + let pf = parse_preflight(out, 9701); + assert!(pf.arch_supported); + assert_eq!(pf.docker_access_mode, DockerAccessMode::BrokenDockerHome); + assert!(pf.in_docker_group_file); + assert!(!pf.docker_home_writable); + assert!(pf.tar_available); + assert!(pf.sudo_needs_password); + assert_eq!(pf.probed_port, 9701); + assert!(pf.container_exists); + assert!(pf.container_running); + assert_eq!(pf.existing_relay_port, 9700); + assert!(pf.relay_healthy); + assert!(!pf.port_owned_by_relay); + } +} diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs index c053c8eb05..d12a29c4f0 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs @@ -41,6 +41,14 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { } async fn write_file(&self, path: &str, contents: &[u8]) -> anyhow::Result<()> { + if let Some((parent, _)) = path + .rsplit_once('/') + .filter(|(parent, _)| !parent.is_empty()) + { + self.file_service + .create_dir_all(&self.connection_id, parent) + .await?; + } self.file_service .write_file(&self.connection_id, path, contents) .await diff --git a/src/crates/services/services-integrations/src/script_tool.rs b/src/crates/services/services-integrations/src/script_tool.rs index 057aff1026..4fa3051002 100644 --- a/src/crates/services/services-integrations/src/script_tool.rs +++ b/src/crates/services/services-integrations/src/script_tool.rs @@ -6,6 +6,7 @@ use bitfun_runtime_ports::{ ScriptToolLoadRequest, ScriptToolLoadResponse, ScriptToolRuntime, ScriptToolRuntimeAvailability, }; +use bitfun_services_core::process_tree::ProcessTreeChild; use serde::Deserialize; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -13,8 +14,8 @@ use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{ChildStdin, ChildStdout, Command}; use tokio::sync::{oneshot, watch, Mutex, OwnedSemaphorePermit, RwLock, Semaphore}; const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -427,7 +428,7 @@ async fn read_protocol_frame( struct NodeWorker { stdin: Mutex, - child: Mutex, + child: Mutex, pending: Arc>>, next_request_id: AtomicU64, response_token: String, @@ -447,21 +448,23 @@ impl NodeWorker { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = command.spawn().map_err(|error| { - PortError::new( - PortErrorKind::NotAvailable, - format!("failed to start JavaScript tool worker: {error}"), - ) - })?; + .env_remove("NODE_OPTIONS") + .env_remove("NODE_PATH"); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("failed to start JavaScript tool worker: {error}"), + ) + })?; let stdin = child - .stdin - .take() + .take_stdin() .ok_or_else(|| PortError::new(PortErrorKind::Backend, "worker stdin is unavailable"))?; - let stdout = child.stdout.take().ok_or_else(|| { + let stdout = child.take_stdout().ok_or_else(|| { PortError::new(PortErrorKind::Backend, "worker stdout is unavailable") })?; - let stderr = child.stderr.take().ok_or_else(|| { + let stderr = child.take_stderr().ok_or_else(|| { PortError::new(PortErrorKind::Backend, "worker stderr is unavailable") })?; let mut reader = BufReader::new(stdout); @@ -651,12 +654,16 @@ impl NodeWorker { if matches!(child.try_wait(), Ok(Some(_))) { return Ok(()); } - child.kill().await.map_err(|error| { - PortError::new( - PortErrorKind::Backend, - format!("failed to stop script tool worker: {error}"), - ) - }) + child + .terminate(CANCEL_GRACE_PERIOD) + .await + .map(|_| ()) + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("failed to stop script tool worker: {error}"), + ) + }) } async fn is_running(&self) -> bool { @@ -754,7 +761,7 @@ fn port_error_kind(kind: Option<&str>) -> PortErrorKind { } pub struct NodeScriptToolRuntime { - executable: Option, + executable: RwLock>, workers: RwLock>>, load_gate: Mutex<()>, } @@ -768,12 +775,32 @@ impl Default for NodeScriptToolRuntime { impl NodeScriptToolRuntime { pub fn discover() -> Self { Self { - executable: which::which("node").ok(), + executable: RwLock::new(which::which("node").ok()), workers: RwLock::new(HashMap::new()), load_gate: Mutex::new(()), } } + async fn remember_discovered_executable(&self, discovered: Option) -> Option { + if let Some(executable) = self.executable.read().await.clone() { + return Some(executable); + } + let discovered = discovered?; + let mut executable = self.executable.write().await; + if executable.is_none() { + *executable = Some(discovered); + } + executable.clone() + } + + async fn resolve_executable(&self) -> Option { + if let Some(executable) = self.executable.read().await.clone() { + return Some(executable); + } + self.remember_discovered_executable(which::which("node").ok()) + .await + } + async fn evict_worker(&self, target_id: &str, worker: &Arc) { let mut workers = self.workers.write().await; if workers @@ -790,13 +817,16 @@ impl NodeScriptToolRuntime { #[async_trait] impl ScriptToolRuntime for NodeScriptToolRuntime { async fn availability(&self) -> ScriptToolRuntimeAvailability { - match &self.executable { - Some(executable) => ScriptToolRuntimeAvailability::Available { - executable: executable.to_string_lossy().into_owned(), - version: "verified when first enabled".to_string(), + match self.resolve_executable().await { + Some(executable) => match probe_node_version(&executable).await { + Ok(version) => ScriptToolRuntimeAvailability::Available { + executable: executable.to_string_lossy().into_owned(), + version, + }, + Err(reason) => ScriptToolRuntimeAvailability::Unavailable { reason }, }, None => ScriptToolRuntimeAvailability::Unavailable { - reason: "Node.js was not found; discovered tools remain disabled".to_string(), + reason: "BitFun could not find Node.js for external tools".to_string(), }, } } @@ -839,7 +869,7 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { async fn load(&self, request: ScriptToolLoadRequest) -> PortResult { let _guard = self.load_gate.lock().await; - let executable = self.executable.as_ref().ok_or_else(|| { + let executable = self.resolve_executable().await.ok_or_else(|| { PortError::new(PortErrorKind::NotAvailable, "Node.js is not available") })?; if request.target_id.is_empty() @@ -854,7 +884,7 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { if let Some(previous) = self.workers.write().await.remove(&request.target_id) { previous.dispose(&request.target_id).await?; } - let worker = NodeWorker::spawn(executable, &request.working_directory).await?; + let worker = NodeWorker::spawn(&executable, &request.working_directory).await?; let payload = serde_json::to_value(&request).map_err(|error| { PortError::new( PortErrorKind::InvalidRequest, @@ -974,3 +1004,72 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { worker.dispose(target_id).await } } + +async fn probe_node_version(executable: &PathBuf) -> Result { + let mut command = Command::new(executable); + command + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .env_remove("NODE_OPTIONS") + .env_remove("NODE_PATH"); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|_| "BitFun could not start Node.js for external tools".to_string())?; + let stdout = child + .take_stdout() + .ok_or_else(|| "Node.js version output was unavailable".to_string())?; + let probe = async move { + let mut output = Vec::with_capacity(65); + stdout + .take(65) + .read_to_end(&mut output) + .await + .map_err(|_| "Node.js version output could not be read".to_string())?; + let status = child + .wait() + .await + .map_err(|_| "Node.js version probe failed".to_string())?; + Ok::<_, String>((status, output)) + }; + let (status, output) = tokio::time::timeout(std::time::Duration::from_secs(2), probe) + .await + .map_err(|_| "Node.js version probe timed out".to_string())??; + if !status.success() { + return Err("Node.js version probe failed".to_string()); + } + let version = String::from_utf8(output) + .map_err(|_| "Node.js returned an invalid version".to_string())? + .trim() + .to_string(); + if version.is_empty() || version.len() > 64 || !version.starts_with('v') { + return Err("Node.js returned an invalid version".to_string()); + } + Ok(version) +} + +#[cfg(test)] +mod tests { + use super::NodeScriptToolRuntime; + use std::path::PathBuf; + use tokio::sync::{Mutex, RwLock}; + + #[tokio::test] + async fn missing_node_path_can_recover_without_replacing_the_runtime() { + let runtime = NodeScriptToolRuntime { + executable: RwLock::new(None), + workers: RwLock::new(Default::default()), + load_gate: Mutex::new(()), + }; + let discovered = PathBuf::from("C:/Program Files/nodejs/node.exe"); + + assert_eq!( + runtime + .remember_discovered_executable(Some(discovered.clone())) + .await, + Some(discovered.clone()) + ); + assert_eq!(*runtime.executable.read().await, Some(discovered)); + } +} diff --git a/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs b/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs index 4d93fc9e77..44c81c2c1b 100644 --- a/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs +++ b/src/crates/services/services-integrations/src/workspace_search/flashgrep/client.rs @@ -9,10 +9,10 @@ use std::{ }; use async_trait::async_trait; -use bitfun_services_core::process_manager; +use bitfun_services_core::{process_manager, process_tree::ProcessTreeChild}; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}, - process::{Child, ChildStderr, ChildStdin, ChildStdout}, + process::{ChildStderr, ChildStdin, ChildStdout}, sync::{mpsc, Mutex}, time::{sleep, timeout}, }; @@ -62,7 +62,7 @@ struct ManagedClientState { #[derive(Debug)] struct AsyncDaemonClient { - child: StdMutex>, + child: StdMutex>, protocol: ProtocolClient, writer_task: StdMutex>>, reader_task: StdMutex>>, @@ -440,16 +440,14 @@ impl AsyncDaemonClient { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - process_manager::configure_process_group(&mut command); - - let mut child = command.spawn()?; - let stdin = child.stdin.take().ok_or_else(|| { + let mut child = ProcessTreeChild::spawn(&mut command).await?; + let stdin = child.take_stdin().ok_or_else(|| { AppError::Protocol("flashgrep stdio backend did not provide stdin".into()) })?; - let stdout = child.stdout.take().ok_or_else(|| { + let stdout = child.take_stdout().ok_or_else(|| { AppError::Protocol("flashgrep stdio backend did not provide stdout".into()) })?; - let stderr = child.stderr.take(); + let stderr = child.take_stderr(); let (protocol, write_rx) = ProtocolClient::channel("flashgrep stdio backend"); @@ -555,11 +553,11 @@ impl AsyncDaemonClient { wait_result?; Ok(()) } - Err(_) => { - process_manager::terminate_child_process_tree(child, Duration::from_millis(750)) - .await - .map_err(AppError::Io) - } + Err(_) => child + .terminate(Duration::from_millis(750)) + .await + .map(|_| ()) + .map_err(AppError::Io), } } @@ -670,7 +668,7 @@ impl AsyncDaemonClient { self.protocol.reject_pending(message.into()).await; } - fn take_child_for_drop(&self) -> Option { + fn take_child_for_drop(&self) -> Option { take_std_option(&self.child) } @@ -692,7 +690,7 @@ impl Drop for AsyncDaemonClient { self.mark_closed(); self.abort_background_tasks_for_drop(); if let Some(child) = self.take_child_for_drop() { - process_manager::spawn_child_process_tree_cleanup(child, DROP_CLEANUP_TIMEOUT); + child.spawn_cleanup(DROP_CLEANUP_TIMEOUT); } } } diff --git a/src/crates/services/services-integrations/tests/mcp_contracts.rs b/src/crates/services/services-integrations/tests/mcp_contracts.rs index 87b9b17af6..74b00b0570 100644 --- a/src/crates/services/services-integrations/tests/mcp_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_contracts.rs @@ -55,6 +55,8 @@ fn make_mcp_config( command: command.map(str::to_string), args: Vec::new(), env: HashMap::new(), + working_directory: None, + inherit_parent_environment: None, headers: HashMap::new(), url: url.map(str::to_string), auto_start: true, @@ -63,6 +65,7 @@ fn make_mcp_config( capabilities: Vec::new(), settings: Default::default(), oauth: None, + oauth_enabled: None, xaa: None, } } @@ -1571,6 +1574,8 @@ fn mcp_server_config_preserves_transport_defaults_and_validation_contract() { command: Some("npx".to_string()), args: vec!["server".to_string()], env: Default::default(), + working_directory: None, + inherit_parent_environment: None, headers: Default::default(), url: None, auto_start: true, @@ -1579,6 +1584,7 @@ fn mcp_server_config_preserves_transport_defaults_and_validation_contract() { capabilities: Vec::new(), settings: Default::default(), oauth: None, + oauth_enabled: None, xaa: None, }; assert_eq!(local.resolved_transport(), MCPServerTransport::Stdio); @@ -1605,6 +1611,86 @@ fn mcp_server_config_preserves_transport_defaults_and_validation_contract() { .expect("remote streamable-http config is valid"); } +#[test] +fn mcp_server_config_preserves_an_optional_local_working_directory() { + let config: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "external-local", + "name": "External local", + "type": "local", + "command": "node", + "args": ["server.js"], + "workingDirectory": "C:/workspace/project", + "autoStart": true, + "enabled": true, + "location": "built-in", + "capabilities": [], + "settings": {} + })) + .unwrap(); + + assert_eq!( + config.working_directory.as_deref(), + Some("C:/workspace/project") + ); + assert_eq!( + serde_json::to_value(config).unwrap()["workingDirectory"], + "C:/workspace/project" + ); +} + +#[test] +fn remote_mcp_oauth_can_be_explicitly_disabled_without_changing_legacy_default() { + let disabled: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "remote-static-auth", + "name": "Remote static auth", + "type": "remote", + "transport": "streamable-http", + "url": "https://example.test/mcp", + "oauthEnabled": false, + "location": "built-in" + })) + .unwrap(); + assert!(!disabled.remote_oauth_enabled()); + + let legacy: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "remote-legacy", + "name": "Remote legacy", + "type": "remote", + "transport": "streamable-http", + "url": "https://example.test/mcp", + "location": "user" + })) + .unwrap(); + assert!(legacy.remote_oauth_enabled()); +} + +#[test] +fn local_mcp_can_disable_parent_environment_inheritance_without_changing_legacy_default() { + let restricted: MCPServerConfig = serde_json::from_value(serde_json::json!({ + "id": "external-local", + "name": "External local", + "type": "local", + "command": "node", + "args": [], + "env": {"ALLOWED_TOKEN": "explicit"}, + "inheritParentEnvironment": false, + "autoStart": true, + "enabled": true, + "location": "built-in" + })) + .unwrap(); + assert!(!restricted.inherits_parent_environment()); + + let legacy = make_mcp_config( + "legacy-local", + ConfigLocation::User, + MCPServerType::Local, + Some("node"), + None, + ); + assert!(legacy.inherits_parent_environment()); +} + #[test] fn mcp_oauth_session_snapshot_preserves_camel_case_status_contract() { let snapshot = MCPRemoteOAuthSessionSnapshot::new( @@ -1677,6 +1763,8 @@ fn mcp_cursor_format_helpers_preserve_cursor_compatibility_contract() { command: None, args: Vec::new(), env: Default::default(), + working_directory: None, + inherit_parent_environment: None, headers: std::collections::HashMap::from([( "Authorization".to_string(), "Bearer token".to_string(), @@ -1688,6 +1776,7 @@ fn mcp_cursor_format_helpers_preserve_cursor_compatibility_contract() { capabilities: Vec::new(), settings: Default::default(), oauth: None, + oauth_enabled: None, xaa: None, }; diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 0bf97046dc..65bc878758 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -27,22 +27,22 @@ use bitfun_services_integrations::remote_connect::{ resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, DeviceIdentity, - ImageAttachment, KeyPair, PairingProtocol, PairingState, QrGenerator, QrPayload, RelayMessage, - RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, - RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, - RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, - RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, - RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, - RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, - RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, - RemoteImageContext, RemoteImageContextAdapter, RemoteModelCapabilityFact, RemoteModelCatalog, - RemoteModelCatalogFacts, RemoteModelConfig, RemoteModelFacts, RemoteReasoningModeFact, - RemoteRecentWorkspaceFacts, RemoteResponse, RemoteSessionMetadata, RemoteSessionStateTracker, - RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, - RemoteTerminalPrewarmRequest, RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, - RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, - REMOTE_FILE_MAX_READ_BYTES, + ImageAttachment, KeyPair, PairingChallenge, PairingProtocol, PairingResponse, PairingState, + QrGenerator, QrPayload, RelayMessage, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, + RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, + RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, + RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, + RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, + RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, + RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteImageContextAdapter, + RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelConfig, + RemoteModelFacts, RemoteReasoningModeFact, RemoteRecentWorkspaceFacts, RemoteResponse, + RemoteSessionMetadata, RemoteSessionStateTracker, RemoteSessionTrackerHost, + RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, RemoteTerminalPrewarmRequest, + RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, + RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, + RemoteWorkspaceUpdate, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, }; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -95,10 +95,46 @@ fn remote_connect_qr_and_relay_primitives_live_in_services_owner() { version: 1, }; - let url = QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN"); + let url = QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN", None); assert!(url.starts_with("https://mobile.example.com/#/pair?")); assert!(url.contains("relay=wss%3A%2F%2Frelay.example.com%2Fsocket")); assert!(url.contains("lang=zh-CN")); + assert!(!url.contains("auth=account")); + + let account_url = QrGenerator::build_url( + &payload, + "https://mobile.example.com/", + "zh-CN", + Some("alice"), + ); + assert!(account_url.contains("auth=account")); + assert!(account_url.contains("user=alice")); + + let auth_only_url = + QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN", Some("")); + assert!(auth_only_url.contains("auth=account")); + assert!(!auth_only_url.contains("user=")); + + let with_password = PairingProtocol::answer_challenge_with_password( + &PairingChallenge { + challenge: "abc".to_string(), + timestamp: 1, + }, + &DeviceIdentity { + device_id: "m1".to_string(), + device_name: "Phone".to_string(), + mac_address: "00:00:00:00:00:00".to_string(), + }, + Some("install-1".to_string()), + Some("alice".to_string()), + Some("secret".to_string()), + ); + let json = serde_json::to_value(&with_password).expect("serialize pairing response"); + assert_eq!(json["user_id"], "alice"); + assert_eq!(json["password"], "secret"); + let parsed: PairingResponse = + serde_json::from_value(json).expect("deserialize pairing response"); + assert_eq!(parsed.password.as_deref(), Some("secret")); let message = RelayMessage::CreateRoom { room_id: Some(payload.room_id), @@ -849,7 +885,7 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { async fn handle_interaction_command(&self, _command: &RemoteCommand) -> RemoteResponse { self.events.lock().unwrap().push("interaction".to_string()); RemoteResponse::InteractionAccepted { - action: "confirm_tool".to_string(), + action: "cancel_tool".to_string(), target_id: "tool-1".to_string(), } } @@ -982,9 +1018,9 @@ async fn remote_connect_command_owner_preserves_cancel_and_group_routing() { let interaction = handle_remote_command( &host, - &RemoteCommand::ConfirmTool { + &RemoteCommand::CancelTool { tool_id: "tool-1".to_string(), - updated_input: None, + reason: None, }, RemoteConnectSubmissionSource::Relay, ) @@ -1081,7 +1117,6 @@ async fn remote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order() { submitted.policy.queue_priority, RemoteDialogQueuePriority::Normal ); - assert!(submitted.policy.skip_tool_confirmation); } #[tokio::test] @@ -1381,13 +1416,28 @@ fn make_temp_remote_workspace() -> (PathBuf, PathBuf, PathBuf) { #[test] fn remote_connect_file_path_resolution_stays_within_workspace_root() { let (base, workspace, report) = make_temp_remote_workspace(); + let secret = base.join("secret.md"); + std::fs::write(&secret, b"outside workspace").expect("write outside file"); let resolved = resolve_remote_workspace_path("computer://artifacts/report.md", Some(&workspace)) .expect("workspace-relative file resolves"); assert_eq!(resolved, report.canonicalize().expect("canonical report")); + let absolute_resolved = + resolve_remote_workspace_path(report.to_str().expect("UTF-8 test path"), Some(&workspace)) + .expect("absolute path within workspace resolves"); + assert_eq!( + absolute_resolved, + report.canonicalize().expect("canonical report") + ); + assert!(resolve_remote_workspace_path("../secret.md", Some(&workspace)).is_none()); + assert!(resolve_remote_workspace_path( + secret.to_str().expect("UTF-8 test path"), + Some(&workspace) + ) + .is_none()); assert!(resolve_remote_workspace_path("artifacts/report.md", None).is_none()); std::fs::remove_dir_all(base).expect("cleanup remote workspace"); @@ -1584,9 +1634,9 @@ fn remote_connect_execution_response_helpers_preserve_wire_shape() { } ); assert_eq!( - remote_interaction_accepted_response("confirm_tool", "tool-1", Ok(())), + remote_interaction_accepted_response("cancel_tool", "tool-1", Ok(())), RemoteResponse::InteractionAccepted { - action: "confirm_tool".to_string(), + action: "cancel_tool".to_string(), target_id: "tool-1".to_string(), } ); diff --git a/src/crates/services/services-integrations/tests/script_tool_runtime.rs b/src/crates/services/services-integrations/tests/script_tool_runtime.rs index 4a373c055d..0577af9e01 100644 --- a/src/crates/services/services-integrations/tests/script_tool_runtime.rs +++ b/src/crates/services/services-integrations/tests/script_tool_runtime.rs @@ -58,6 +58,19 @@ fn named_invoke_request(operation_id: &str, revision: &str) -> ScriptToolInvokeR request } +#[tokio::test] +async fn runtime_availability_reports_the_probed_node_version() { + let runtime = NodeScriptToolRuntime::discover(); + + if let ScriptToolRuntimeAvailability::Available { version, .. } = runtime.availability().await { + assert!( + version.starts_with('v'), + "unexpected Node.js version: {version}" + ); + assert!(version[1..].split('.').count() >= 2); + } +} + #[tokio::test] async fn node_worker_loads_invokes_updates_and_disposes_a_target() { let runtime = NodeScriptToolRuntime::discover(); diff --git a/src/crates/services/terminal/Cargo.toml b/src/crates/services/terminal/Cargo.toml index 86e3f6d04d..c0f1c39340 100644 --- a/src/crates/services/terminal/Cargo.toml +++ b/src/crates/services/terminal/Cargo.toml @@ -12,9 +12,6 @@ path = "src/lib.rs" [dependencies] bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } -# PTY support -portable-pty = { workspace = true } - # Async runtime tokio = { workspace = true } tokio-stream = { workspace = true } @@ -55,6 +52,14 @@ tempfile = { workspace = true } # Windows process-tree ownership win32job = { workspace = true } +# portable-pty: ohos requires 0.9 (nix 0.25 is incompatible); all other +# platforms use 0.8 to avoid the PSEUDOCONSOLE_INHERIT_CURSOR ConPTY bug. +[target.'cfg(not(target_env = "ohos"))'.dependencies] +portable-pty = { workspace = true } + +[target.'cfg(target_env = "ohos")'.dependencies] +portable-pty = "0.9" + [lints] workspace = true diff --git a/src/crates/services/terminal/src/pty/process.rs b/src/crates/services/terminal/src/pty/process.rs index cfab09b045..c2a7b3a8e5 100644 --- a/src/crates/services/terminal/src/pty/process.rs +++ b/src/crates/services/terminal/src/pty/process.rs @@ -23,8 +23,8 @@ use std::thread; #[cfg(windows)] use log::debug; use log::{error, warn}; -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use tokio::sync::mpsc; +use portable_pty::{native_pty_system, Child, CommandBuilder, PtySize}; +use tokio::sync::{mpsc, oneshot}; use crate::config::ShellConfig; use crate::shell::ShellType; @@ -36,6 +36,10 @@ use super::flow_control::{HIGH_WATER_MARK, LOW_WATER_MARK}; mod shutdown { /// Time to wait for data flush after exit is queued pub(super) const DATA_FLUSH_TIMEOUT_MS: u64 = 250; + /// Maximum time to wait for the managed child to report termination. + pub(super) const EXIT_CONFIRM_TIMEOUT_MS: u64 = 5_000; + /// Poll interval while confirming termination after a kill request. + pub(super) const EXIT_CONFIRM_POLL_MS: u64 = 10; } /// Resize constants for Windows ConPTY @@ -74,7 +78,10 @@ enum InternalCommand { /// Send a signal to the process Signal(String), /// Shutdown the process - Shutdown { immediate: bool }, + Shutdown { + immediate: bool, + completion: oneshot::Sender>, + }, } /// Events emitted by the PTY process @@ -193,10 +200,17 @@ impl PtyController { /// Shutdown the process pub async fn shutdown(&self, immediate: bool) -> TerminalResult<()> { + let (completion, completed) = oneshot::channel(); self.command_tx - .send(InternalCommand::Shutdown { immediate }) + .send(InternalCommand::Shutdown { + immediate, + completion, + }) .await - .map_err(|_| TerminalError::ProcessNotRunning) + .map_err(|_| TerminalError::ProcessNotRunning)?; + completed + .await + .map_err(|_| TerminalError::ProcessNotRunning)? } /// Check if process is still running @@ -570,21 +584,20 @@ pub fn spawn_pty( } } } - InternalCommand::Shutdown { immediate } => { - has_exited_cmd.store(true, Ordering::Relaxed); - - if !immediate { - // Wait for data flush - tokio::time::sleep(tokio::time::Duration::from_millis( - shutdown::DATA_FLUSH_TIMEOUT_MS, - )) - .await; + InternalCommand::Shutdown { + immediate, + completion, + } => match confirm_child_shutdown(child.as_mut(), immediate).await { + Ok(exit_code) => { + has_exited_cmd.store(true, Ordering::Relaxed); + let _ = event_tx.try_send(PtyEvent::Exit { exit_code }); + let _ = completion.send(Ok(())); + break; } - - let code = None; - let _ = event_tx.send(PtyEvent::Exit { exit_code: code }).await; - break; - } + Err(error) => { + let _ = completion.send(Err(error)); + } + }, } } }); @@ -647,6 +660,49 @@ fn is_tauri_host_env(key: &OsStr) -> bool { || key.starts_with("TAURI_ANDROID_PACKAGE_NAME_") } +async fn confirm_child_shutdown( + child: &mut (dyn Child + Send), + immediate: bool, +) -> TerminalResult> { + if !immediate { + tokio::time::sleep(tokio::time::Duration::from_millis( + shutdown::DATA_FLUSH_TIMEOUT_MS, + )) + .await; + } + + match child.try_wait() { + Ok(Some(status)) => return Ok(Some(status.exit_code())), + Ok(None) => {} + Err(error) => return Err(TerminalError::Io(error)), + } + + let kill_error = child.kill().err().map(|error| error.to_string()); + let deadline = tokio::time::Instant::now() + + tokio::time::Duration::from_millis(shutdown::EXIT_CONFIRM_TIMEOUT_MS); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(Some(status.exit_code())), + Ok(None) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(tokio::time::Duration::from_millis( + shutdown::EXIT_CONFIRM_POLL_MS, + )) + .await; + } + Ok(None) => { + let detail = kill_error + .as_deref() + .map(|error| format!("; kill request failed: {error}")) + .unwrap_or_default(); + return Err(TerminalError::Timeout(format!( + "PTY process did not exit after shutdown request{detail}" + ))); + } + Err(error) => return Err(TerminalError::Io(error)), + } + } +} + // ============================================================================ // Legacy compatibility - PtyCommand enum (for external use if needed) // ============================================================================ @@ -666,7 +722,9 @@ pub enum PtyCommand { #[cfg(test)] mod tests { - use super::is_tauri_host_env; + use super::{is_tauri_host_env, spawn_pty, PtyEvent}; + use crate::config::ShellConfig; + use crate::shell::ShellType; #[test] fn strips_tauri_host_configuration_from_parent_env() { @@ -683,4 +741,34 @@ mod tests { assert!(!is_tauri_host_env("PATH".as_ref())); assert!(!is_tauri_host_env("TERMINAL_NONCE".as_ref())); } + + #[tokio::test(flavor = "current_thread")] + async fn shutdown_returns_only_after_process_exit_is_confirmed() { + let shell = if cfg!(windows) { + ("cmd.exe", ShellType::Cmd) + } else { + ("/bin/sh", ShellType::Sh) + }; + let mut spawned = spawn_pty( + 1, + &ShellConfig { + executable: shell.0.to_string(), + ..ShellConfig::default() + }, + shell.1, + 80, + 24, + ) + .expect("spawn test PTY"); + + spawned + .controller + .shutdown(true) + .await + .expect("shutdown test PTY"); + + assert!(!spawned.controller.is_running()); + assert!(std::iter::from_fn(|| spawned.events.try_recv()) + .any(|event| matches!(event, PtyEvent::Exit { .. }))); + } } diff --git a/src/crates/services/terminal/src/pty/service.rs b/src/crates/services/terminal/src/pty/service.rs index 9648e13259..37a7b1efeb 100644 --- a/src/crates/services/terminal/src/pty/service.rs +++ b/src/crates/services/terminal/src/pty/service.rs @@ -292,13 +292,25 @@ impl PtyService { /// Shutdown a PTY process pub async fn shutdown(&self, id: u32, immediate: bool) -> TerminalResult<()> { - let process = { - let mut processes = self.processes.write().await; - processes.remove(&id) + let controller = { + let processes = self.processes.read().await; + processes.get(&id).map(|process| process.controller.clone()) }; - if let Some(process) = process { - process.controller.shutdown(immediate).await?; + if let Some(controller) = controller { + if !controller.is_running() { + self.processes.write().await.remove(&id); + return Ok(()); + } + match controller.shutdown(immediate).await { + Ok(()) => { + self.processes.write().await.remove(&id); + } + Err(TerminalError::ProcessNotRunning) if !controller.is_running() => { + self.processes.write().await.remove(&id); + } + Err(error) => return Err(error), + } } Ok(()) @@ -376,3 +388,52 @@ impl Drop for PtyService { // Note: Processes should be shut down explicitly before dropping } } + +#[cfg(test)] +mod tests { + use super::PtyService; + use crate::config::{ShellConfig, TerminalConfig}; + use crate::shell::ShellType; + + #[tokio::test(flavor = "current_thread")] + async fn shutdown_evicts_a_process_whose_controller_already_confirmed_exit() { + let service = PtyService::new(TerminalConfig::default()); + let (executable, shell_type) = if cfg!(windows) { + ("cmd.exe", ShellType::Cmd) + } else { + ("/bin/sh", ShellType::Sh) + }; + let process_id = service + .create_process( + ShellConfig { + executable: executable.to_string(), + ..ShellConfig::default() + }, + shell_type, + 80, + 24, + ) + .await + .expect("create test PTY"); + let controller = service + .processes + .read() + .await + .get(&process_id) + .expect("managed process") + .controller + .clone(); + + controller + .shutdown(true) + .await + .expect("confirm child exit before service eviction"); + assert!(service.has_process(process_id).await); + + service + .shutdown(process_id, true) + .await + .expect("stale process eviction must be idempotent"); + assert!(!service.has_process(process_id).await); + } +} diff --git a/src/crates/services/terminal/src/session/binding.rs b/src/crates/services/terminal/src/session/binding.rs index 26222b544d..a4fad97365 100644 --- a/src/crates/services/terminal/src/session/binding.rs +++ b/src/crates/services/terminal/src/session/binding.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::sync::Arc; +use dashmap::mapref::entry::Entry; use dashmap::DashMap; use log::warn; @@ -222,38 +223,55 @@ impl TerminalSessionBinding { pub async fn remove(&self, owner_id: &str) -> TerminalResult<()> { let session_manager = get_session_manager() .ok_or_else(|| TerminalError::Session("SessionManager not initialized".to_string()))?; + let mut first_error = None; // Close primary session - if let Some(terminal_session_id) = self.unbind(owner_id) { - if let Err(e) = session_manager + if let Some(terminal_session_id) = self.get(owner_id) { + if let Err(error) = session_manager .close_session(&terminal_session_id, false) .await { warn!( "Failed to close terminal session {}: {}", - terminal_session_id, e + terminal_session_id, error ); + first_error = Some(error); + } else if let Entry::Occupied(entry) = self.bindings.entry(owner_id.to_string()) { + if entry.get() == &terminal_session_id { + entry.remove(); + } } } // Close all background sessions - if let Some((_, bg_sessions)) = self.background_bindings.remove(owner_id) { - for bg_session_id in bg_sessions { - if let Err(e) = session_manager.close_session(&bg_session_id, false).await { - warn!( - "Failed to close background terminal session {}: {}", - bg_session_id, e - ); + for bg_session_id in self.list_background_sessions(owner_id) { + if let Err(error) = session_manager.close_session(&bg_session_id, false).await { + warn!( + "Failed to close background terminal session {}: {}", + bg_session_id, error + ); + if first_error.is_none() { + first_error = Some(error); + } + } else if let Entry::Occupied(mut entry) = + self.background_bindings.entry(owner_id.to_string()) + { + entry.get_mut().retain(|current| current != &bg_session_id); + if entry.get().is_empty() { + entry.remove(); } } } - Ok(()) + match first_error { + Some(error) => Err(error), + None => Ok(()), + } } /// Check if a binding exists for the given owner pub fn has(&self, owner_id: &str) -> bool { - self.bindings.contains_key(owner_id) + self.bindings.contains_key(owner_id) || self.background_bindings.contains_key(owner_id) } /// List all current bindings @@ -338,4 +356,14 @@ mod tests { binding.clear(); assert_eq!(binding.count(), 0); } + + #[test] + fn background_only_binding_is_owned_by_the_session() { + let binding = TerminalSessionBinding::new(); + binding + .background_bindings + .insert("owner1".to_string(), vec!["background-session".to_string()]); + + assert!(binding.has("owner1")); + } } diff --git a/src/crates/services/terminal/src/session/manager.rs b/src/crates/services/terminal/src/session/manager.rs index 89d1933b0a..4ad7eb785a 100644 --- a/src/crates/services/terminal/src/session/manager.rs +++ b/src/crates/services/terminal/src/session/manager.rs @@ -1488,13 +1488,11 @@ impl SessionManager { // Shutdown PTY if exists if let Some(pty_id) = pty_id { - // Remove mapping - { - let mut mapping = self.pty_to_session.write().await; - mapping.remove(&pty_id); - } - self.pty_service.shutdown(pty_id, immediate).await?; + + // Keep the mapping available for retry until shutdown is confirmed. + let mut mapping = self.pty_to_session.write().await; + mapping.remove(&pty_id); } if is_manual_session { diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html index 3dfd18e75d..5e7a8fc874 100644 --- a/src/mobile-web/index.html +++ b/src/mobile-web/index.html @@ -5,18 +5,11 @@ + BitFun Remote - +