From f977e32b7e0fe6c34d10fa4e0753aeeec0c27bc4 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 5 Jun 2026 12:16:21 +0800 Subject: [PATCH] refactor: move MiniApp host plans and developer tooling Move MiniApp host call planning into product-domain decisions while keeping runtime execution and permission-sensitive IO in core. Streamline root contributor guidance, move DeepReview-specific rules to local AGENTS files, and guard desktop DevTools shortcuts behind backend availability. --- AGENTS-CN.md | 49 --- AGENTS.md | 49 --- CONTRIBUTING.md | 22 +- CONTRIBUTING_CN.md | 17 +- docs/plans/core-decomposition-completed.md | 7 +- docs/plans/core-decomposition-plan.md | 16 +- .../rules/source/required-rules.mjs | 52 ++- scripts/core-boundaries/self-test.mjs | 13 +- src/apps/desktop/src/api/debug_api.rs | 13 + src/apps/desktop/src/lib.rs | 1 + src/crates/core/AGENTS-CN.md | 4 +- src/crates/core/AGENTS.md | 7 +- .../core/src/agentic/deep_review/AGENTS.md | 15 + src/crates/core/src/miniapp/host_dispatch.rs | 185 +++------ src/crates/product-domains/AGENTS-CN.md | 4 +- src/crates/product-domains/AGENTS.md | 8 +- .../src/miniapp/host_routing.rs | 381 +++++++++++++++++- .../tests/miniapp_contracts.rs | 296 ++++++++++++-- .../src/flow_chat/deep-review/AGENTS.md | 10 +- .../debug/useDebugInspector.test.tsx | 138 +++++++ .../infrastructure/debug/useDebugInspector.ts | 123 ++++-- 21 files changed, 1043 insertions(+), 367 deletions(-) create mode 100644 src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx diff --git a/AGENTS-CN.md b/AGENTS-CN.md index de0ef182b..6f45d3afd 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -164,40 +164,6 @@ await api.invoke('your_command', { request: { ... } }); - 产品表面可以有差异;共享稳定 facts 或 ports,不共享 UI、protocol、lifecycle 或平台实现。 - 迁移 runtime owner 必须有评审过的 port/provider 设计、旧路径兼容、行为等价测试;如果可能改变行为边界,还需要先确认。 -### DeepReview 护栏 - -Deep Review / 代码审核团队横跨 core runtime 与 Web UI。target resolution 与 -manifest construction 保持在前端;policy validation、queue/retry state 和 -report enrichment 保持在 shared core。 - -### 后端链路 - -大多数功能建议按这个顺序追踪: - -1. `src/web-ui` 或应用入口 -2. `src/apps/desktop/src/api/*` 或 server routes -3. `src/crates/api-layer` -4. `src/crates/transport` -5. `src/crates/core` - -### `bitfun-core` - -`src/crates/core` 是代码库中心。 - -主要区域: - -- `agentic/`:agents、prompts、tools、sessions、execution、persistence -- `service/`:config、filesystem、terminal、git、LSP、MCP、remote connect、project context、AI memory -- `infrastructure/`:AI clients、app paths、event system、storage、debug log server - -Agent 运行时心智模型: - -```text -SessionManager → Session → DialogTurn → ModelRound -``` - -会话数据保存在 `.bitfun/sessions/{session_id}/`。 - ## 验证 按触及文件选择最小本地预检。完整构建和大范围测试默认由 CI 保护;只有改动直接影响构建、 @@ -210,7 +176,6 @@ SessionManager → Session → DialogTurn → ModelRound | Locale contract 或 shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | | Web UI i18n runtime、namespace loading 或直接 `i18nService.t(...)` 调用 | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile web UI、状态、配对、断开或重连行为 | `pnpm --dir src/mobile-web run type-check`;行为变化还需要在 PR 中说明手动配对 / 重连验证 | -| Deep Review / 代码审核团队行为 | 运行最近的 Web UI 检查,再运行 `cargo test -p bitfun-core deep_review -- --nocapture`;如果触及后端或 Tauri API,还需要运行对应 Rust / 桌面端检查 | | `core`、`transport`、`api-layer` 或共享服务中的 Rust 逻辑 | `cargo check --workspace`;行为变化时再加最近的 focused `cargo test` | | 桌面端集成、Tauri API、browser/computer-use 或桌面专属行为 | `cargo check -p bitfun-desktop`;行为变化时再加 focused desktop tests | | 被桌面端 smoke/functional 流覆盖的行为 | 优先运行最近的 focused E2E/smoke check;除非改动影响构建,否则 broad build/test 交给 CI | @@ -219,20 +184,6 @@ SessionManager → Session → DialogTurn → ModelRound | 安装器 Tauri/Rust 改动 | `cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml` | | 安装器打包、payload、安装/卸载流程或 native bundling | `pnpm run installer:build` | -## 先看哪里 - -| 功能 | 关键路径 | -|---|---| -| Agent mode | `src/crates/core/src/agentic/agents/`、`src/crates/core/src/agentic/agents/prompts/`、`src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review/`、`src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/review-team/`、`src/web-ui/src/flow_chat/deep-review/`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | -| Mobile web 配对 / 远程控制 | `src/mobile-web/src/pages/PairingPage.tsx`、`src/mobile-web/src/pages/SessionListPage.tsx`、`src/mobile-web/src/pages/ChatPage.tsx`、`src/mobile-web/src/services/RemoteSessionManager.ts`、`src/mobile-web/src/services/RelayHttpClient.ts`、`src/mobile-web/src/services/store.ts` | -| 会话用量报告(`/usage`) | `src/crates/core/src/service/session_usage/`、`src/web-ui/src/flow_chat/components/usage/`、`src/web-ui/src/locales/*/flow-chat.json` | -| Tool | `src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` | -| MCP / LSP / remote | `src/crates/core/src/service/mcp/`、`src/crates/core/src/service/lsp/`、`src/crates/core/src/service/remote_connect/`、`src/crates/core/src/service/remote_ssh/` | -| 桌面端 API | `src/apps/desktop/src/api/`、`src/crates/api-layer/src/`、`src/crates/transport/src/adapters/tauri.rs` | -| 中继服务器 | `src/apps/relay-server/` | -| Web/server 通信 | `src/web-ui/src/infrastructure/api/`、`src/crates/transport/src/adapters/websocket.rs`、`src/apps/server/src/routes/`、`src/apps/server/src/main.rs` | - ## Agent 文档优先级 进入具体目录后,优先遵循离目标文件最近的 `AGENTS.md` / `AGENTS-CN.md`。如果局部文档与本文件冲突,以更具体、更近的文档为准。 diff --git a/AGENTS.md b/AGENTS.md index e07c762ca..9859fc537 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,40 +174,6 @@ Repository-level decomposition rules: compatibility, behavior equivalence tests, and explicit confirmation when a behavior boundary could change. -### DeepReview guardrails - -Deep Review / Code Review Team work spans the core runtime and web UI. Keep -target resolution and manifest construction on the frontend; keep policy -validation, queue/retry state, and report enrichment in shared core. - -### Backend flow - -Trace most features in this order: - -1. `src/web-ui` or app entrypoint -2. `src/apps/desktop/src/api/*` or server routes -3. `src/crates/api-layer` -4. `src/crates/transport` -5. `src/crates/core` - -### `bitfun-core` - -`src/crates/core` is the center of the codebase. - -Important areas: - -- `agentic/`: agents, prompts, tools, sessions, execution, persistence -- `service/`: config, filesystem, terminal, git, LSP, MCP, remote connect, project context, AI memory -- `infrastructure/`: AI clients, app paths, event system, storage, debug log server - -Agent runtime mental model: - -```text -SessionManager → Session → DialogTurn → ModelRound -``` - -Session data is stored under `.bitfun/sessions/{session_id}/`. - ## Verification Run the smallest local precheck that matches the touched files. CI is expected to @@ -221,7 +187,6 @@ change directly affects build, packaging, or CI cannot protect the path. | Locale contract or shared terms | `pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit` | | Web UI i18n runtime, namespace loading, or direct `i18nService.t(...)` usage | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile web UI, state, pairing, disconnect, or reconnect behavior | `pnpm --dir src/mobile-web run type-check`; include manual pairing / reconnect notes when behavior changes | -| Deep Review / Code Review Team behavior | Nearest Web UI check above, plus `cargo test -p bitfun-core deep_review -- --nocapture`; also run Rust / desktop checks when backend or Tauri APIs are touched | | Shared Rust logic in `core`, `transport`, `api-layer`, or services | `cargo check --workspace`, plus the nearest focused `cargo test` when behavior changed | | Desktop integration, Tauri APIs, browser/computer-use, or desktop-only behavior | `cargo check -p bitfun-desktop`, plus focused desktop tests when behavior changed | | Behavior covered by desktop smoke/functional flows | Prefer the nearest focused E2E/smoke check; rely on CI for broad build/test coverage unless build behavior changed | @@ -230,20 +195,6 @@ change directly affects build, packaging, or CI cannot protect the path. | Installer Tauri/Rust changes | `cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml` | | Installer packaging, payload, install/uninstall flow, or native bundling | `pnpm run installer:build` | -## Where to look first - -| Feature | Key paths | -|---|---| -| Agent modes | `src/crates/core/src/agentic/agents/`, `src/crates/core/src/agentic/agents/prompts/`, `src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review/`, `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/review-team/`, `src/web-ui/src/flow_chat/deep-review/`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | -| Mobile web pairing / remote control | `src/mobile-web/src/pages/PairingPage.tsx`, `src/mobile-web/src/pages/SessionListPage.tsx`, `src/mobile-web/src/pages/ChatPage.tsx`, `src/mobile-web/src/services/RemoteSessionManager.ts`, `src/mobile-web/src/services/RelayHttpClient.ts`, `src/mobile-web/src/services/store.ts` | -| Session usage report (`/usage`) | `src/crates/core/src/service/session_usage/`, `src/web-ui/src/flow_chat/components/usage/`, `src/web-ui/src/locales/*/flow-chat.json` | -| Tools | `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` | -| MCP / LSP / remote | `src/crates/core/src/service/mcp/`, `src/crates/core/src/service/lsp/`, `src/crates/core/src/service/remote_connect/`, `src/crates/core/src/service/remote_ssh/` | -| Desktop APIs | `src/apps/desktop/src/api/`, `src/crates/api-layer/src/`, `src/crates/transport/src/adapters/tauri.rs` | -| Relay server | `src/apps/relay-server/` | -| Web/server communication | `src/web-ui/src/infrastructure/api/`, `src/crates/transport/src/adapters/websocket.rs`, `src/apps/server/src/routes/`, `src/apps/server/src/main.rs` | - ## Agent-doc priority Prefer the nearest matching `AGENTS.md` / `AGENTS-CN.md` for the directory you are changing. If local guidance conflicts with this file, follow the more specific, nearer document. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 324ebe14a..2823d1e3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,12 +19,14 @@ Be respectful, kind, and constructive. We welcome contributors of all background #### Windows: OpenSSL Setup -The desktop app includes SSH remote support, which pulls in OpenSSL. On Windows the workspace **does not use vendored OpenSSL**; link against **pre-built** binaries (no Perl/NASM/OpenSSL source build). +Most Windows contributors do not need to configure OpenSSL manually. Use +`pnpm run desktop:dev` or the normal `desktop:build*` scripts; they bootstrap a +pre-built OpenSSL package when needed. -- **Default**: `pnpm run desktop:dev` calls `ensure-openssl-windows.mjs` on Windows. `pnpm run desktop:preview:debug` does the same whenever it needs to fast-rebuild `bitfun-desktop` before preview. Every `desktop:build*` script runs via `scripts/desktop-tauri-build.mjs`, which does the same before invoking Cargo. -- **Manual / CI**: Download the [FireDaemon OpenSSL 3.5.5 LTS ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip), extract, set `OPENSSL_DIR` to the `x64` folder, `OPENSSL_STATIC=1`, or run `scripts/ci/setup-openssl-windows.ps1`. -- **Opt out of auto-download**: `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` and configure `OPENSSL_DIR` yourself. -- **`desktop:dev:raw`** skips the dev script (no OpenSSL bootstrap); set `OPENSSL_DIR` yourself, run `scripts/ci/setup-openssl-windows.ps1`, or `node scripts/ensure-openssl-windows.mjs` (warms `.bitfun/cache/` and prints PowerShell `OPENSSL_*` lines to paste). +Only handle OpenSSL yourself when the bootstrap fails, you are preparing CI, or +you intentionally use `pnpm run desktop:dev:raw`. In that case, run +`scripts/ci/setup-openssl-windows.ps1`, or set `OPENSSL_DIR` to a pre-built x64 +OpenSSL directory and set `OPENSSL_STATIC=1`. ### Install dependencies @@ -54,9 +56,10 @@ pnpm run e2e:test ### Desktop debugging tools -Desktop dev builds enable the `devtools` Cargo feature. Use -`Cmd/Ctrl + Shift + I` for the element inspector and `Cmd/Ctrl + Shift + J` for -native webview DevTools. These tools are disabled in end-user `release` builds. +Desktop dev builds enable the `devtools` Cargo feature. Use `F12` for native +webview DevTools. `Cmd/Ctrl + Shift + I` toggles the BitFun element inspector, +and `Cmd/Ctrl + Shift + J` also opens native DevTools. These tools are disabled +in end-user `release` builds. ## Code Standards and Architecture Constraints @@ -73,8 +76,7 @@ terms: payloads. - Core decomposition, feature-boundary, dependency-boundary, and build-speed work must follow `docs/architecture/core-decomposition.md`. -- Deep Review / Code Review Team changes must keep the core and Web UI guidance - aligned with the implementation. +- Feature-specific rules belong in the nearest module `AGENTS.md`. ## Key Contribution Focus Areas diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index b8e48353d..fd332142f 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -19,12 +19,12 @@ #### Windows:OpenSSL 配置 -桌面端包含 SSH 远程功能,会链接 OpenSSL。Windows 上**不使用 OpenSSL 源码编译(vendored)**,需使用**预编译**库。 +大多数 Windows 贡献者不需要手动配置 OpenSSL。使用 `pnpm run desktop:dev` +或常规 `desktop:build*` 脚本即可;脚本会在需要时自动引导预编译的 OpenSSL 包。 -- **默认**:Windows 下 `pnpm run desktop:dev` 会调用 `ensure-openssl-windows.mjs`;`pnpm run desktop:preview:debug` 在需要为预览执行快速本地 `cargo build -p bitfun-desktop` 时,也会做同样的 OpenSSL 引导。所有 `desktop:build*` 均通过 `scripts/desktop-tauri-build.mjs` 执行,在 `tauri build` 前做相同引导(首次下载到 `.bitfun/cache/`,之后走缓存)。 -- **手动 / CI**:下载 [FireDaemon ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip),解压后将 `OPENSSL_DIR` 指向 `x64`,并设 `OPENSSL_STATIC=1`,或运行 `scripts/ci/setup-openssl-windows.ps1`。 -- **关闭自动下载**:设置 `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` 并自行配置 `OPENSSL_DIR`。 -- **`desktop:dev:raw`** 不经过 `dev.cjs`(无 OpenSSL 引导);请自行设置 `OPENSSL_DIR`、运行 `scripts/ci/setup-openssl-windows.ps1`,或执行 `node scripts/ensure-openssl-windows.mjs`(会预热 `.bitfun/cache/` 并打印可在 PowerShell 中粘贴的 `OPENSSL_*` 命令)。 +只有在自动引导失败、准备 CI 环境,或你明确使用 `pnpm run desktop:dev:raw` +时才需要手动处理。此时运行 `scripts/ci/setup-openssl-windows.ps1`,或将 +`OPENSSL_DIR` 指向预编译的 x64 OpenSSL 目录,并设置 `OPENSSL_STATIC=1`。 ### 安装依赖 @@ -54,8 +54,9 @@ pnpm run e2e:test ### 桌面端调试工具 -桌面端 dev 构建会启用 `devtools` Cargo feature。`Cmd/Ctrl + Shift + I` 打开元素检查器, -`Cmd/Ctrl + Shift + J` 打开原生 webview DevTools;面向最终用户的 `release` 构建不会启用这些工具。 +桌面端 dev 构建会启用 `devtools` Cargo feature。`F12` 打开原生 webview +DevTools;`Cmd/Ctrl + Shift + I` 切换 BitFun 元素检查器,`Cmd/Ctrl + Shift + J` +也可以打开原生 DevTools。面向最终用户的 `release` 构建不会启用这些工具。 ## 代码规范与架构约束 @@ -67,7 +68,7 @@ pnpm run e2e:test - Tauri command 使用 `snake_case` 命令名和结构化 `request` 参数。 - core 拆解、feature 边界、依赖边界和构建提速重构必须遵循 `docs/architecture/core-decomposition.md`。 -- Deep Review / 代码审核团队改动需要保持 core 与 Web UI 指南和实现一致。 +- 功能级规则应放在离代码最近的模块 `AGENTS.md` 中。 ## 重点关注的贡献方向 diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 9f5682111..49762a98c 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -64,12 +64,13 @@ ### 1.5 Product Domain 与 function-agent / MiniApp 边界 - `product-domains` 已承接 MiniApp 的纯状态、runtime detection policy、worker capacity / idle / LRU policy、 - host method / fs access / shell token / env 等纯决策,以及 function-agent prompt / parser / response policy / ports。 + host method、`fs.*` / `shell.exec` host call plan、fs access / shell token / cwd / timeout / env 等纯决策, + 以及 function-agent prompt / parser / response policy / ports。 - 内置 MiniApp bundle identity、版本和 embedded source assets 已归入 `product-domains`。 - function-agent Git concrete snapshot、no-HEAD diff fallback、非 Git workspace fallback、ahead/behind/last-commit fallback 和 project context lookup 已迁入 `services-integrations::function_agents`。 -- function-agent AI provider acquisition、AI transport error mapping、MiniApp worker process、host dispatch、 - PathManager integration、marker IO 和 seed 写盘仍留在 core concrete path。 +- function-agent AI provider acquisition、AI transport error mapping、MiniApp worker process、host side-effect dispatch、 + `net.fetch` / `os.info` runtime execution、PathManager integration、marker IO 和 seed 写盘仍留在 core concrete path。 ## 2. 已建立的保护 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 2db349173..dbdbcca3a 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -120,11 +120,15 @@ prompt-visible manifest、GetToolSpec、readonly/enabled filtering、expanded/co 目标:让 Harness / Product Domains 不再停留在 descriptor 或 pure policy 层,而是接管符合设计边界的工作流和 domain owner。 -- 为 Deep Review、DeepResearch、MiniApp、function-agent 明确哪些属于 Harness workflow、哪些属于 Product Domain policy、 - 哪些属于 Concrete Integration provider。 -- 迁移 MiniApp worker/host/seed/marker IO 中可被 Runtime Services / provider port 保护的主体。 -- 为 function-agent AI provider acquisition 抽稳定 AI runtime/provider port,避免 integration crate 依赖回 core 或复制 AI client runtime。 -- Harness provider 从 legacy route plan 逐步转向可执行 workflow owner;无法迁移的 concrete 副作用必须明确保留在 Product Assembly adapter。 +- MiniApp host primitive 的 `fs.*` / `shell.exec` 纯调用计划、参数默认值、错误契约和权限检查计划归入 + Product Domain;core 仅消费计划并继续负责权限 policy resolution、路径规范化、文件 IO、进程执行和输出映射。 +- `net.fetch` / `os.info` 不迁入 Product Domain:前者依赖 `reqwest::Url` 与 HTTP client 语义,后者依赖平台 runtime facts, + 保留在 core concrete path 更符合最小依赖原则。 +- MiniApp seed / marker 的 bundle、hash、marker wire format 与 seed decision 已由 Product Domain 持有;core 仍负责磁盘读写、 + customization metadata、PathManager integration 和 recompile。 +- function-agent 已通过 Product Domain facade 与 core adapter 隔离 Git/AI 端口;AI provider acquisition 和 transport error mapping + 仍留在 core,除非后续单独评审稳定 AI runtime/provider 边界。 +- Harness provider 继续保持 legacy route plan / descriptor owner;Deep Review / DeepResearch concrete execution 不混入本阶段。 **不混入:** 改变 MiniApp storage layout、worker 生命周期、host primitive 权限、Deep Review report 语义、function-agent prompt/response policy。 @@ -155,7 +159,7 @@ prompt-visible manifest、GetToolSpec、readonly/enabled filtering、expanded/co ## 5. 执行节奏 -后续按 M2 -> M3 -> M4 -> M5 推进。每个里程碑原则上对应一个大 PR;如果发现风险超过单 PR 可控范围,只允许按 owner 边界拆分, +后续按 M4 收尾 -> M5 推进。每个里程碑原则上对应一个大 PR;如果发现风险超过单 PR 可控范围,只允许按 owner 边界拆分, 不允许拆成 facade / guard / helper 小 PR。 每个里程碑固定流程: diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 34bb5c39d..4c9cabe95 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -4357,8 +4357,12 @@ export const requiredContentRules = [ message: 'missing MiniApp fs host dispatch', }, { - regex: /\bfs_method_access_mode\b/, - message: 'missing product-domain MiniApp fs access-mode policy use', + regex: /\bplan_fs_legacy_path_check\b/, + message: 'missing product-domain MiniApp legacy fs path-gate plan use', + }, + { + regex: /\bplan_fs_host_call\b/, + message: 'missing product-domain MiniApp fs host-call plan use', }, { regex: /\bfs_policy_scopes\b/, @@ -4373,20 +4377,8 @@ export const requiredContentRules = [ message: 'missing MiniApp shell host dispatch', }, { - regex: /\bshell_exec_first_token\b/, - message: 'missing product-domain MiniApp shell token policy use', - }, - { - regex: /\bshell_exec_input_is_empty\b/, - message: 'missing product-domain MiniApp shell empty-input policy use', - }, - { - regex: /\bshell_exec_cwd\b/, - message: 'missing product-domain MiniApp shell cwd policy use', - }, - { - regex: /\bshell_exec_timeout_ms\b/, - message: 'missing product-domain MiniApp shell timeout policy use', + regex: /\bplan_shell_host_call\b/, + message: 'missing product-domain MiniApp shell host-call plan use', }, { regex: /\bshell_exec_default_env\b/, @@ -4677,6 +4669,18 @@ export const requiredContentRules = [ regex: /\bpub fn fs_method_access_mode\b/, message: 'missing MiniApp fs access mode helper', }, + { + regex: /\bpub enum MiniAppFsHostCallPlan\b/, + message: 'missing MiniApp fs host-call plan contract', + }, + { + regex: /\bpub fn plan_fs_host_call\b/, + message: 'missing MiniApp fs host-call planner', + }, + { + regex: /\bpub fn plan_fs_legacy_path_check\b/, + message: 'missing MiniApp legacy fs path-gate planner', + }, { regex: /\bpub fn fs_policy_scopes\b/, message: 'missing MiniApp fs policy scope helper', @@ -4717,6 +4721,14 @@ export const requiredContentRules = [ regex: /\bpub fn shell_exec_default_env\b/, message: 'missing MiniApp shell env policy helper', }, + { + regex: /\bpub struct MiniAppShellHostCallPlan\b/, + message: 'missing MiniApp shell host-call plan contract', + }, + { + regex: /\bpub fn plan_shell_host_call\b/, + message: 'missing MiniApp shell host-call planner', + }, { regex: /\bfs_method_access_mode_preserves_access_bypass_and_default_read_contract\b/, message: 'missing MiniApp fs access mode regression test', @@ -4733,6 +4745,14 @@ export const requiredContentRules = [ regex: /\bshell_exec_plan_helpers_preserve_defaults_and_precedence\b/, message: 'missing MiniApp shell plan regression test', }, + { + regex: /\bminiapp_host_fs_call_plans_preserve_existing_path_and_permission_contract\b/, + message: 'missing MiniApp fs host-call plan regression test', + }, + { + regex: /\bminiapp_host_shell_call_plans_preserve_existing_input_and_default_contract\b/, + message: 'missing MiniApp shell host-call plan regression test', + }, ], }, { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 5b325bb1b..1e4ee6a25 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1870,14 +1870,12 @@ export function runManifestParserSelfTest({ 'dispatch_host', 'split_host_method', 'dispatch_fs', - 'fs_method_access_mode', + 'plan_fs_legacy_path_check', + 'plan_fs_host_call', 'fs_policy_scopes', 'fs_resolved_path_allowed', 'dispatch_shell', - 'shell_exec_first_token', - 'shell_exec_input_is_empty', - 'shell_exec_cwd', - 'shell_exec_timeout_ms', + 'plan_shell_host_call', 'shell_exec_default_env', 'command_basename_allowed', 'host_allowed_by_allowlist', @@ -2029,6 +2027,9 @@ export function runManifestParserSelfTest({ 'split_host_method', 'FsAccessMode', 'fs_method_access_mode', + 'MiniAppFsHostCallPlan', + 'plan_fs_host_call', + 'plan_fs_legacy_path_check', 'fs_policy_scopes', 'fs_resolved_path_allowed', 'command_basename_for_allowlist', @@ -2039,6 +2040,8 @@ export function runManifestParserSelfTest({ 'shell_exec_cwd', 'shell_exec_timeout_ms', 'shell_exec_default_env', + 'MiniAppShellHostCallPlan', + 'plan_shell_host_call', ], }, { diff --git a/src/apps/desktop/src/api/debug_api.rs b/src/apps/desktop/src/api/debug_api.rs index d1a1de6ce..e090b63a7 100644 --- a/src/apps/desktop/src/api/debug_api.rs +++ b/src/apps/desktop/src/api/debug_api.rs @@ -66,6 +66,13 @@ pub async fn debug_element_picked(request: DebugElementPickedRequest) -> Result< Ok(()) } +/// Report whether desktop debug commands are available in this build. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_devtools_available() -> Result { + Ok(true) +} + /// Open the native webview DevTools window for the main window. #[tauri::command] #[cfg(any(debug_assertions, feature = "devtools"))] @@ -92,6 +99,12 @@ pub async fn debug_close_devtools(app: tauri::AppHandle) -> Result<(), String> { // No-op stubs for release builds (so the module always compiles) // --------------------------------------------------------------------------- +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_devtools_available() -> Result { + Ok(false) +} + #[tauri::command] #[cfg(not(any(debug_assertions, feature = "devtools")))] pub async fn debug_element_picked(_request: DebugElementPickedRequest) -> Result<(), String> { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index bf9485fef..93b6295d9 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1230,6 +1230,7 @@ pub async fn run() { api::announcement_api::trigger_announcement, api::announcement_api::get_announcement_tips, // Debug API (no-op stubs in release builds) + api::debug_api::debug_devtools_available, api::debug_api::debug_element_picked, api::debug_api::debug_open_devtools, api::debug_api::debug_close_devtools, diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index 1de04fd52..091d608b8 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -41,8 +41,8 @@ SessionManager -> Session -> DialogTurn -> ModelRound - Tool 改动必须保持 expanded/collapsed exposure、prompt-visible manifest、`GetToolSpec`、权限行为、 `ToolUseContext` 语义,以及 desktop/MCP/ACP catalog 行为等价。 - Runtime owner 迁移在目标 owner 具备评审过的 port/provider 设计和行为等价测试前,不应移动 concrete lifecycle、IO、event delivery、permission orchestration 或 remote/platform provider。 -- Product-domain 改动不得在没有明确 owner 设计和 focused regression 覆盖前,把 filesystem writes、worker/host execution、 - Git/AI concrete calls、marker IO 或 path-manager integration 移出 core。 +- Product-domain 改动可以在有等价保护时迁移纯产品领域计划;filesystem writes、worker/host side effect、 + Git/AI concrete calls、marker IO 和 path-manager integration 仍留在 core,除非有经过评审的 owner 设计。 - Remote/service 改动必须保持 external protocol lifecycle、workspace projection、scheduler/session restore、 terminal pre-warm 和 product execution 边界清晰。 - Feature 改动必须保持 `product-full` 作为兼容产品组装边界;默认能力选择只有在单独的 product matrix review 后才能变化。 diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 541ef98bd..a82e180cd 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -55,9 +55,10 @@ SessionManager -> Session -> DialogTurn -> ModelRound permission orchestration, and remote/platform providers in core until the target owner has a reviewed port/provider design plus behavior-equivalence tests. -- Product-domain changes must not move filesystem writes, worker/host execution, - Git/AI concrete calls, marker IO, or path-manager integration out of core - without an explicit owner design and focused regression coverage. +- Product-domain changes may move pure product-domain plans with equivalence + coverage, but filesystem writes, worker/host side effects, Git/AI concrete + calls, marker IO, and path-manager integration stay in core unless a reviewed + owner design says otherwise. - Remote/service changes must keep external protocol lifecycle, workspace projection, scheduler/session restore, terminal pre-warm, and product execution boundaries explicit. diff --git a/src/crates/core/src/agentic/deep_review/AGENTS.md b/src/crates/core/src/agentic/deep_review/AGENTS.md index 8613bdb26..04447df0d 100644 --- a/src/crates/core/src/agentic/deep_review/AGENTS.md +++ b/src/crates/core/src/agentic/deep_review/AGENTS.md @@ -9,9 +9,24 @@ This file applies to DeepReview runtime internals in this directory. - Keep this code platform-agnostic; use shared events, config, and tool context. - Keep policy, manifest admission, queue state, retry metadata, task adapter, and report enrichment aligned. +- Frontend code owns target resolution and review-team manifest construction; + this directory owns validation, queue/retry state, task adaptation, and + report enrichment. - Keep default team/runtime contracts aligned with `deep_review_policy.rs` and reviewer agents in `src/crates/core/src/agentic/agents`. - Reviewer subagents stay read-only; `ReviewFixer` is not part of the review pass. - When queue or report fields change, update the matching frontend DTOs and DeepReview UI state. + +## Verification + +Use the nearest Web UI check for frontend-only behavior. For shared runtime +behavior, run: + +```bash +cargo test -p bitfun-core deep_review -- --nocapture +``` + +Also run the relevant Rust or desktop check when the change touches backend +state, Tauri APIs, or desktop integration. diff --git a/src/crates/core/src/miniapp/host_dispatch.rs b/src/crates/core/src/miniapp/host_dispatch.rs index 2db1a660d..08eb52585 100644 --- a/src/crates/core/src/miniapp/host_dispatch.rs +++ b/src/crates/core/src/miniapp/host_dispatch.rs @@ -28,10 +28,10 @@ use crate::util::errors::{BitFunError, BitFunResult}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; pub use bitfun_product_domains::miniapp::host_routing::is_host_primitive; use bitfun_product_domains::miniapp::host_routing::{ - command_basename_allowed, command_basename_for_allowlist, fs_method_access_mode, - fs_policy_scopes, fs_resolved_path_allowed, host_allowed_by_allowlist, shell_exec_cwd, - shell_exec_default_env, shell_exec_first_token, shell_exec_input_is_empty, - shell_exec_timeout_ms, split_host_method, FsAccessMode, + command_basename_allowed, command_basename_for_allowlist, fs_policy_scopes, + fs_resolved_path_allowed, host_allowed_by_allowlist, plan_fs_host_call, + plan_fs_legacy_path_check, plan_shell_host_call, shell_exec_default_env, split_host_method, + FsAccessMode, MiniAppFsHostCallPlan, MiniAppHostPlanError, MiniAppHostPlanErrorKind, }; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; @@ -101,13 +101,8 @@ fn canonicalize_best_effort(p: &Path) -> PathBuf { /// canonicalized scope roots. Mirrors the worker_host.js check, but uses real /// canonicalization so e.g. `/tmp/foo` on macOS (`/private/tmp/foo`) matches a /// `/tmp` scope after both sides resolve symlinks. -fn path_allowed(policy: &Value, target: &Path, mode: &str) -> bool { - let access_mode = if mode == "write" { - FsAccessMode::Write - } else { - FsAccessMode::Read - }; - let scopes = fs_policy_scopes(policy, access_mode); +fn path_allowed(policy: &Value, target: &Path, mode: FsAccessMode) -> bool { + let scopes = fs_policy_scopes(policy, mode); if scopes.is_empty() { return false; } @@ -119,12 +114,13 @@ fn path_allowed(policy: &Value, target: &Path, mode: &str) -> bool { fs_resolved_path_allowed(&resolved, resolved_scopes) } -fn arg_path(params: &Value, key: &str) -> BitFunResult { - params - .get(key) - .and_then(|v| v.as_str()) - .map(PathBuf::from) - .ok_or_else(|| BitFunError::parse(format!("missing param: {}", key))) +fn host_plan_error(error: MiniAppHostPlanError) -> BitFunError { + match error.kind() { + MiniAppHostPlanErrorKind::Parse => BitFunError::parse(error.message().to_string()), + MiniAppHostPlanErrorKind::Validation => { + BitFunError::validation(error.message().to_string()) + } + } } fn resolve_shell_program(command: &str) -> PathBuf { @@ -137,47 +133,47 @@ fn resolve_shell_program(command: &str) -> PathBuf { } async fn dispatch_fs(policy: &Value, name: &str, params: &Value) -> BitFunResult { - // Common path arg ("path" or legacy "p"). - let path_param = params - .get("path") - .or_else(|| params.get("p")) - .and_then(|v| v.as_str()) - .map(PathBuf::from); + let legacy_path_check = plan_fs_legacy_path_check(name, params); + if let Some(check) = &legacy_path_check { + if !path_allowed(policy, &check.path, check.mode) { + return Err(deny(check.denied_message())); + } + } - if let Some(ref p) = path_param { - if let Some(mode) = fs_method_access_mode(name).policy_key() { - if !path_allowed(policy, p, mode) { - return Err(deny(format!("Path not allowed: {}", p.display()))); - } + let plan = plan_fs_host_call(name, params).map_err(host_plan_error)?; + for check in plan.path_checks() { + if legacy_path_check + .as_ref() + .is_some_and(|legacy_check| legacy_check == &check) + { + continue; + } + if !path_allowed(policy, &check.path, check.mode) { + return Err(deny(check.denied_message())); } } - match name { - "readFile" => { - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; - let enc = params - .get("encoding") - .and_then(|v| v.as_str()) - .unwrap_or("utf8"); + match plan { + MiniAppFsHostCallPlan::ReadFile { + path: p, + encoding_base64, + } => { let bytes = tokio::fs::read(&p) .await .map_err(|e| BitFunError::io(format!("readFile {}: {}", p.display(), e)))?; - if enc == "base64" { + if encoding_base64 { Ok(Value::String(BASE64.encode(&bytes))) } else { Ok(Value::String(String::from_utf8_lossy(&bytes).into_owned())) } } - "writeFile" => { - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; - let data = params.get("data").and_then(|v| v.as_str()).unwrap_or(""); + MiniAppFsHostCallPlan::WriteFile { path: p, data } => { tokio::fs::write(&p, data) .await .map_err(|e| BitFunError::io(format!("writeFile {}: {}", p.display(), e)))?; Ok(Value::Null) } - "readdir" => { - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; + MiniAppFsHostCallPlan::ReadDir { path: p } => { let mut rd = tokio::fs::read_dir(&p) .await .map_err(|e| BitFunError::io(format!("readdir {}: {}", p.display(), e)))?; @@ -196,8 +192,7 @@ async fn dispatch_fs(policy: &Value, name: &str, params: &Value) -> BitFunResult } Ok(Value::Array(out)) } - "stat" => { - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; + MiniAppFsHostCallPlan::Stat { path: p } => { let meta = tokio::fs::metadata(&p) .await .map_err(|e| BitFunError::io(format!("stat {}: {}", p.display(), e)))?; @@ -207,12 +202,7 @@ async fn dispatch_fs(policy: &Value, name: &str, params: &Value) -> BitFunResult "isFile": meta.is_file(), })) } - "mkdir" => { - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; - let recursive = params - .get("recursive") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + MiniAppFsHostCallPlan::Mkdir { path: p, recursive } => { (if recursive { tokio::fs::create_dir_all(&p).await } else { @@ -221,16 +211,11 @@ async fn dispatch_fs(policy: &Value, name: &str, params: &Value) -> BitFunResult .map_err(|e| BitFunError::io(format!("mkdir {}: {}", p.display(), e)))?; Ok(Value::Null) } - "rm" => { - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; - let recursive = params - .get("recursive") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let force = params - .get("force") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + MiniAppFsHostCallPlan::Rm { + path: p, + recursive, + force, + } => { let result = match tokio::fs::metadata(&p).await { Ok(m) if m.is_dir() => { if recursive { @@ -250,38 +235,23 @@ async fn dispatch_fs(policy: &Value, name: &str, params: &Value) -> BitFunResult result.map_err(|e| BitFunError::io(format!("rm {}: {}", p.display(), e)))?; Ok(Value::Null) } - "copyFile" => { - let src = arg_path(params, "src")?; - let dst = arg_path(params, "dst")?; - if !path_allowed(policy, &src, "read") { - return Err(deny(format!("src not allowed: {}", src.display()))); - } - if !path_allowed(policy, &dst, "write") { - return Err(deny(format!("dst not allowed: {}", dst.display()))); - } + MiniAppFsHostCallPlan::CopyFile { src, dst } => { tokio::fs::copy(&src, &dst) .await .map_err(|e| BitFunError::io(format!("copyFile: {}", e)))?; Ok(Value::Null) } - "rename" => { - let oldp = arg_path(params, "oldPath")?; - let newp = arg_path(params, "newPath")?; - if !path_allowed(policy, &oldp, "write") { - return Err(deny(format!("oldPath not allowed: {}", oldp.display()))); - } - if !path_allowed(policy, &newp, "write") { - return Err(deny(format!("newPath not allowed: {}", newp.display()))); - } + MiniAppFsHostCallPlan::Rename { + old_path: oldp, + new_path: newp, + } => { tokio::fs::rename(&oldp, &newp) .await .map_err(|e| BitFunError::io(format!("rename: {}", e)))?; Ok(Value::Null) } - "appendFile" => { + MiniAppFsHostCallPlan::AppendFile { path: p, data } => { use tokio::io::AsyncWriteExt; - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; - let data = params.get("data").and_then(|v| v.as_str()).unwrap_or(""); let mut f = tokio::fs::OpenOptions::new() .create(true) .append(true) @@ -293,17 +263,12 @@ async fn dispatch_fs(policy: &Value, name: &str, params: &Value) -> BitFunResult .map_err(|e| BitFunError::io(format!("appendFile write: {}", e)))?; Ok(Value::Null) } - "access" => { - let p = path_param.ok_or_else(|| BitFunError::parse("missing path"))?; + MiniAppFsHostCallPlan::Access { path: p } => { tokio::fs::metadata(&p) .await .map_err(|e| BitFunError::io(format!("access {}: {}", p.display(), e)))?; Ok(Value::Null) } - other => Err(BitFunError::validation(format!( - "unknown fs method: {}", - other - ))), } } @@ -314,32 +279,14 @@ async fn dispatch_shell( name: &str, params: &Value, ) -> BitFunResult { - if name != "exec" { - return Err(BitFunError::validation(format!( - "unknown shell method: {}", - name - ))); - } // Two input shapes are supported: // 1. `{ command: "git status" }` — runs through the platform shell (sh -c / cmd /C). // 2. `{ args: ["git", "rev-parse", "--is-inside-work-tree"] }` — spawns the program // directly with no shell. This is the cross-platform safe form: callers no longer // need to worry about per-shell quoting (single quotes from sh do not work under // cmd.exe on Windows, which previously broke `builtin-coding-selfie` git scans). - let argv: Option> = params.get("args").and_then(|v| v.as_array()).map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(str::to_string)) - .collect() - }); - let command = params - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if shell_exec_input_is_empty(argv.as_deref(), &command) { - return Err(BitFunError::parse("empty command")); - } + let plan = + plan_shell_host_call(name, params, workspace_dir, app_data_dir).map_err(host_plan_error)?; // Allowlist check: take the program name (basename of the first token, sans // extension) and require it to be in `policy.shell.allow`. @@ -353,22 +300,12 @@ async fn dispatch_shell( .collect() }) .unwrap_or_default(); - let first_token = shell_exec_first_token(argv.as_deref(), &command); - let base = command_basename_for_allowlist(first_token); + let base = command_basename_for_allowlist(&plan.first_token); if !command_basename_allowed(&allow, &base) { return Err(deny(format!("Command not in allowlist: {}", base))); } - // cwd: explicit > workspace > appdata. Mirrors what worker_host.js gives users - // (where process.cwd() is appDir, but the iframe always passes cwd explicitly). - let cwd = shell_exec_cwd( - params.get("cwd").and_then(|v| v.as_str()), - workspace_dir, - app_data_dir, - ); - let timeout_ms = shell_exec_timeout_ms(params.get("timeout").and_then(|v| v.as_u64())); - - let mut cmd = if let Some(argv) = argv.as_ref() { + let mut cmd = if let Some(argv) = plan.argv.as_ref() { let program = resolve_shell_program(&argv[0]); let mut c = crate::util::process_manager::create_tokio_command(program.as_os_str()); if argv.len() > 1 { @@ -379,26 +316,28 @@ async fn dispatch_shell( #[cfg(target_os = "windows")] { let mut c = crate::util::process_manager::create_tokio_command("cmd"); - c.args(["/C", &command]); + c.args(["/C", &plan.command]); c } #[cfg(not(target_os = "windows"))] { let mut c = crate::util::process_manager::create_tokio_command("sh"); - c.args(["-c", &command]); + c.args(["-c", &plan.command]); c } }; - cmd.current_dir(&cwd); + cmd.current_dir(&plan.cwd); // Match worker_host.js: never let git prompt for credentials, force C locale so // stdout parsing is deterministic. for (key, value) in shell_exec_default_env() { cmd.env(key, value); } - let output = tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()) + let output = tokio::time::timeout(Duration::from_millis(plan.timeout_ms), cmd.output()) .await - .map_err(|_| BitFunError::service(format!("shell.exec timed out after {}ms", timeout_ms)))? + .map_err(|_| { + BitFunError::service(format!("shell.exec timed out after {}ms", plan.timeout_ms)) + })? .map_err(|e| BitFunError::service(format!("shell.exec spawn failed: {}", e)))?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); diff --git a/src/crates/product-domains/AGENTS-CN.md b/src/crates/product-domains/AGENTS-CN.md index 244cc7d9f..d5258a4ba 100644 --- a/src/crates/product-domains/AGENTS-CN.md +++ b/src/crates/product-domains/AGENTS-CN.md @@ -19,10 +19,10 @@ ports;具体 runtime 行为不属于本 crate。 ## 归属边界 - `miniapp` 可以拥有 MiniApp 数据形态、纯生命周期决策、metadata/import policy、built-in bundle identity、embedded source assets、 - seed-plan facts、marker wire format 和窄 port。 + seed-plan facts、marker wire format、host primitive call plan 和窄 port。 - `function-agents` 可以拥有 function-agent DTO、prompt/domain policy、response parsing/repair rule、file-shape analysis 和 Git/AI port trait。 -- Core 仍拥有 filesystem writes、marker IO、worker/host execution、compile orchestration、`PathManager` integration、 +- Core 仍拥有 filesystem writes、marker IO、worker/host side effect、compile orchestration、`PathManager` integration、 concrete Git/AI service、provider acquisition 和 transport error mapping。 ## 验证 diff --git a/src/crates/product-domains/AGENTS.md b/src/crates/product-domains/AGENTS.md index 7e03bd0fe..847d48cdd 100644 --- a/src/crates/product-domains/AGENTS.md +++ b/src/crates/product-domains/AGENTS.md @@ -28,12 +28,12 @@ policies, and narrow ports; concrete runtime behavior belongs outside this crate - `miniapp` may own MiniApp data shapes, pure lifecycle decisions, metadata and import policies, built-in bundle identity, embedded source assets, seed-plan - facts, marker wire formats, and narrow ports. + facts, marker wire formats, host primitive call plans, and narrow ports. - `function-agents` may own function-agent DTOs, prompt/domain policies, response parsing and repair rules, file-shape analysis, and Git/AI port traits. -- Core still owns filesystem writes, marker IO, worker/host execution, compile - orchestration, `PathManager` integration, concrete Git/AI services, provider - acquisition, and transport error mapping. +- Core still owns filesystem writes, marker IO, worker/host side effects, + compile orchestration, `PathManager` integration, concrete Git/AI services, + provider acquisition, and transport error mapping. ## Verification diff --git a/src/crates/product-domains/src/miniapp/host_routing.rs b/src/crates/product-domains/src/miniapp/host_routing.rs index cabbe5403..4019367cc 100644 --- a/src/crates/product-domains/src/miniapp/host_routing.rs +++ b/src/crates/product-domains/src/miniapp/host_routing.rs @@ -1,6 +1,6 @@ //! MiniApp host-routing string helpers. -use std::path::Path; +use std::path::{Path, PathBuf}; const HOST_NAMESPACES: &[&str] = &["fs", "shell", "os", "net"]; const DEFAULT_SHELL_EXEC_TIMEOUT_MS: u64 = 30_000; @@ -23,6 +23,165 @@ impl FsAccessMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MiniAppHostPlanErrorKind { + Parse, + Validation, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppHostPlanError { + kind: MiniAppHostPlanErrorKind, + message: String, +} + +impl MiniAppHostPlanError { + pub fn parse(message: impl Into) -> Self { + Self { + kind: MiniAppHostPlanErrorKind::Parse, + message: message.into(), + } + } + + pub fn validation(message: impl Into) -> Self { + Self { + kind: MiniAppHostPlanErrorKind::Validation, + message: message.into(), + } + } + + pub fn kind(&self) -> MiniAppHostPlanErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl std::fmt::Display for MiniAppHostPlanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for MiniAppHostPlanError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppFsHostPathCheck { + pub path: PathBuf, + pub mode: FsAccessMode, + pub denied_prefix: &'static str, +} + +impl MiniAppFsHostPathCheck { + pub fn denied_message(&self) -> String { + format!( + "{} not allowed: {}", + self.denied_prefix, + self.path.display() + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MiniAppFsHostCallPlan { + ReadFile { + path: PathBuf, + encoding_base64: bool, + }, + WriteFile { + path: PathBuf, + data: String, + }, + ReadDir { + path: PathBuf, + }, + Stat { + path: PathBuf, + }, + Mkdir { + path: PathBuf, + recursive: bool, + }, + Rm { + path: PathBuf, + recursive: bool, + force: bool, + }, + CopyFile { + src: PathBuf, + dst: PathBuf, + }, + Rename { + old_path: PathBuf, + new_path: PathBuf, + }, + AppendFile { + path: PathBuf, + data: String, + }, + Access { + path: PathBuf, + }, +} + +impl MiniAppFsHostCallPlan { + pub fn path_checks(&self) -> Vec { + match self { + Self::ReadFile { path, .. } | Self::ReadDir { path } | Self::Stat { path } => { + vec![MiniAppFsHostPathCheck { + path: path.clone(), + mode: FsAccessMode::Read, + denied_prefix: "Path", + }] + } + Self::WriteFile { path, .. } + | Self::Mkdir { path, .. } + | Self::Rm { path, .. } + | Self::AppendFile { path, .. } => vec![MiniAppFsHostPathCheck { + path: path.clone(), + mode: FsAccessMode::Write, + denied_prefix: "Path", + }], + Self::CopyFile { src, dst } => vec![ + MiniAppFsHostPathCheck { + path: src.clone(), + mode: FsAccessMode::Read, + denied_prefix: "src", + }, + MiniAppFsHostPathCheck { + path: dst.clone(), + mode: FsAccessMode::Write, + denied_prefix: "dst", + }, + ], + Self::Rename { old_path, new_path } => vec![ + MiniAppFsHostPathCheck { + path: old_path.clone(), + mode: FsAccessMode::Write, + denied_prefix: "oldPath", + }, + MiniAppFsHostPathCheck { + path: new_path.clone(), + mode: FsAccessMode::Write, + denied_prefix: "newPath", + }, + ], + Self::Access { .. } => Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppShellHostCallPlan { + pub argv: Option>, + pub command: String, + pub first_token: String, + pub cwd: PathBuf, + pub timeout_ms: u64, +} + /// Returns true when `method` belongs to a namespace served by the host directly. /// /// `storage.*` is intentionally excluded: it is routed through MiniApp storage @@ -45,6 +204,155 @@ pub fn fs_method_access_mode(name: &str) -> FsAccessMode { } } +pub fn plan_fs_host_call( + name: &str, + params: &serde_json::Value, +) -> Result { + let path_param = fs_host_path_param(params); + + match name { + "readFile" => Ok(MiniAppFsHostCallPlan::ReadFile { + path: require_path(path_param)?, + encoding_base64: params + .get("encoding") + .and_then(|v| v.as_str()) + .is_some_and(|encoding| encoding == "base64"), + }), + "writeFile" => Ok(MiniAppFsHostCallPlan::WriteFile { + path: require_path(path_param)?, + data: params + .get("data") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }), + "readdir" => Ok(MiniAppFsHostCallPlan::ReadDir { + path: require_path(path_param)?, + }), + "stat" => Ok(MiniAppFsHostCallPlan::Stat { + path: require_path(path_param)?, + }), + "mkdir" => Ok(MiniAppFsHostCallPlan::Mkdir { + path: require_path(path_param)?, + recursive: params + .get("recursive") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }), + "rm" => Ok(MiniAppFsHostCallPlan::Rm { + path: require_path(path_param)?, + recursive: params + .get("recursive") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + force: params + .get("force") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }), + "copyFile" => Ok(MiniAppFsHostCallPlan::CopyFile { + src: require_named_path(params, "src")?, + dst: require_named_path(params, "dst")?, + }), + "rename" => Ok(MiniAppFsHostCallPlan::Rename { + old_path: require_named_path(params, "oldPath")?, + new_path: require_named_path(params, "newPath")?, + }), + "appendFile" => Ok(MiniAppFsHostCallPlan::AppendFile { + path: require_path(path_param)?, + data: params + .get("data") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }), + "access" => Ok(MiniAppFsHostCallPlan::Access { + path: require_path(path_param)?, + }), + other => Err(MiniAppHostPlanError::validation(format!( + "unknown fs method: {}", + other + ))), + } +} + +pub fn plan_fs_legacy_path_check( + name: &str, + params: &serde_json::Value, +) -> Option { + let mode = fs_method_access_mode(name); + mode.policy_key()?; + fs_host_path_param(params).map(|path| MiniAppFsHostPathCheck { + path, + mode, + denied_prefix: "Path", + }) +} + +fn fs_host_path_param(params: &serde_json::Value) -> Option { + params + .get("path") + .or_else(|| params.get("p")) + .and_then(|v| v.as_str()) + .map(PathBuf::from) +} + +fn require_path(path: Option) -> Result { + path.ok_or_else(|| MiniAppHostPlanError::parse("missing path")) +} + +fn require_named_path( + params: &serde_json::Value, + key: &str, +) -> Result { + params + .get(key) + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .ok_or_else(|| MiniAppHostPlanError::parse(format!("missing param: {}", key))) +} + +pub fn plan_shell_host_call( + name: &str, + params: &serde_json::Value, + workspace_dir: Option<&Path>, + app_data_dir: &Path, +) -> Result { + if name != "exec" { + return Err(MiniAppHostPlanError::validation(format!( + "unknown shell method: {}", + name + ))); + } + + let argv: Option> = params.get("args").and_then(|v| v.as_array()).map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect() + }); + let command = params + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if shell_exec_input_is_empty(argv.as_deref(), &command) { + return Err(MiniAppHostPlanError::parse("empty command")); + } + + Ok(MiniAppShellHostCallPlan { + first_token: shell_exec_first_token(argv.as_deref(), &command).to_string(), + cwd: shell_exec_cwd( + params.get("cwd").and_then(|v| v.as_str()), + workspace_dir, + app_data_dir, + ), + timeout_ms: shell_exec_timeout_ms(params.get("timeout").and_then(|v| v.as_u64())), + argv, + command, + }) +} + pub fn fs_policy_scopes(policy: &serde_json::Value, mode: FsAccessMode) -> Vec { let Some(key) = mode.policy_key() else { return Vec::new(); @@ -219,4 +527,75 @@ mod tests { [("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")] ); } + + #[test] + fn miniapp_host_fs_call_plans_preserve_existing_path_and_permission_contract() { + let read = plan_fs_host_call( + "readFile", + &serde_json::json!({ "path": "/workspace/read.txt", "encoding": "base64" }), + ) + .expect("readFile should plan"); + + assert_eq!( + read, + MiniAppFsHostCallPlan::ReadFile { + path: std::path::PathBuf::from("/workspace/read.txt"), + encoding_base64: true, + } + ); + assert_eq!( + read.path_checks(), + vec![MiniAppFsHostPathCheck { + path: std::path::PathBuf::from("/workspace/read.txt"), + mode: FsAccessMode::Read, + denied_prefix: "Path", + }] + ); + + let copy = plan_fs_host_call( + "copyFile", + &serde_json::json!({ "src": "/workspace/src.txt", "dst": "/workspace/dst.txt" }), + ) + .expect("copyFile should plan"); + assert_eq!( + copy.path_checks(), + vec![ + MiniAppFsHostPathCheck { + path: std::path::PathBuf::from("/workspace/src.txt"), + mode: FsAccessMode::Read, + denied_prefix: "src", + }, + MiniAppFsHostPathCheck { + path: std::path::PathBuf::from("/workspace/dst.txt"), + mode: FsAccessMode::Write, + denied_prefix: "dst", + }, + ] + ); + } + + #[test] + fn miniapp_host_shell_call_plans_preserve_existing_input_and_default_contract() { + let plan = plan_shell_host_call( + "exec", + &serde_json::json!({ + "args": ["git", "status"], + "command": "ignored", + "cwd": "/workspace", + "timeout": 8000, + }), + Some(Path::new("/fallback-workspace")), + Path::new("/appdata"), + ) + .expect("shell.exec should plan"); + + assert_eq!( + plan.argv, + Some(vec!["git".to_string(), "status".to_string()]) + ); + assert_eq!(plan.command, "ignored"); + assert_eq!(plan.first_token, "git"); + assert_eq!(plan.cwd, std::path::PathBuf::from("/workspace")); + assert_eq!(plan.timeout_ms, 8000); + } } diff --git a/src/crates/product-domains/tests/miniapp_contracts.rs b/src/crates/product-domains/tests/miniapp_contracts.rs index 5601d8af8..1c1fbc23d 100644 --- a/src/crates/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/product-domains/tests/miniapp_contracts.rs @@ -2,42 +2,44 @@ use bitfun_product_domains::miniapp::bridge_builder::{build_bridge_script, build_csp_content}; use bitfun_product_domains::miniapp::builtin::{ - BUILTIN_INSTALL_MARKER, BUILTIN_PLACEHOLDER_COMPILED_HTML, BuiltinInstallMarker, - BuiltinMiniAppBundle, BuiltinSeedAction, BuiltinSeedCheck, LEGACY_BUILTIN_VERSION_MARKER, build_builtin_install_marker, build_builtin_package_json, build_builtin_seed_meta, builtin_content_hash, builtin_source_files, legacy_builtin_version_marker_content, parse_builtin_install_marker, preserved_builtin_created_at, resolve_builtin_seed_action, resolve_builtin_seed_check, serialize_builtin_install_marker, should_seed_builtin_app, + BuiltinInstallMarker, BuiltinMiniAppBundle, BuiltinSeedAction, BuiltinSeedCheck, + BUILTIN_INSTALL_MARKER, BUILTIN_PLACEHOLDER_COMPILED_HTML, LEGACY_BUILTIN_VERSION_MARKER, }; use bitfun_product_domains::miniapp::compiler::compile; use bitfun_product_domains::miniapp::customization::{ - MAX_DECLINED_BUILTIN_UPDATES, MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, - MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, apply_draft_customization_metadata, decline_builtin_update_metadata, declined_builtin_update_needs_local_snapshot, is_current_declined_builtin_update, - mark_builtin_update_available_metadata, + mark_builtin_update_available_metadata, MiniAppCustomizationBaseline, + MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, + MiniAppCustomizationOriginKind, MAX_DECLINED_BUILTIN_UPDATES, }; use bitfun_product_domains::miniapp::draft::{ - MINIAPP_DRAFT_STATUS_APPLIED, MINIAPP_DRAFT_STATUS_DRAFT, build_draft_manifest, - build_draft_response, + build_draft_manifest, build_draft_response, MINIAPP_DRAFT_STATUS_APPLIED, + MINIAPP_DRAFT_STATUS_DRAFT, }; use bitfun_product_domains::miniapp::exporter::{ - ExportCheckResult, ExportTarget, MISSING_JS_RUNTIME_MESSAGE, build_export_check_result, - export_runtime_label, + build_export_check_result, export_runtime_label, ExportCheckResult, ExportTarget, + MISSING_JS_RUNTIME_MESSAGE, }; use bitfun_product_domains::miniapp::host_routing::{ - FsAccessMode, command_basename_allowed, command_basename_for_allowlist, fs_method_access_mode, + command_basename_allowed, command_basename_for_allowlist, fs_method_access_mode, fs_policy_scopes, fs_resolved_path_allowed, host_allowed_by_allowlist, is_host_primitive, - shell_exec_cwd, shell_exec_default_env, shell_exec_first_token, shell_exec_input_is_empty, - shell_exec_timeout_ms, split_host_method, + plan_fs_host_call, plan_fs_legacy_path_check, plan_shell_host_call, shell_exec_cwd, + shell_exec_default_env, shell_exec_first_token, shell_exec_input_is_empty, + shell_exec_timeout_ms, split_host_method, FsAccessMode, MiniAppFsHostCallPlan, + MiniAppFsHostPathCheck, MiniAppHostPlanErrorKind, MiniAppShellHostCallPlan, }; use bitfun_product_domains::miniapp::lifecycle::{ - MiniAppCreateInput, MiniAppUpdatePatch, apply_draft_permission_update_result, - apply_draft_source_sync_result, apply_draft_to_active, apply_import_runtime_state, - apply_recompile_result, apply_sync_from_fs_result, apply_update_patch, build_created_app, - build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, - clear_worker_restart_required_state, ensure_runtime_state, mark_deps_installed_state, - prepare_draft_app, prepare_rollback_app, workspace_dir_string, + apply_draft_permission_update_result, apply_draft_source_sync_result, apply_draft_to_active, + apply_import_runtime_state, apply_recompile_result, apply_sync_from_fs_result, + apply_update_patch, build_created_app, build_deps_revision, build_runtime_state, + build_source_revision, build_worker_revision, clear_worker_restart_required_state, + ensure_runtime_state, mark_deps_installed_state, prepare_draft_app, prepare_rollback_app, + workspace_dir_string, MiniAppCreateInput, MiniAppUpdatePatch, }; use bitfun_product_domains::miniapp::permission_policy::resolve_policy; use bitfun_product_domains::miniapp::ports::{ @@ -45,23 +47,23 @@ use bitfun_product_domains::miniapp::ports::{ MiniAppRuntimeFacade, MiniAppRuntimePort, MiniAppStoragePort, }; use bitfun_product_domains::miniapp::runtime::{ - DetectedRuntime, RuntimeKind, candidate_dirs, candidate_executable_path, detect_runtime, - runtime_lookup_order, version_manager_roots, versioned_executable_candidate, + candidate_dirs, candidate_executable_path, detect_runtime, runtime_lookup_order, + version_manager_roots, versioned_executable_candidate, DetectedRuntime, RuntimeKind, }; use bitfun_product_domains::miniapp::storage::{ - COMPILED_HTML, CUSTOMIZATION_JSON, DRAFT_JSON, DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, - DRAFTS_DIR, EMPTY_ESM_DEPENDENCIES_JSON, EMPTY_STORAGE_JSON, ESM_DEPS_JSON, INDEX_HTML, - META_JSON, MiniAppImportLayout, MiniAppStorageLayout, PACKAGE_JSON, PLACEHOLDER_COMPILED_HTML, + build_import_fallbacks, build_package_json, parse_npm_dependencies, MiniAppImportLayout, + MiniAppStorageLayout, COMPILED_HTML, CUSTOMIZATION_JSON, DRAFTS_CLEANUP_MARKER, + DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, EMPTY_ESM_DEPENDENCIES_JSON, EMPTY_STORAGE_JSON, + ESM_DEPS_JSON, INDEX_HTML, META_JSON, PACKAGE_JSON, PLACEHOLDER_COMPILED_HTML, REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, - build_import_fallbacks, build_package_json, parse_npm_dependencies, }; use bitfun_product_domains::miniapp::types::{ FsPermissions, MiniApp, MiniAppAiContext, MiniAppI18n, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, NetPermissions, NotificationPermissions, NpmDep, }; use bitfun_product_domains::miniapp::worker::{ - InstallDepsPlan, InstallResult, install_command_for_runtime, plan_install_deps, - select_lru_worker, worker_idle_timeout_ms, worker_is_idle, worker_pool_at_capacity, + install_command_for_runtime, plan_install_deps, select_lru_worker, worker_idle_timeout_ms, + worker_is_idle, worker_pool_at_capacity, InstallDepsPlan, InstallResult, }; use std::collections::BTreeMap; use std::future::Future; @@ -633,6 +635,222 @@ fn miniapp_host_routing_preserves_existing_primitive_and_allowlist_contract() { )); } +#[test] +fn miniapp_host_fs_call_plans_preserve_existing_path_and_permission_contract() { + let read = plan_fs_host_call( + "readFile", + &serde_json::json!({ "path": "/workspace/read.txt", "encoding": "base64" }), + ) + .expect("readFile should plan"); + assert_eq!( + read, + MiniAppFsHostCallPlan::ReadFile { + path: PathBuf::from("/workspace/read.txt"), + encoding_base64: true, + } + ); + assert_eq!( + read.path_checks(), + vec![MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/read.txt"), + mode: FsAccessMode::Read, + denied_prefix: "Path", + }] + ); + + let write = plan_fs_host_call( + "writeFile", + &serde_json::json!({ "p": "/workspace/out.txt", "data": "hello" }), + ) + .expect("legacy p alias should plan"); + assert_eq!( + write, + MiniAppFsHostCallPlan::WriteFile { + path: PathBuf::from("/workspace/out.txt"), + data: "hello".to_string(), + } + ); + assert_eq!( + write.path_checks(), + vec![MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/out.txt"), + mode: FsAccessMode::Write, + denied_prefix: "Path", + }] + ); + + let copy = plan_fs_host_call( + "copyFile", + &serde_json::json!({ "src": "/workspace/src.txt", "dst": "/workspace/dst.txt" }), + ) + .expect("copyFile should plan source and destination checks"); + assert_eq!( + copy.path_checks(), + vec![ + MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/src.txt"), + mode: FsAccessMode::Read, + denied_prefix: "src", + }, + MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/dst.txt"), + mode: FsAccessMode::Write, + denied_prefix: "dst", + } + ] + ); + + let rename = plan_fs_host_call( + "rename", + &serde_json::json!({ "oldPath": "/workspace/old.txt", "newPath": "/workspace/new.txt" }), + ) + .expect("rename should plan write checks for old and new paths"); + assert_eq!( + rename.path_checks(), + vec![ + MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/old.txt"), + mode: FsAccessMode::Write, + denied_prefix: "oldPath", + }, + MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/new.txt"), + mode: FsAccessMode::Write, + denied_prefix: "newPath", + } + ] + ); + + let access = plan_fs_host_call( + "access", + &serde_json::json!({ "path": "/workspace/read.txt" }), + ) + .expect("access should plan without permission checks"); + assert!(access.path_checks().is_empty()); + + assert_eq!( + plan_fs_legacy_path_check( + "copyFile", + &serde_json::json!({ "path": "/workspace/legacy.txt" }) + ), + Some(MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/legacy.txt"), + mode: FsAccessMode::Write, + denied_prefix: "Path", + }) + ); + assert_eq!( + plan_fs_legacy_path_check( + "unknownMethod", + &serde_json::json!({ "p": "/workspace/legacy.txt" }) + ), + Some(MiniAppFsHostPathCheck { + path: PathBuf::from("/workspace/legacy.txt"), + mode: FsAccessMode::Read, + denied_prefix: "Path", + }) + ); + assert_eq!( + plan_fs_legacy_path_check("access", &serde_json::json!({ "path": "/workspace/a.txt" })), + None + ); +} + +#[test] +fn miniapp_host_fs_call_plans_preserve_existing_error_contract() { + let missing_path = plan_fs_host_call("readFile", &serde_json::json!({})).unwrap_err(); + assert_eq!(missing_path.kind(), MiniAppHostPlanErrorKind::Parse); + assert_eq!(missing_path.message(), "missing path"); + + let missing_src = plan_fs_host_call( + "copyFile", + &serde_json::json!({ "dst": "/workspace/dst.txt" }), + ) + .unwrap_err(); + assert_eq!(missing_src.kind(), MiniAppHostPlanErrorKind::Parse); + assert_eq!(missing_src.message(), "missing param: src"); + + let unknown = + plan_fs_host_call("chmod", &serde_json::json!({ "path": "/workspace/a.txt" })).unwrap_err(); + assert_eq!(unknown.kind(), MiniAppHostPlanErrorKind::Validation); + assert_eq!(unknown.message(), "unknown fs method: chmod"); +} + +#[test] +fn miniapp_host_shell_call_plans_preserve_existing_input_and_default_contract() { + let argv_plan = plan_shell_host_call( + "exec", + &serde_json::json!({ + "args": ["git", "rev-parse", "--is-inside-work-tree"], + "command": "ignored when args exists", + "cwd": "/workspace", + "timeout": 8000 + }), + Some(Path::new("/fallback-workspace")), + Path::new("/appdata"), + ) + .expect("argv shell.exec should plan"); + assert_eq!( + argv_plan, + MiniAppShellHostCallPlan { + argv: Some(vec![ + "git".to_string(), + "rev-parse".to_string(), + "--is-inside-work-tree".to_string(), + ]), + command: "ignored when args exists".to_string(), + first_token: "git".to_string(), + cwd: PathBuf::from("/workspace"), + timeout_ms: 8000, + } + ); + + let command_plan = plan_shell_host_call( + "exec", + &serde_json::json!({ "command": " cargo test " }), + Some(Path::new("/workspace")), + Path::new("/appdata"), + ) + .expect("command shell.exec should plan"); + assert_eq!(command_plan.argv, None); + assert_eq!(command_plan.command, "cargo test"); + assert_eq!(command_plan.first_token, "cargo"); + assert_eq!(command_plan.cwd, PathBuf::from("/workspace")); + assert_eq!(command_plan.timeout_ms, 30_000); + + let appdata_plan = plan_shell_host_call( + "exec", + &serde_json::json!({ "command": "git status" }), + None, + Path::new("/appdata"), + ) + .expect("missing cwd should fall back to app data dir"); + assert_eq!(appdata_plan.cwd, PathBuf::from("/appdata")); +} + +#[test] +fn miniapp_host_shell_call_plans_preserve_existing_error_contract() { + let empty = plan_shell_host_call( + "exec", + &serde_json::json!({ "command": " " }), + Some(Path::new("/workspace")), + Path::new("/appdata"), + ) + .unwrap_err(); + assert_eq!(empty.kind(), MiniAppHostPlanErrorKind::Parse); + assert_eq!(empty.message(), "empty command"); + + let unknown = plan_shell_host_call( + "spawn", + &serde_json::json!({ "command": "git status" }), + Some(Path::new("/workspace")), + Path::new("/appdata"), + ) + .unwrap_err(); + assert_eq!(unknown.kind(), MiniAppHostPlanErrorKind::Validation); + assert_eq!(unknown.message(), "unknown shell method: spawn"); +} + #[test] fn miniapp_lifecycle_helpers_preserve_runtime_revision_contract() { let source = MiniAppSource { @@ -906,15 +1124,13 @@ fn miniapp_lifecycle_draft_helpers_preserve_manager_contract() { assert_eq!(permissioned.version, active.version); assert_eq!(permissioned.updated_at, 4000); - assert!( - permissioned - .permissions - .fs - .as_ref() - .unwrap() - .write - .is_some() - ); + assert!(permissioned + .permissions + .fs + .as_ref() + .unwrap() + .write + .is_some()); assert_eq!(permissioned.runtime.source_revision, "src:3:4000"); assert!(permissioned.runtime.worker_restart_required); @@ -1536,12 +1752,10 @@ fn miniapp_customization_decline_policy_updates_existing_and_trims_old_records() metadata.declined_builtin_updates.len(), MAX_DECLINED_BUILTIN_UPDATES ); - assert!( - !metadata - .declined_builtin_updates - .iter() - .any(|record| record.source_hash == "hash-v5") - ); + assert!(!metadata + .declined_builtin_updates + .iter() + .any(|record| record.source_hash == "hash-v5")); } fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { diff --git a/src/web-ui/src/flow_chat/deep-review/AGENTS.md b/src/web-ui/src/flow_chat/deep-review/AGENTS.md index 86f492456..fa50e8348 100644 --- a/src/web-ui/src/flow_chat/deep-review/AGENTS.md +++ b/src/web-ui/src/flow_chat/deep-review/AGENTS.md @@ -8,11 +8,17 @@ This file applies to DeepReview launch, report, queue, and action UI code. - The frontend resolves targets, builds the review-team manifest, and owns consent/action UI. -- The backend validates and executes the manifest; do not duplicate runtime - policy in components. +- The backend validates and executes the manifest, queue/retry state, and + report enrichment; do not duplicate runtime policy in components. - Keep `src/shared/services/review-team`, launch services, `AgentAPI`, action state, report rendering, and locales in sync. - Work packets and evidence packs are metadata-only; do not embed file contents, full diffs, raw provider bodies, or model output. - Use infrastructure APIs such as `agentAPI`; do not call Tauri directly from UI components. + +## Verification + +Use the nearest focused Web UI test or `pnpm run type-check:web`. If the change +updates manifest, queue, retry, or report contracts, also run the matching core +DeepReview check from `src/crates/core/src/agentic/deep_review/AGENTS.md`. diff --git a/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx b/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx new file mode 100644 index 000000000..f9ecdff6f --- /dev/null +++ b/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDebugInspector } from './useDebugInspector'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mocks.invoke, +})); + +vi.mock('./mainWindowInspector', () => ({ + createMainWindowInspectorScript: () => + 'window.__bitfunInspectorToggleCount = (window.__bitfunInspectorToggleCount || 0) + 1; window.__bitfun_main_inspector_active = true;', + CANCEL_MAIN_WINDOW_INSPECTOR_SCRIPT: + 'window.__bitfun_main_inspector_active = false;', + IS_INSPECTOR_ACTIVE_SCRIPT: + 'return Boolean(window.__bitfun_main_inspector_active);', +})); + +function DebugInspectorHarness(): null { + useDebugInspector(); + return null; +} + +function setTauriRuntime(enabled: boolean): void { + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: enabled ? { invoke: vi.fn() } : undefined, + }); +} + +function dispatchKey(init: KeyboardEventInit): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...init, + }); + document.body.dispatchEvent(event); + return event; +} + +describe('useDebugInspector', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.invoke.mockImplementation((command: string) => { + if (command === 'debug_devtools_available') { + return Promise.resolve(true); + } + return Promise.resolve(undefined); + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + setTauriRuntime(false); + delete (window as unknown as { __bitfunInspectorToggleCount?: number }).__bitfunInspectorToggleCount; + delete (window as unknown as { __bitfun_main_inspector_active?: boolean }).__bitfun_main_inspector_active; + }); + + it('does not intercept DevTools shortcuts outside the desktop runtime', () => { + setTauriRuntime(false); + act(() => { + root.render(); + }); + + const event = dispatchKey({ key: 'F12' }); + + expect(event.defaultPrevented).toBe(false); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + + it('opens native DevTools with F12 in the desktop runtime', async () => { + setTauriRuntime(true); + act(() => { + root.render(); + }); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_devtools_available')); + mocks.invoke.mockClear(); + + const event = dispatchKey({ key: 'F12' }); + + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_open_devtools')); + }); + + it('keeps Ctrl+Shift+I available for the element inspector', async () => { + setTauriRuntime(true); + act(() => { + root.render(); + }); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_devtools_available')); + mocks.invoke.mockClear(); + + const event = dispatchKey({ key: 'i', ctrlKey: true, shiftKey: true }); + + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => + expect((window as unknown as { __bitfunInspectorToggleCount?: number }).__bitfunInspectorToggleCount) + .toBe(1) + ); + }); + + it('does not intercept shortcuts when desktop DevTools are unavailable', async () => { + mocks.invoke.mockImplementation((command: string) => { + if (command === 'debug_devtools_available') { + return Promise.resolve(false); + } + return Promise.resolve(undefined); + }); + setTauriRuntime(true); + act(() => { + root.render(); + }); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_devtools_available')); + mocks.invoke.mockClear(); + + const event = dispatchKey({ key: 'F12' }); + + expect(event.defaultPrevented).toBe(false); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/infrastructure/debug/useDebugInspector.ts b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts index bd71bfc99..795f2e85b 100644 --- a/src/web-ui/src/infrastructure/debug/useDebugInspector.ts +++ b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts @@ -1,16 +1,15 @@ /** * Desktop debug inspector hook. * - * Provides Cmd/Ctrl + Shift + I shortcut to toggle the interactive element - * inspector in the main webview. Only active in development or when the + * Provides desktop-only shortcuts for the native DevTools window and the + * interactive element inspector. Only active in development or when the * desktop app is built with the `devtools` feature. * * The inspector is injected via `eval()` into the current page, so it works * without any server-side changes and has zero overhead when inactive. */ -import { useCallback } from 'react'; -import { useShortcut } from '@/infrastructure/hooks/useShortcut'; +import { useEffect } from 'react'; import { createLogger } from '@/shared/utils/logger'; import { isTauriRuntime } from '@/infrastructure/runtime'; import { @@ -21,17 +20,22 @@ import { const log = createLogger('DebugInspector'); -/** Detect whether we are running inside a Tauri desktop webview with devtools available. */ -function isDevToolsAvailable(): boolean { +type DebugShortcutAction = 'toggleInspector' | 'openDevTools'; + +/** Detect whether the desktop backend exposes debug commands in this build. */ +async function loadDevToolsAvailable(): Promise { // In a standard web build (non-Tauri) the inspector is useless because we // already have browser DevTools. Only enable in the desktop webview. if (typeof window === 'undefined') return false; if (!isTauriRuntime()) return false; - // The backend only exposes debug commands when compiled with devtools feature - // or in debug builds. We optimistically enable the shortcut here; the invoke - // will gracefully fail if the backend does not support it. - return true; + try { + const { invoke } = await import('@tauri-apps/api/core'); + return await invoke('debug_devtools_available'); + } catch (error) { + log.error('Failed to detect DevTools availability', error); + return false; + } } /** Toggle the element inspector by eval-ing the inspector script into the page. */ @@ -74,48 +78,81 @@ async function openNativeDevTools(): Promise { } } +function isPrimaryModifier(event: KeyboardEvent): boolean { + const isMac = typeof navigator !== 'undefined' + && navigator.platform.toUpperCase().includes('MAC'); + return isMac ? event.metaKey : event.ctrlKey; +} + +function getDebugShortcutAction(event: KeyboardEvent): DebugShortcutAction | null { + if ( + event.key === 'F12' && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + return 'openDevTools'; + } + + if (!isPrimaryModifier(event) || !event.shiftKey || event.altKey) { + return null; + } + + const key = event.key.toLowerCase(); + if (key === 'i') { + return 'toggleInspector'; + } + if (key === 'j') { + return 'openDevTools'; + } + return null; +} + /** * Register debug shortcuts when running in a Tauri desktop environment. * * Shortcuts: * Cmd/Ctrl + Shift + I → Toggle element inspector * Cmd/Ctrl + Shift + J → Open native DevTools + * F12 → Open native DevTools * - * Note: shortcuts are always registered (so they work even if Tauri runtime - * detection races with component mount), but the callback no-ops when not - * in a Tauri desktop environment. + * These shortcuts intentionally bypass the user-configurable product shortcut + * manager so development tools stay available even when app shortcuts change. */ export function useDebugInspector(): void { - const handleToggleInspector = useCallback(() => { - if (!isDevToolsAvailable()) return; - void toggleInspector(); - }, []); + useEffect(() => { + if (typeof window === 'undefined') return; - const handleOpenDevTools = useCallback(() => { - if (!isDevToolsAvailable()) return; - void openNativeDevTools(); - }, []); + let cancelled = false; + let unregister: (() => void) | null = null; - // Ctrl/Cmd + Shift + I — toggle element inspector - // ctrl: true maps to Cmd on macOS and Ctrl on Windows/Linux (handled by ShortcutManager) - useShortcut( - 'debug.toggleInspector', - { key: 'i', ctrl: true, shift: true, scope: 'app', allowInInput: true }, - handleToggleInspector, - { - priority: 100, - description: 'Toggle element inspector', - } - ); - - // Ctrl/Cmd + Shift + J — open native DevTools - useShortcut( - 'debug.openDevTools', - { key: 'j', ctrl: true, shift: true, scope: 'app', allowInInput: true }, - handleOpenDevTools, - { - priority: 100, - description: 'Open native DevTools', - } - ); + void (async () => { + const available = await loadDevToolsAvailable(); + if (cancelled || !available) return; + + const handleKeyDown = (event: KeyboardEvent) => { + const action = getDebugShortcutAction(event); + if (!action) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + if (action === 'toggleInspector') { + void toggleInspector(); + return; + } + void openNativeDevTools(); + }; + + window.addEventListener('keydown', handleKeyDown, { capture: true }); + unregister = () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); + })(); + + return () => { + cancelled = true; + unregister?.(); + }; + }, []); }