From ce6feb5e7d7ec602404121f5d2e05e7f238f0049 Mon Sep 17 00:00:00 2001 From: erow Date: Tue, 25 Aug 2026 16:11:29 +0800 Subject: [PATCH 1/5] docs: add Cursor Cloud environment setup notes to AGENTS.md (#1) Co-authored-by: Cursor Agent --- AGENTS.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index abc15c8340..ff0c13f292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -368,3 +368,50 @@ document a command only when a real workflow needs it. ## 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. + +## Cursor Cloud specific instructions + +These notes cover non-obvious gotchas for developing BitFun inside the Cloud +Agent VM. Standard commands live in `README.md`, `CONTRIBUTING.md`, and +`package.json`; only the surprises are recorded here. The base image already +has Node 22, pnpm 10.15 (Corepack), the Rust stable toolchain, and the Tauri +Linux system libraries (webkit2gtk-4.1, gtk-3, libxdo, appindicator, librsvg2, +tesseract/leptonica, xcb) preinstalled. The startup script only runs +`pnpm install` plus the two generated-artifact steps below. + +- **Rust must be >= 1.85.** The committed lockfile pulls crates that require the + `edition2024` Cargo feature, so an older toolchain fails resolution with + "feature `edition2024` is required". The VM uses current stable (1.98+). +- **Linker fix for `rust-lld` (`-lstdc++` not found).** Rust's default + `rust-lld` linker on this image does not search GCC's private lib dir, where + the `libstdc++.so` dev symlink lives, so C++-linking crates + (tesseract/leptonica in `bitfun-desktop`) fail to link. This is resolved + globally in `$CARGO_HOME/config.toml` (`/usr/local/cargo/config.toml`) via + `[env] LIBRARY_PATH = "/usr/lib/gcc/x86_64-linux-gnu/13"`. If a fresh VM ever + hits `unable to find library -lstdc++`, recreate that config entry. +- **Generated TypeScript API barrel is required for the Web UI.** + `src/web-ui/src/generated/api/` is git-ignored and produced by + `pnpm --dir src/web-ui run gen:types` (which runs a `cargo test ... export`, + so it needs the Rust toolchain). Without it, `pnpm run type-check:web`, + `pnpm run lint:web`, `pnpm run dev:web`, and the Vite dev server fail to + resolve `@/generated/api`. `pnpm run build:web` regenerates it automatically; + `type-check:web` / `dev:web` do not. The startup script runs `gen:types` once + so the tree is ready. +- **`bitfun-desktop` Rust builds need `src/mobile-web/dist`.** The Tauri build + script references it as a resource, so bare `cargo check/build -p + bitfun-desktop` (or `--workspace`) fails with "resource path doesn't exist" + until you run `pnpm run prepare:mobile-web`. `pnpm run desktop:dev` builds it + automatically in its prep step; the startup script also pre-builds it. +- **Running the desktop app.** `pnpm run desktop:dev` (the primary loop) works + headlessly on `DISPLAY=:1`; the GUI window opens there and the Vite dev + server serves on `localhost:1422`. The first `tauri dev` run recompiles the + workspace with the `devtools` feature, which is a long cold build; subsequent + runs are incremental. A prebuilt debug binary can be launched faster with + `pnpm run desktop:preview:debug` (frontend HMR only, no Rust auto-rebuild). +- **Exercising the AI agent needs an LLM key.** The agent chat loop is inert + until a provider/model is configured in Settings → Models (see README "First + run"). Model configuration and keys are stored under `~/.config/bitfun` + (outside the repo), so they are never committed. +- **Bun** (for the CLI plugin/extension host, `pnpm run plugin-host:*` and + `cli:*`) is not preinstalled; install `bun@1.3.14` only if you work on the CLI + surface. From 904e5ef9531aaec388b0575d990c2c9dcd8e72df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 07:57:09 +0000 Subject: [PATCH 2/5] feat(miniapp): add view modes and lifecycle-script contracts + design spec Introduce the foundational contract layer for the Mini App system upgrade: - MiniAppViewMode (background/front/full) with wire (de)serialization and Front default - MiniAppLifecycleEvent (install/uninstall/start/stop) and MiniAppLifecycleScripts manifest - view_mode and lifecycle fields on MiniApp/MiniAppMeta (additive, defaulted, upgrade-safe) - HOOKS_DIR + resolve_contained_relative path-traversal guard in the storage layout - plan_lifecycle_script pure resolver and miniapp_lifecycle_event_payload - design specification doc (docs/features/mini-app-system.md) Contracts stay pure (no IO); services/assembly/desktop/web-ui wiring follows. Includes unit + contract tests in bitfun-product-domains. Co-authored-by: erow --- docs/features/mini-app-system.md | 255 ++++++++++++++++++ .../implementations/miniapp_publish_tool.rs | 2 + .../assembly/core/src/miniapp/manager.rs | 7 + .../product-domains/src/miniapp/lifecycle.rs | 196 +++++++++++++- .../src/miniapp/runtime_facade.rs | 4 + .../product-domains/src/miniapp/storage.rs | 57 ++++ .../product-domains/src/miniapp/types.rs | 144 ++++++++++ .../tests/miniapp_contracts.rs | 2 + .../src/miniapp/storage.rs | 6 + .../src/miniapp_market/package.rs | 2 + 10 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 docs/features/mini-app-system.md diff --git a/docs/features/mini-app-system.md b/docs/features/mini-app-system.md new file mode 100644 index 0000000000..db196c7f62 --- /dev/null +++ b/docs/features/mini-app-system.md @@ -0,0 +1,255 @@ +# Mini App System — Design Specification + +**English** · Status: living specification (implemented incrementally) + +This document is the design specification for BitFun's **Mini App** system. It +defines three things the runtime and UI must agree on: + +1. **Lifecycle events** — `install`, `uninstall`, `start`, `stop`, each of which + may trigger a user-defined script. +2. **View modes** — `background` (collapsed into a panel), `front` (default, in a + tab), and `full` (an independent OS window). +3. **App management** — the standardized on-disk location and directory + structure for an installed app. + +It complements the architecture map in +[`product-architecture.md`](../architecture/product-architecture.md) and the +security boundary in +[`../sdlc-harness/architecture/security-boundary.md`](../sdlc-harness/architecture/security-boundary.md). +It does **not** cover BitFun Pages / Page Functions +(`bitfun-page-function-runtime`), which are a separate relay-hosted system. + +Layer ownership follows the repository boundary rules: + +| Concern | Owner layer | Crate / path | +| --- | --- | --- | +| Data shapes, pure lifecycle/view-mode decisions, path contract | Contracts | `src/crates/contracts/product-domains/src/miniapp` | +| Concrete filesystem IO, worker/script process execution | Services | `src/crates/services/services-integrations/src/miniapp` | +| Manager orchestration, `PathManager` wiring, event emission | Assembly | `src/crates/assembly/core/src/miniapp` | +| Tauri commands, independent windows | Interface (desktop) | `src/apps/desktop/src/api` | +| Gallery, scenes, panels, window hosting | Interface (web-ui) | `src/web-ui/src/app/scenes/miniapps` | + +--- + +## 1. App management: local path and directory structure + +### 1.1 Root location + +Mini App data is **user-scoped**, not workspace-scoped. The root is resolved by +`PathManager`: + +- `miniapps_dir()` → `{user_root}/data/miniapps/` +- `miniapp_dir(app_id)` → `{user_root}/data/miniapps/{app_id}/` + +`{user_root}` is the platform BitFun home: + +| OS | `{user_root}` | +| --- | --- | +| Linux | `~/.config/bitfun` | +| macOS | `~/Library/Application Support/BitFun` | +| Windows | `%APPDATA%/BitFun` | + +The frontend mirrors the segment as `MINIAPP_DATA_PATH_SEGMENT = +'/data/miniapps/'`. Workspace services explicitly exclude these paths from +workspace files. + +`app_id` is a UUID v4 for user-created / imported / draft apps; built-ins use a +stable `builtin-*` id. + +### 1.2 Per-app directory layout + +The canonical layout (owned by `MiniAppStorageLayout` in +`product-domains/.../miniapp/storage.rs`): + +``` +{user_root}/data/miniapps/{app_id}/ +├── meta.json # MiniAppMeta: identity, permissions, runtime state, +│ # view_mode, lifecycle scripts, i18n +├── compiled.html # Generated sandbox document (UI + import map + bridge) +├── package.json # npm deps for the worker +├── storage.json # App key/value storage +├── .customization.json # Origin / market / override metadata +├── .builtin-manifest.json # Built-in seed marker (built-ins only) +├── source/ +│ ├── index.html +│ ├── style.css +│ ├── ui.js # ESM browser module +│ ├── worker.js # Node/Bun worker entry +│ └── esm_dependencies.json +├── hooks/ # (optional) lifecycle scripts — see §2 +│ ├── install.js +│ ├── uninstall.js +│ ├── start.js +│ └── stop.js +└── versions/ + └── v{N}.json # Full snapshots for rollback +``` + +Drafts live under a sibling sandbox and never touch the active app until +applied: + +``` +{user_root}/data/miniapps/.drafts/{app_id}/{draft_id}/ +``` + +Market install/update/rollback use temporary staging dirs +(`.market-install-*`, `.market-update-*`, `.market-rollback-*`) and commit +atomically so a failed install never leaves a half-written app. + +The `hooks/` directory is a **recommended convention** (constant +`HOOKS_DIR = "hooks"`), not a hard requirement: a lifecycle script path is any +path relative to the app root (see §2.2). + +--- + +## 2. Lifecycle events and scripts + +### 2.1 Events + +A Mini App has four host-driven lifecycle transitions +(`MiniAppLifecycleEvent`): + +| Event | When it fires | Typical use | +| --- | --- | --- | +| `install` | After the app's files are first committed to disk (create / import / market install) | Fetch assets, initialize `storage.json`, scaffold data | +| `uninstall` | Before the app directory is removed | Clean up external state, revoke tokens | +| `start` | When the app is activated / its worker is brought up | Warm caches, open connections | +| `stop` | When the app is deactivated / its worker is torn down | Flush state, close connections | + +These are distinct from the in-iframe UI hooks (`app.onActivate` / +`app.onDeactivate`) exposed by the bridge, which react to focus changes inside +an already-running app. Lifecycle scripts run in the **host JS runtime** +(Bun/Node), not in the sandboxed iframe. + +### 2.2 Declaring scripts + +Scripts are declared in `meta.json` under `lifecycle` +(`MiniAppLifecycleScripts`): + +```json +{ + "lifecycle": { + "install": "hooks/install.js", + "uninstall": "hooks/uninstall.js", + "start": "hooks/start.js", + "stop": "hooks/stop.js" + } +} +``` + +Rules: + +- Each value is a path **relative to the app root**. `hooks/install.js` and + `worker.js` are both valid; the recommended location is `hooks/`. +- Whitespace-only or absent entries mean "no script for this event". +- The field is omitted from `meta.json` entirely when no scripts are declared + (`skip_serializing_if`), so existing apps are unaffected. + +### 2.3 Resolution and safety + +Path resolution is a **pure decision** in the contracts layer +(`plan_lifecycle_script` → `MiniAppStorageLayout::resolve_contained_relative`): + +- The relative path is resolved against the app directory. +- Absolute paths, `..` parent components, and root/drive prefixes are + **rejected** — a script can never escape its own app directory. +- A path that resolves back to the app root itself is rejected. + +The services layer then confirms the file exists and executes it; the contracts +layer never touches the filesystem. + +### 2.4 Execution semantics + +- Scripts run with the detected runtime (`RuntimeKind::Bun` preferred, else + `Node`), using the same non-interactive process facade as the worker + (`bitfun_services_core::process_manager`), so no console window flashes on + Windows and GUI/headless hosts behave identically. +- The working directory is the app directory; the resolved script path is + passed as the entry. +- Scripts run with the app's resolved permission policy (fs/net/shell scopes), + identical to the worker, so a lifecycle script cannot exceed what the app is + already granted. +- Each run emits a `miniapp-lifecycle` event + (`miniapp_lifecycle_event_payload`: `{ id, event, script, succeeded }`) for + the UI / telemetry. +- Failures are surfaced, not silently swallowed. `install` failure aborts the + install and rolls back atomically; `uninstall` failure is reported but does + not block directory removal (an app must always be removable). `start` / + `stop` failures are reported and do not wedge the worker lifecycle. + +--- + +## 3. View modes + +### 3.1 Modes + +`MiniAppViewMode` (persisted in `meta.json` as `view_mode`) selects how the host +presents the app: + +| Mode | Wire value | Presentation | +| --- | --- | --- | +| `Background` | `background` | Collapsed into a compact resident panel (dock / side rail). Stays running without occupying the main content area. | +| `Front` (default) | `front` | Opens inside a tab in the main content scene area. This is today's behavior and the default for existing apps. | +| `Full` | `full` | Opens in its own independent OS window, detached from the main shell. | + +`view_mode` defaults to `Front`; unknown wire values fall back to `Front` +(`MiniAppViewMode::from_wire`), so an older client reading a newer mode degrades +to a tab rather than failing. + +### 3.2 Semantics + +- The mode is a **persisted app property** (part of the content hash), editable + by the author and via the view-mode command; it is the app's default + presentation. +- The runtime may still let the user temporarily relocate an open app (e.g. + pop a `front` app out to a window), but the persisted `view_mode` is the + restore default. +- `background` apps keep their worker resident and surface through the nav + running-apps entry and a compact panel; they do not claim a scene tab. +- `full` apps are hosted in an independent desktop window that loads the same + compiled document and bridge as the tab host, so behavior and permissions are + identical across modes. + +### 3.3 Remote / non-desktop surfaces + +View mode is a presentation hint. Surfaces that cannot honor a mode (mobile web, +CLl/TUI, peer host) must degrade explicitly to their supported presentation +(typically `front`-equivalent) rather than silently dropping the app, per the +repository's "degrade loudly" rule. + +--- + +## 4. Data model and upgrade compatibility + +`MiniApp` and `MiniAppMeta` gain two additive, defaulted fields: + +- `view_mode: MiniAppViewMode` — `#[serde(default)]`, defaults to `Front`. +- `lifecycle: MiniAppLifecycleScripts` — `#[serde(default)]`, omitted when empty. + +Both are included in `miniapp_content_hash`, so editing them is tracked like any +other content change. Because both deserialize from absent values, **existing +`meta.json` files load unchanged** and keep their current behavior (in-tab, no +scripts) after an upgrade. No field that old data cannot supply is required, and +no persisted field is repurposed — consistent with the upgrade-compatibility +rules in the root `AGENTS.md`. + +--- + +## 5. Implementation status + +The specification is delivered incrementally. Current state: + +- [x] Contracts: `MiniAppViewMode`, `MiniAppLifecycleEvent`, + `MiniAppLifecycleScripts`, `view_mode` / `lifecycle` fields, `HOOKS_DIR`, + safe path resolver, `plan_lifecycle_script`, and event payload — with unit + and contract tests in `bitfun-product-domains`. +- [ ] Services: execute lifecycle scripts on install/uninstall/start/stop via the + process facade, with permission policy and event emission. +- [ ] Assembly: manager dispatch of lifecycle events and view-mode updates wired + to `PathManager`. +- [ ] Desktop: Tauri commands for setting view mode and for independent (`full`) + windows. +- [ ] Web UI: render `background` panel, `front` tab, and `full` window; expose + lifecycle status. + +Each subsequent change keeps this table current. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs index 018d514e27..bdc968d055 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -566,6 +566,8 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), i18n: locale_name.map(|value| MiniAppI18n { locales: HashMap::from([( "en-US".to_string(), diff --git a/src/crates/assembly/core/src/miniapp/manager.rs b/src/crates/assembly/core/src/miniapp/manager.rs index 9f88d0d56c..70127459b8 100644 --- a/src/crates/assembly/core/src/miniapp/manager.rs +++ b/src/crates/assembly/core/src/miniapp/manager.rs @@ -221,6 +221,11 @@ impl MiniAppManager { source, permissions, ai_context, + // View mode and lifecycle scripts are managed through dedicated + // paths (import/meta + view-mode command); the generic update keeps + // them unchanged. + view_mode: None, + lifecycle: None, }; let now = Utc::now().timestamp_millis(); let compiled_html = if self.uses_market_strict_runtime(app_id).await { @@ -940,6 +945,8 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), i18n: None, }; tokio::fs::write( diff --git a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs index 434b9b2a2d..62f719d454 100644 --- a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs +++ b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs @@ -2,8 +2,10 @@ use std::path::{Path, PathBuf}; +use crate::miniapp::storage::MiniAppStorageLayout; use crate::miniapp::types::{ - MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, + MiniApp, MiniAppAiContext, MiniAppLifecycleEvent, MiniAppLifecycleScripts, MiniAppMeta, + MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, MiniAppViewMode, }; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -30,6 +32,8 @@ pub struct MiniAppUpdatePatch { pub source: Option, pub permissions: Option, pub ai_context: Option, + pub view_mode: Option, + pub lifecycle: Option, } impl MiniAppUpdatePatch { @@ -75,6 +79,8 @@ pub fn miniapp_content_hash(app: &MiniApp) -> String { "permissions": &app.permissions, "aiContext": &app.ai_context, "runtimeProfile": app.runtime_profile, + "viewMode": app.view_mode, + "lifecycle": &app.lifecycle, "i18n": &app.i18n, })); let encoded = serde_json::to_vec(&payload) @@ -144,6 +150,8 @@ pub fn build_created_app( ai_context: input.ai_context, runtime, runtime_profile: Default::default(), + view_mode: MiniAppViewMode::default(), + lifecycle: MiniAppLifecycleScripts::default(), i18n: None, }; refresh_content_hash(&mut app); @@ -184,6 +192,12 @@ pub fn apply_update_patch( if let Some(ai_context) = patch.ai_context { app.ai_context = Some(ai_context); } + if let Some(view_mode) = patch.view_mode { + app.view_mode = view_mode; + } + if let Some(lifecycle) = patch.lifecycle { + app.lifecycle = lifecycle; + } app.version += 1; app.updated_at = now; @@ -276,6 +290,8 @@ pub fn apply_draft_to_active( app.source = draft.source; app.permissions = draft.permissions; app.ai_context = draft.ai_context; + app.view_mode = draft.view_mode; + app.lifecycle = draft.lifecycle; app.i18n = draft.i18n; app.version = current.version + 1; app.updated_at = now; @@ -458,6 +474,52 @@ pub fn miniapp_worker_stopped_payload(app_id: &str, reason: &str) -> Value { json!({ "id": app_id, "reason": reason }) } +/// A resolved lifecycle-script invocation the host runtime should execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppLifecyclePlan { + pub event: MiniAppLifecycleEvent, + /// Absolute path to the script file, guaranteed to be inside the app dir. + pub script_path: PathBuf, + /// The manifest-relative path, retained for logging/telemetry. + pub relative_path: String, +} + +/// Resolve the script (if any) to run for `event`, given the app's on-disk +/// layout and its declared lifecycle scripts. +/// +/// Returns `None` when no script is declared or when the declared path would +/// escape the app directory (see +/// [`MiniAppStorageLayout::resolve_contained_relative`]). This is a pure +/// decision: the services layer confirms the file exists and executes it. +pub fn plan_lifecycle_script( + layout: &MiniAppStorageLayout, + scripts: &MiniAppLifecycleScripts, + event: MiniAppLifecycleEvent, +) -> Option { + let relative = scripts.script_for(event)?; + let script_path = layout.resolve_contained_relative(relative)?; + Some(MiniAppLifecyclePlan { + event, + script_path, + relative_path: relative.to_string(), + }) +} + +/// Frontend/telemetry payload emitted when a lifecycle script runs. +pub fn miniapp_lifecycle_event_payload( + app_id: &str, + event: MiniAppLifecycleEvent, + relative_path: &str, + succeeded: bool, +) -> Value { + json!({ + "id": app_id, + "event": event.as_str(), + "script": relative_path, + "succeeded": succeeded, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -478,6 +540,8 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), i18n: None, }; @@ -547,4 +611,134 @@ mod tests { assert_eq!(worker_restart_reason(true), "deps-installed"); assert_eq!(worker_restart_reason(false), "runtime-restart"); } + + fn sample_app() -> MiniApp { + build_created_app( + "app-1".to_string(), + MiniAppCreateInput { + name: "App".to_string(), + description: "Desc".to_string(), + icon: "box".to_string(), + category: "utility".to_string(), + tags: vec![], + source: MiniAppSource::default(), + permissions: MiniAppPermissions::default(), + ai_context: None, + }, + "".to_string(), + 123, + ) + } + + #[test] + fn view_mode_defaults_to_front_and_round_trips_over_wire() { + assert_eq!(MiniAppViewMode::default(), MiniAppViewMode::Front); + for mode in [ + MiniAppViewMode::Background, + MiniAppViewMode::Front, + MiniAppViewMode::Full, + ] { + assert_eq!(MiniAppViewMode::from_wire(mode.as_str()), mode); + } + // Unknown values fall back to Front rather than erroring. + assert_eq!(MiniAppViewMode::from_wire("bogus"), MiniAppViewMode::Front); + } + + #[test] + fn lifecycle_scripts_report_declared_events_and_emptiness() { + let empty = MiniAppLifecycleScripts::default(); + assert!(empty.is_empty()); + for event in MiniAppLifecycleEvent::all() { + assert_eq!(empty.script_for(event), None); + } + + let scripts = MiniAppLifecycleScripts { + install: Some("hooks/install.js".to_string()), + // Whitespace-only entries are treated as absent. + uninstall: Some(" ".to_string()), + start: Some(" worker.js ".to_string()), + stop: None, + }; + assert!(!scripts.is_empty()); + assert_eq!( + scripts.script_for(MiniAppLifecycleEvent::Install), + Some("hooks/install.js") + ); + assert_eq!(scripts.script_for(MiniAppLifecycleEvent::Uninstall), None); + assert_eq!( + scripts.script_for(MiniAppLifecycleEvent::Start), + Some("worker.js") + ); + assert_eq!(scripts.script_for(MiniAppLifecycleEvent::Stop), None); + } + + #[test] + fn plan_lifecycle_script_resolves_declared_scripts_and_rejects_escapes() { + let layout = MiniAppStorageLayout::new("/root/miniapps", "app-1"); + let scripts = MiniAppLifecycleScripts { + install: Some("hooks/install.js".to_string()), + uninstall: Some("../evil.js".to_string()), + start: Some("/etc/passwd".to_string()), + stop: None, + }; + + let install = plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Install) + .expect("install script should resolve"); + assert_eq!(install.event, MiniAppLifecycleEvent::Install); + assert_eq!(install.relative_path, "hooks/install.js"); + assert_eq!( + install.script_path, + layout.app_dir().join("hooks").join("install.js") + ); + + // Path traversal and absolute paths are rejected. + assert_eq!( + plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Uninstall), + None + ); + assert_eq!( + plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Start), + None + ); + // Undeclared events produce no plan. + assert_eq!( + plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Stop), + None + ); + } + + #[test] + fn content_hash_tracks_view_mode_and_lifecycle_changes() { + let base = sample_app(); + let base_hash = miniapp_content_hash(&base); + + let mut with_mode = base.clone(); + with_mode.view_mode = MiniAppViewMode::Full; + assert_ne!(miniapp_content_hash(&with_mode), base_hash); + + let mut with_hooks = base.clone(); + with_hooks.lifecycle.install = Some("hooks/install.js".to_string()); + assert_ne!(miniapp_content_hash(&with_hooks), base_hash); + } + + #[test] + fn update_patch_applies_view_mode_and_lifecycle() { + let previous = sample_app(); + let patch = MiniAppUpdatePatch { + view_mode: Some(MiniAppViewMode::Background), + lifecycle: Some(MiniAppLifecycleScripts { + start: Some("hooks/start.js".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let updated = apply_update_patch(&previous, patch, "".to_string(), 456); + assert_eq!(updated.view_mode, MiniAppViewMode::Background); + assert_eq!( + updated.lifecycle.script_for(MiniAppLifecycleEvent::Start), + Some("hooks/start.js") + ); + assert_eq!(updated.version, previous.version + 1); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs index 5a9d296410..5ed1592595 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs @@ -192,6 +192,8 @@ impl<'a> MiniAppRuntimeFacade<'a> { source: Some(source), permissions: Some(permissions), ai_context: None, + view_mode: None, + lifecycle: None, }, compiled_html, now, @@ -866,6 +868,8 @@ mod tests { ai_context: None, runtime: MiniAppRuntimeState::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), i18n: None, } } diff --git a/src/crates/contracts/product-domains/src/miniapp/storage.rs b/src/crates/contracts/product-domains/src/miniapp/storage.rs index 6eabcc6e69..e6da575e47 100644 --- a/src/crates/contracts/product-domains/src/miniapp/storage.rs +++ b/src/crates/contracts/product-domains/src/miniapp/storage.rs @@ -20,6 +20,11 @@ pub const EMPTY_STORAGE_JSON: &str = "{}"; pub const PLACEHOLDER_COMPILED_HTML: &str = "Loading..."; pub const VERSIONS_DIR: &str = "versions"; +/// Conventional directory for lifecycle hook scripts inside an app +/// (`{app_dir}/hooks/`). Lifecycle script paths in `meta.json` are resolved +/// relative to the app root, so hooks may also live at the app root; this +/// constant documents the recommended layout. +pub const HOOKS_DIR: &str = "hooks"; pub const DRAFTS_DIR: &str = ".drafts"; pub const DRAFTS_CLEANUP_PREFIX: &str = ".drafts.cleanup-"; pub const DRAFTS_CLEANUP_MARKER: &str = ".cleanup-pending"; @@ -120,6 +125,22 @@ impl MiniAppStorageLayout { self.app_dir().join(VERSIONS_DIR) } + /// Recommended directory for lifecycle hook scripts (`{app_dir}/hooks/`). + pub fn hooks_dir(&self) -> PathBuf { + self.app_dir().join(HOOKS_DIR) + } + + /// Resolve a lifecycle-script path (declared in `meta.json`, relative to the + /// app root) into an absolute path, rejecting anything that would escape the + /// app directory. + /// + /// Returns `None` when the value is empty, absolute, or contains a parent + /// (`..`) component. This is a pure, lexical containment check; the caller + /// (services layer) is still responsible for confirming the file exists. + pub fn resolve_contained_relative(&self, relative: &str) -> Option { + resolve_contained_relative(&self.app_dir(), relative) + } + pub fn version_path(&self, version: u32) -> PathBuf { self.versions_dir().join(format!("v{}.json", version)) } @@ -159,6 +180,42 @@ impl MiniAppStorageLayout { } } +/// Lexically resolve `relative` under `root`, rejecting absolute paths, empty +/// input, and any `..` / root / drive-prefix components that would escape +/// `root`. Returns `None` when the value cannot be safely contained or resolves +/// back to `root` itself. +/// +/// This is a pure containment check: it does not touch the filesystem, so the +/// caller (services layer) must still confirm the resolved file exists. +pub fn resolve_contained_relative(root: &Path, relative: &str) -> Option { + let trimmed = relative.trim(); + if trimmed.is_empty() { + return None; + } + + let candidate = Path::new(trimmed); + if candidate.is_absolute() { + return None; + } + + let mut resolved = root.to_path_buf(); + for component in candidate.components() { + match component { + std::path::Component::Normal(part) => resolved.push(part), + std::path::Component::CurDir => {} + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => return None, + } + } + + if resolved == root { + return None; + } + + Some(resolved) +} + /// Parse package.json dependencies using the legacy MiniApp storage contract. pub fn parse_npm_dependencies(package_json: &str) -> Result, serde_json::Error> { let package: serde_json::Value = serde_json::from_str(package_json)?; diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs index db0d378e18..cbfd90f0a6 100644 --- a/src/crates/contracts/product-domains/src/miniapp/types.rs +++ b/src/crates/contracts/product-domains/src/miniapp/types.rs @@ -222,6 +222,133 @@ pub enum MiniAppRuntimeProfile { MarketStrict, } +/// How a MiniApp is presented in the host shell. +/// +/// - `Background`: collapsed into a compact panel (dock/side rail) that keeps the +/// app resident without occupying the main content area. +/// - `Front` (default): opens inside a tab in the main content scene area. +/// - `Full`: opens in its own independent OS window, detached from the main shell. +/// +/// Persisted in `meta.json` as `view_mode`; absent values default to `Front` so +/// existing installs keep their current in-tab behavior after an upgrade. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MiniAppViewMode { + Background, + #[default] + Front, + Full, +} + +impl MiniAppViewMode { + /// Stable wire identifier shared with the frontend and Tauri layer. + pub fn as_str(&self) -> &'static str { + match self { + MiniAppViewMode::Background => "background", + MiniAppViewMode::Front => "front", + MiniAppViewMode::Full => "full", + } + } + + /// Parse a wire identifier; unknown values fall back to the default (`Front`). + pub fn from_wire(value: &str) -> Self { + match value { + "background" => MiniAppViewMode::Background, + "full" => MiniAppViewMode::Full, + _ => MiniAppViewMode::Front, + } + } +} + +/// A MiniApp lifecycle transition that can trigger a user-defined script. +/// +/// The host runs the matching script (see [`MiniAppLifecycleScripts`]) at each +/// transition: +/// - `Install`: after the app's files are committed to disk for the first time. +/// - `Uninstall`: before the app directory is removed. +/// - `Start`: when the app is activated / its worker is brought up. +/// - `Stop`: when the app is deactivated / its worker is torn down. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MiniAppLifecycleEvent { + Install, + Uninstall, + Start, + Stop, +} + +impl MiniAppLifecycleEvent { + /// Stable wire identifier shared with the frontend and Tauri layer. + pub fn as_str(&self) -> &'static str { + match self { + MiniAppLifecycleEvent::Install => "install", + MiniAppLifecycleEvent::Uninstall => "uninstall", + MiniAppLifecycleEvent::Start => "start", + MiniAppLifecycleEvent::Stop => "stop", + } + } + + /// Parse a wire identifier. + pub fn from_wire(value: &str) -> Option { + match value { + "install" => Some(MiniAppLifecycleEvent::Install), + "uninstall" => Some(MiniAppLifecycleEvent::Uninstall), + "start" => Some(MiniAppLifecycleEvent::Start), + "stop" => Some(MiniAppLifecycleEvent::Stop), + _ => None, + } + } + + /// All lifecycle events, in canonical order. + pub fn all() -> [MiniAppLifecycleEvent; 4] { + [ + MiniAppLifecycleEvent::Install, + MiniAppLifecycleEvent::Uninstall, + MiniAppLifecycleEvent::Start, + MiniAppLifecycleEvent::Stop, + ] + } +} + +/// User-declared scripts run at MiniApp lifecycle transitions. +/// +/// Each value is a path relative to the app root (for example `hooks/install.js` +/// or `worker.js`). The host resolves it against the app directory, rejecting any +/// path that escapes the app root, and executes it with the detected JS runtime +/// (Bun/Node). Absent entries mean "no script for this event". +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MiniAppLifecycleScripts { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub install: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uninstall: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop: Option, +} + +impl MiniAppLifecycleScripts { + /// The declared script for `event`, if any (trimmed, non-empty). + pub fn script_for(&self, event: MiniAppLifecycleEvent) -> Option<&str> { + let raw = match event { + MiniAppLifecycleEvent::Install => self.install.as_deref(), + MiniAppLifecycleEvent::Uninstall => self.uninstall.as_deref(), + MiniAppLifecycleEvent::Start => self.start.as_deref(), + MiniAppLifecycleEvent::Stop => self.stop.as_deref(), + }; + raw.map(str::trim).filter(|value| !value.is_empty()) + } + + /// Whether no lifecycle script is declared. Used by `skip_serializing_if` so + /// apps without hooks keep a clean `meta.json`. + pub fn is_empty(&self) -> bool { + MiniAppLifecycleEvent::all() + .iter() + .all(|event| self.script_for(*event).is_none()) + } +} + /// Full MiniApp entity (in-memory / API). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MiniApp { @@ -252,6 +379,15 @@ pub struct MiniApp { #[serde(default)] pub runtime_profile: MiniAppRuntimeProfile, + /// How the app is presented in the host shell (background panel / front tab / + /// full window). Defaults to `Front`. + #[serde(default)] + pub view_mode: MiniAppViewMode, + + /// User-declared lifecycle scripts (install / uninstall / start / stop). + #[serde(default, skip_serializing_if = "MiniAppLifecycleScripts::is_empty")] + pub lifecycle: MiniAppLifecycleScripts, + /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, @@ -278,6 +414,12 @@ pub struct MiniAppMeta { pub runtime: MiniAppRuntimeState, #[serde(default)] pub runtime_profile: MiniAppRuntimeProfile, + /// How the app is presented in the host shell. Defaults to `Front`. + #[serde(default)] + pub view_mode: MiniAppViewMode, + /// User-declared lifecycle scripts (install / uninstall / start / stop). + #[serde(default, skip_serializing_if = "MiniAppLifecycleScripts::is_empty")] + pub lifecycle: MiniAppLifecycleScripts, /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, @@ -299,6 +441,8 @@ impl From<&MiniApp> for MiniAppMeta { ai_context: app.ai_context.clone(), runtime: app.runtime.clone(), runtime_profile: app.runtime_profile, + view_mode: app.view_mode, + lifecycle: app.lifecycle.clone(), i18n: app.i18n.clone(), } } diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index d60fd0feeb..fd9251010f 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -2308,6 +2308,8 @@ fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { ai_context: None, runtime: MiniAppRuntimeState::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), i18n: None, } } diff --git a/src/crates/services/services-integrations/src/miniapp/storage.rs b/src/crates/services/services-integrations/src/miniapp/storage.rs index c0758f6058..73688c9aa9 100644 --- a/src/crates/services/services-integrations/src/miniapp/storage.rs +++ b/src/crates/services/services-integrations/src/miniapp/storage.rs @@ -363,6 +363,8 @@ impl MiniAppStorage { ai_context: meta.ai_context, runtime: meta.runtime, runtime_profile: meta.runtime_profile, + view_mode: meta.view_mode, + lifecycle: meta.lifecycle, i18n: meta.i18n, }) } @@ -747,6 +749,8 @@ impl MiniAppStorage { ai_context: meta.ai_context, runtime: meta.runtime, runtime_profile: meta.runtime_profile, + view_mode: meta.view_mode, + lifecycle: meta.lifecycle, i18n: meta.i18n, }) } @@ -1846,6 +1850,8 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), i18n: None, } } diff --git a/src/crates/services/services-integrations/src/miniapp_market/package.rs b/src/crates/services/services-integrations/src/miniapp_market/package.rs index 5d169aa8c6..f037e19682 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/package.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/package.rs @@ -431,6 +431,8 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), i18n: None, }; From 4c61a2b4f12ef66dc9a3724857bd1d8d17c40202 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 08:04:02 +0000 Subject: [PATCH 3/5] feat(miniapp): add services-layer lifecycle-script runner Add run_lifecycle_script in services-integrations that executes a resolved lifecycle script (Bun/Node) as a one-shot child process via the shared non-interactive process facade, capturing stdout/stderr/exit code with a timeout. LifecycleScriptOutcome::from_output keeps result mapping pure and unit-tested; a runtime-gated test exercises a real script run. Path containment and event selection remain pure decisions in bitfun-product-domains; this module only performs concrete execution. Co-authored-by: erow --- .../src/miniapp/lifecycle_runner.rs | 146 ++++++++++++++++++ .../services-integrations/src/miniapp/mod.rs | 1 + 2 files changed, 147 insertions(+) create mode 100644 src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs diff --git a/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs b/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs new file mode 100644 index 0000000000..6ac8fb5149 --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs @@ -0,0 +1,146 @@ +//! MiniApp lifecycle-script execution. +//! +//! Runs a resolved lifecycle script (see +//! [`bitfun_product_domains::miniapp::lifecycle::plan_lifecycle_script`]) as a +//! one-shot child process using the detected JS runtime (Bun/Node), through the +//! shared non-interactive process facade so no console window flashes on Windows +//! and GUI/headless hosts behave identically. +//! +//! Path containment and event selection are pure decisions owned by +//! `bitfun-product-domains`; this module only performs the concrete process +//! execution and result capture. + +use bitfun_product_domains::miniapp::runtime::DetectedRuntime; +use std::path::Path; +use std::process::Output; +use std::time::Duration; + +/// Default per-script timeout when the caller does not specify one. +pub const DEFAULT_LIFECYCLE_TIMEOUT_MS: u64 = 30_000; + +/// Result of running a lifecycle script. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LifecycleScriptOutcome { + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, +} + +impl LifecycleScriptOutcome { + /// Build an outcome from a finished process `Output`. Kept pure and separate + /// from spawning so the success/exit-code/stream mapping is unit-testable. + pub fn from_output(output: &Output) -> Self { + Self { + succeeded: output.status.success(), + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } + } +} + +/// Run `script_path` with `runtime`, using `app_dir` as the working directory. +/// +/// The script path must already be validated as contained within the app +/// directory by the caller. Returns an error only when the process cannot be +/// spawned/awaited or exceeds `timeout_ms`; a script that runs but exits +/// non-zero returns `Ok` with `succeeded == false` so the caller can decide how +/// to react per event. +pub async fn run_lifecycle_script( + runtime: &DetectedRuntime, + script_path: &Path, + app_dir: &Path, + timeout_ms: u64, +) -> Result { + let exe = runtime.path.to_string_lossy(); + let script = script_path.to_string_lossy(); + + let mut command = bitfun_services_core::process_manager::create_tokio_command(&*exe); + command + .arg(&*script) + .current_dir(app_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + let effective_timeout = if timeout_ms == 0 { + DEFAULT_LIFECYCLE_TIMEOUT_MS + } else { + timeout_ms + }; + + let run = command.output(); + match tokio::time::timeout(Duration::from_millis(effective_timeout), run).await { + Ok(Ok(output)) => Ok(LifecycleScriptOutcome::from_output(&output)), + Ok(Err(error)) => Err(format!("Failed to run lifecycle script: {error}")), + Err(_) => Err(format!( + "Lifecycle script timed out after {effective_timeout}ms" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::miniapp::runtime::{detect_runtime, RuntimeKind}; + + #[cfg(unix)] + fn output_from(code: i32, stdout: &str, stderr: &str) -> Output { + // Constructing an ExitStatus directly is only supported on Unix; the + // stream/exit-code mapping under test is platform-independent. + use std::os::unix::process::ExitStatusExt; + Output { + status: std::process::ExitStatus::from_raw((code & 0xff) << 8), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + } + } + + #[cfg(unix)] + #[test] + fn from_output_maps_success_and_streams() { + let ok = LifecycleScriptOutcome::from_output(&output_from(0, "hello", "")); + assert!(ok.succeeded); + assert_eq!(ok.exit_code, Some(0)); + assert_eq!(ok.stdout, "hello"); + assert_eq!(ok.stderr, ""); + + let failed = LifecycleScriptOutcome::from_output(&output_from(3, "", "boom")); + assert!(!failed.succeeded); + assert_eq!(failed.exit_code, Some(3)); + assert_eq!(failed.stderr, "boom"); + } + + #[tokio::test] + async fn runs_a_real_script_when_a_js_runtime_is_available() { + // Hermetic-friendly: skip when no Bun/Node is present (e.g. minimal CI). + let Some(runtime) = detect_runtime() else { + eprintln!("no JS runtime detected; skipping live lifecycle-script run"); + return; + }; + + let dir = std::env::temp_dir().join(format!("miniapp-lifecycle-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("hook.js"); + // Print to stdout, then exit non-zero to prove exit-code capture. + std::fs::write( + &script, + "console.log('lifecycle-ok'); process.exit(2);\n", + ) + .unwrap(); + + let outcome = run_lifecycle_script(&runtime, &script, &dir, 10_000) + .await + .expect("script should run"); + + assert!(outcome.stdout.contains("lifecycle-ok")); + assert!(!outcome.succeeded); + // Bun and Node both honor process.exit(2). + assert_eq!(outcome.exit_code, Some(2)); + assert!(matches!(runtime.kind, RuntimeKind::Bun | RuntimeKind::Node)); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/mod.rs b/src/crates/services/services-integrations/src/miniapp/mod.rs index 0b72d5f3ce..1c2cad0dea 100644 --- a/src/crates/services/services-integrations/src/miniapp/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp/mod.rs @@ -2,6 +2,7 @@ pub mod builtin_io; pub mod host_dispatch; +pub mod lifecycle_runner; pub mod storage; pub mod worker; pub mod worker_pool; From d1e951f4a584026953d658f9e8db854ea069cdb5 Mon Sep 17 00:00:00 2001 From: erow Date: Tue, 25 Aug 2026 21:03:15 +0800 Subject: [PATCH 4/5] feat(miniapp): complete lifecycle, view modes, and named scripts (#3) * feat(miniapp): dispatch lifecycle events and view-mode/lifecycle updates in manager - MiniAppManager::run_lifecycle_event resolves the declared script (traversal- guarded), detects Bun/Node, and runs it via the services runner, injecting app id/dir/event/policy as env; returns a MiniAppLifecycleReport (incl. failures) - MiniAppManager::set_view_mode and set_lifecycle_scripts persist those app properties without recompiling the document - lifecycle_runner gains env injection for trusted script context Adds core manager tests (view mode, no-script, real script run, traversal reject). Co-authored-by: erow * feat(miniapp): desktop commands for view mode, lifecycle, and full window - miniapp_set_view_mode / miniapp_set_lifecycle_scripts / miniapp_run_lifecycle_event - open_miniapp_full_window opens an independent OS window hosting the app (?bitfunWindow=miniapp&miniAppId=...), mirroring the agent-companion window - automatic lifecycle dispatch: install on create/import, uninstall on delete (before removal), stop on worker stop; each emits a miniapp-lifecycle event - re-export MiniAppLifecycleReport; register new commands in invoke_handler - spec: correct execution semantics to best-effort and update status checklist Desktop crate compiles and the running tauri dev app restarted cleanly. Co-authored-by: erow * feat(miniapp): web UI view-mode routing, lifecycle triggers, and full window - MiniAppAPI: setViewMode / setLifecycleScripts / runLifecycleEvent / openFullWindow and view_mode / lifecycle types on MiniAppMeta - gallery open branches on view_mode: full opens an independent OS window, front/background open a scene tab; fires the start lifecycle event on open - main.tsx renders a standalone MiniApp window for ?bitfunWindow=miniapp (reusing MiniAppScene) so full mode has real content Type-check and lint pass. Co-authored-by: erow * feat(miniapp): add view-mode selector to the gallery detail modal Users can switch an app between background / tab (front) / window (full) from the app detail modal; calls miniapp_set_view_mode and refreshes the store. Adds i18n keys (detail.viewMode.*) for en-US / zh-CN / zh-TW. type-check, i18n:audit, and lint pass. Co-authored-by: erow * feat(miniapp): background collapsed-panel dock for background view mode - MiniAppBackgroundDock: resident, collapsible bottom-right panel hosting background view-mode apps (multi-app tabs, per-app close stops worker) - store: backgroundAppIds + openBackground/closeBackground, pruned in setApps - gallery: background mode routes to the dock (not a tab); dock mounted in AppLayout - i18n dock.* keys for en-US/zh-CN/zh-TW Completes the three view modes end to end. type-check, i18n:audit, theme audit, lint pass. Co-authored-by: erow * fix(miniapp): dismiss startup overlay in standalone full-window render The full-mode MiniApp window renders MiniAppScene directly and returns before the main startup pipeline, so the static startup overlay never hid and covered the app. Call hideStartupOverlay() explicitly in that branch. Co-authored-by: erow * fix(miniapp): carry hooks/ directory on import so lifecycle scripts exist Import previously copied only meta.json + source/, so a declared lifecycle script under hooks/ was missing after install and the dispatch reported 'script not found'. write_import_bundle now recursively copies the optional hooks/ directory into the app dir. Extends the import-bundle IO test. Co-authored-by: erow * docs(miniapp): record verification results and market-hooks follow-up Co-authored-by: erow * feat(miniapp): add named-scripts contract to extend app capabilities - MiniAppScriptDef { name, path, description } + scripts field on MiniApp/MiniAppMeta (additive, defaulted, content-hashed, upgrade-safe) - find_script_path + plan_named_script (traversal-guarded) resolver - MiniAppUpdatePatch.scripts + apply + draft copy; miniapp_script_event_payload - update all construction sites; contract + unit tests Co-authored-by: erow * feat(miniapp): run named scripts in manager + carry scripts/ on import - run_miniapp_script (args-aware) generalizes the script runner; lifecycle delegates to it - MiniAppManager::run_named_script (traversal-guarded, runtime-detected, env BITFUN_MINIAPP_SCRIPT) and set_scripts; MiniAppScriptRunReport - SCRIPTS_DIR constant; import copies both hooks/ and scripts/ - manager test runs a real named script with args Co-authored-by: erow * feat(miniapp): desktop + web UI for named scripts - Tauri commands miniapp_set_scripts and miniapp_run_script (emits miniapp-script) - MiniAppAPI setScripts/runScript + MiniAppScriptDef/ScriptRunResult types + scripts on meta - gallery detail modal lists declared scripts with a Run button; result via notification - i18n detail.scripts.* for en-US/zh-CN/zh-TW type-check, i18n:audit, lint pass. Co-authored-by: erow * docs(miniapp): document named-scripts capability and market security boundary Co-authored-by: erow * fix(miniapp): register remote policy and peer-local window command Declare LocalOnly remote-workspace policies for the new MiniApp view-mode, lifecycle, named-script, and full-window Tauri commands so the policy contract test stays green. Keep open_miniapp_full_window on the controller in Peer Device Mode (window chrome) across desktop, CLI, and web deny lists. Co-authored-by: erow --------- Co-authored-by: Cursor Agent --- docs/features/mini-app-system.md | 112 +++- src/apps/cli/src/peer_host/deny.rs | 1 + src/apps/desktop/src/api/miniapp_api.rs | 230 +++++++++ src/apps/desktop/src/api/peer_host_invoke.rs | 1 + .../src/api/remote_workspace_policy.rs | 12 + src/apps/desktop/src/appearance.rs | 45 ++ src/apps/desktop/src/lib.rs | 6 + .../implementations/miniapp_publish_tool.rs | 1 + .../assembly/core/src/miniapp/manager.rs | 481 +++++++++++++++++- src/crates/assembly/core/src/miniapp/mod.rs | 3 +- .../product-domains/src/miniapp/lifecycle.rs | 125 ++++- .../src/miniapp/runtime_facade.rs | 2 + .../product-domains/src/miniapp/storage.rs | 5 + .../product-domains/src/miniapp/types.rs | 48 ++ .../tests/miniapp_contracts.rs | 1 + .../src/miniapp/lifecycle_runner.rs | 31 +- .../src/miniapp/storage.rs | 67 ++- .../src/miniapp_market/package.rs | 1 + src/web-ui/src/app/layout/AppLayout.tsx | 8 + .../components/MiniAppBackgroundDock.scss | 128 +++++ .../components/MiniAppBackgroundDock.tsx | 193 +++++++ .../src/app/scenes/miniapps/miniAppStore.ts | 18 + .../miniapps/views/MiniAppGalleryView.tsx | 165 +++++- .../api/adapters/peer-device-adapter.ts | 1 + .../api/service-api/MiniAppAPI.ts | 109 ++++ .../src/locales/en-US/scenes/miniapp.json | 21 +- .../src/locales/zh-CN/scenes/miniapp.json | 21 +- .../src/locales/zh-TW/scenes/miniapp.json | 21 +- src/web-ui/src/main.tsx | 33 +- 29 files changed, 1845 insertions(+), 45 deletions(-) create mode 100644 src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.scss create mode 100644 src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.tsx diff --git a/docs/features/mini-app-system.md b/docs/features/mini-app-system.md index db196c7f62..7426971947 100644 --- a/docs/features/mini-app-system.md +++ b/docs/features/mini-app-system.md @@ -81,6 +81,8 @@ The canonical layout (owned by `MiniAppStorageLayout` in │ ├── uninstall.js │ ├── start.js │ └── stop.js +├── scripts/ # (optional) named capability scripts — see §2b +│ └── .js └── versions/ └── v{N}.json # Full snapshots for rollback ``` @@ -98,7 +100,9 @@ atomically so a failed install never leaves a half-written app. The `hooks/` directory is a **recommended convention** (constant `HOOKS_DIR = "hooks"`), not a hard requirement: a lifecycle script path is any -path relative to the app root (see §2.2). +path relative to the app root (see §2.2). When an app is imported from a folder, +the whole `hooks/` directory is copied into the installed app dir so declared +lifecycle scripts exist at runtime. --- @@ -170,15 +174,50 @@ layer never touches the filesystem. identical to the worker, so a lifecycle script cannot exceed what the app is already granted. - Each run emits a `miniapp-lifecycle` event - (`miniapp_lifecycle_event_payload`: `{ id, event, script, succeeded }`) for - the UI / telemetry. -- Failures are surfaced, not silently swallowed. `install` failure aborts the - install and rolls back atomically; `uninstall` failure is reported but does - not block directory removal (an app must always be removable). `start` / - `stop` failures are reported and do not wedge the worker lifecycle. + (`{ id, event, script, succeeded, exitCode, error }`) for the UI / telemetry, + and the desktop command returns the outcome to the caller. +- Lifecycle scripts are **best-effort**: a failing script (non-zero exit, + missing file, traversal attempt, or no runtime) is surfaced via the event, + the command result, and logs, but it does **not** abort or roll back the + surrounding flow. `install` runs after the app is committed, `uninstall` runs + before removal (an app must always be removable), and `stop` runs as part of + worker teardown. This mirrors how npm lifecycle scripts behave and keeps the + app store consistent even when a hook misbehaves; authors that need hard + guarantees should assert inside the script and react to the reported failure. --- +## 2b. Named scripts (capability extension) + +Beyond the four fixed lifecycle hooks, an app may ship **named scripts** to +extend its capabilities — arbitrary commands the author bundles (recommended +under `scripts/`) and invokes on demand. + +Declared in `meta.json` under `scripts` (`Vec`): + +```json +{ + "scripts": [ + { "name": "build", "path": "scripts/build.js", "description": "Rebuild output" }, + { "name": "sync", "path": "scripts/sync.js" } + ] +} +``` + +- `name` is the stable invocation id; `path` is resolved against the app root + with the same traversal guard as lifecycle scripts (`find_script_path` + + `plan_named_script`), and run with the detected JS runtime. +- Invocation: `MiniAppManager::run_named_script(app_id, name, args)` → + desktop command `miniapp_run_script` (emits a `miniapp-script` event with the + outcome). The gallery detail modal lists declared scripts with a Run button; + `MiniAppAPI.runScript` / `setScripts` back it. +- Execution semantics match §2.4: trusted host code in the app dir, with + `BITFUN_MINIAPP_{ID,DIR,SCRIPT,POLICY}` env and forwarded CLI `args`, + captured stdout/stderr/exit-code, best-effort (failures surfaced, never + auto-rollback). +- Named scripts are part of the content hash and carried on import (the + `scripts/` directory travels with the app). + ## 3. View modes ### 3.1 Modes @@ -243,13 +282,56 @@ The specification is delivered incrementally. Current state: `MiniAppLifecycleScripts`, `view_mode` / `lifecycle` fields, `HOOKS_DIR`, safe path resolver, `plan_lifecycle_script`, and event payload — with unit and contract tests in `bitfun-product-domains`. -- [ ] Services: execute lifecycle scripts on install/uninstall/start/stop via the - process facade, with permission policy and event emission. -- [ ] Assembly: manager dispatch of lifecycle events and view-mode updates wired - to `PathManager`. -- [ ] Desktop: Tauri commands for setting view mode and for independent (`full`) - windows. -- [ ] Web UI: render `background` panel, `front` tab, and `full` window; expose - lifecycle status. +- [x] Services: `run_lifecycle_script` runs a resolved script (Bun/Node) via the + non-interactive process facade, injecting app/event/policy env and + capturing stdout/stderr/exit code, in `bitfun-services-integrations`. +- [x] Assembly: `MiniAppManager::run_lifecycle_event` (traversal-guarded resolve + + runtime detect + run + report), `set_view_mode`, and + `set_lifecycle_scripts`, wired to `PathManager` and the permission policy, + with tests. +- [x] Desktop: Tauri commands `miniapp_set_view_mode`, + `miniapp_set_lifecycle_scripts`, `miniapp_run_lifecycle_event`, and + `open_miniapp_full_window`; automatic `install` (create/import), + `uninstall` (delete), and `stop` (worker stop) dispatch with + `miniapp-lifecycle` events. +- [x] Web UI: `MiniAppAPI` gains `setViewMode` / `setLifecycleScripts` / + `runLifecycleEvent` / `openFullWindow` and `view_mode` / `lifecycle` types; + opening an app branches on view mode — `full` opens an independent OS + window via the `?bitfunWindow=miniapp` standalone render, `background` + stays resident in the collapsed `MiniAppBackgroundDock` panel, `front` + opens a tab — and fires the `start` lifecycle event on activation. The + gallery detail modal exposes a view-mode selector (background / tab / + window). + +### Verified + +- Contracts/services/assembly: `cargo test` for `bitfun-product-domains` + (58 lib + 39 contract), `bitfun-services-integrations` miniapp runtime + import + IO, and `bitfun-core` `miniapp::manager` (incl. a real hook-script run). +- View modes: GUI-verified live — `background` dock, `front` tab, and `full` + window all open, and the full window renders real app content. +- Lifecycle: end-to-end verified — importing an app with an `install` hook runs + the script during the install event (marker file written with `BITFUN_MINIAPP_*` + env context), with `hooks/` carried into the installed app dir. + +- [x] Named scripts: `MiniAppScriptDef` + `scripts` manifest field, + `find_script_path` / `plan_named_script`, args-aware `run_miniapp_script`, + `MiniAppManager::run_named_script` / `set_scripts`, desktop + `miniapp_run_script` / `miniapp_set_scripts`, `MiniAppAPI` + gallery Run + UI, `scripts/` carried on import — with contract + manager tests. + +### Design boundary: scripts and the market + +Lifecycle hooks and named scripts run as **trusted, host-privileged** code +(outside the iframe sandbox). They are therefore supported for **user-created +and folder-imported** apps, where the user is the author/installer of that code. + +Market-distributed packages intentionally do **not** carry `hooks/` or +`scripts/`: the market ZIP is a strict, separately-validated whitelist +(`meta.json` + `source/*`), and allowing a downloaded package to ship +host-privileged scripts that auto-run on install would be a security escalation. +Bringing scripts to market apps is a future item that requires an explicit +review/consent model (and matching client + server validator changes), not a +simple whitelist widening. Each subsequent change keeps this table current. diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 16701bec1c..5472c626ae 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -16,6 +16,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "startup_window_control", "toggle_main_window_fullscreen", "set_main_window_transient_geometry", + "open_miniapp_full_window", "get_prevent_sleep_enabled", "set_prevent_sleep_enabled", "restart_app", diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 9bf92d8794..178845eba4 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -16,6 +16,9 @@ use bitfun_core::miniapp::lifecycle::{ workspace_root_from_input, }; use bitfun_core::miniapp::rate_limit::{MiniAppRateLimitState, MiniAppRateLimitSubject}; +use bitfun_core::miniapp::types::{ + MiniAppLifecycleEvent, MiniAppLifecycleScripts, MiniAppScriptDef, MiniAppViewMode, +}; use bitfun_core::miniapp::{ dispatch_host, is_host_primitive, InstallResult as CoreInstallResult, MiniApp, MiniAppAiContext, MiniAppCustomizationMetadata, MiniAppDraft, MiniAppMeta, @@ -280,6 +283,47 @@ async fn emit_miniapp_event(event_name: &str, payload: Value) { .await; } +/// Run a MiniApp lifecycle event's script (if declared) and emit a +/// `miniapp-lifecycle` event describing the outcome. Failures are surfaced to +/// the UI and logged; they are best-effort and never abort the surrounding +/// install/uninstall/stop flow (see docs/features/mini-app-system.md). +async fn run_and_emit_lifecycle( + state: &State<'_, AppState>, + app_id: &str, + event: MiniAppLifecycleEvent, +) -> Option { + match state + .miniapp_manager + .run_lifecycle_event(app_id, event) + .await + { + Ok(Some(report)) => { + if !report.succeeded { + log::warn!( + "MiniApp lifecycle '{}' script failed for {}: exit={:?} error={:?} stderr={}", + event.as_str(), + app_id, + report.exit_code, + report.error, + report.stderr.trim() + ); + } + emit_miniapp_event("miniapp-lifecycle", report.to_event_payload(app_id)).await; + Some(report) + } + Ok(None) => None, + Err(error) => { + log::warn!( + "MiniApp lifecycle '{}' dispatch error for {}: {}", + event.as_str(), + app_id, + error + ); + None + } + } +} + async fn maybe_stop_worker(state: &State<'_, AppState>, app: &MiniApp) { if should_stop_worker_for_runtime_update(app) { if let Some(ref pool) = state.js_worker_pool { @@ -409,6 +453,7 @@ pub async fn create_miniapp( miniapp_runtime_event_payload(&app, "create"), ) .await; + run_and_emit_lifecycle(&state, &app.id, MiniAppLifecycleEvent::Install).await; Ok(app) } @@ -446,6 +491,9 @@ pub async fn update_miniapp( #[tauri::command] pub async fn delete_miniapp(state: State<'_, AppState>, app_id: String) -> Result<(), String> { + // Run the uninstall hook while the app directory (and its script) still + // exist; failures are surfaced but do not block removal. + run_and_emit_lifecycle(&state, &app_id, MiniAppLifecycleEvent::Uninstall).await; if let Some(ref pool) = state.js_worker_pool { pool.stop(app_id.as_str()).await; } @@ -692,6 +740,7 @@ pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> if let Some(ref pool) = state.js_worker_pool { pool.stop(&app_id).await; } + run_and_emit_lifecycle(&state, &app_id, MiniAppLifecycleEvent::Stop).await; emit_miniapp_event( "miniapp-worker-stopped", miniapp_worker_stopped_payload(&app_id, "manual-stop"), @@ -806,9 +855,190 @@ pub async fn miniapp_import_from_path( miniapp_runtime_event_payload(&app, "import"), ) .await; + run_and_emit_lifecycle(&state, &app.id, MiniAppLifecycleEvent::Install).await; + Ok(app) +} + +/// Set a MiniApp's persisted view mode (`background` / `front` / `full`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetViewModeRequest { + pub app_id: String, + pub view_mode: String, +} + +#[tauri::command] +pub async fn miniapp_set_view_mode( + state: State<'_, AppState>, + request: SetViewModeRequest, +) -> Result { + let mode = MiniAppViewMode::from_wire(&request.view_mode); + let app = state + .miniapp_manager + .set_view_mode(&request.app_id, mode) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "view-mode"), + ) + .await; + Ok(app) +} + +/// Replace a MiniApp's lifecycle scripts (install/uninstall/start/stop). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetLifecycleScriptsRequest { + pub app_id: String, + #[serde(default)] + pub lifecycle: MiniAppLifecycleScripts, +} + +#[tauri::command] +pub async fn miniapp_set_lifecycle_scripts( + state: State<'_, AppState>, + request: SetLifecycleScriptsRequest, +) -> Result { + let app = state + .miniapp_manager + .set_lifecycle_scripts(&request.app_id, request.lifecycle) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "lifecycle"), + ) + .await; + Ok(app) +} + +/// Explicitly trigger a lifecycle event (used by the UI for start/stop on +/// activation/deactivation; install/uninstall also fire automatically). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunLifecycleEventRequest { + pub app_id: String, + pub event: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleRunResult { + pub ran: bool, + pub succeeded: bool, + pub exit_code: Option, + pub error: Option, +} + +#[tauri::command] +pub async fn miniapp_run_lifecycle_event( + state: State<'_, AppState>, + request: RunLifecycleEventRequest, +) -> Result { + let Some(event) = MiniAppLifecycleEvent::from_wire(&request.event) else { + return Err(format!("Unknown lifecycle event: {}", request.event)); + }; + match run_and_emit_lifecycle(&state, &request.app_id, event).await { + Some(report) => Ok(LifecycleRunResult { + ran: true, + succeeded: report.succeeded, + exit_code: report.exit_code, + error: report.error, + }), + None => Ok(LifecycleRunResult { + ran: false, + succeeded: true, + exit_code: None, + error: None, + }), + } +} + +/// Replace a MiniApp's named scripts (`scripts` in the manifest). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetScriptsRequest { + pub app_id: String, + #[serde(default)] + pub scripts: Vec, +} + +#[tauri::command] +pub async fn miniapp_set_scripts( + state: State<'_, AppState>, + request: SetScriptsRequest, +) -> Result { + let app = state + .miniapp_manager + .set_scripts(&request.app_id, request.scripts) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "scripts"), + ) + .await; Ok(app) } +/// Run a named script the app declared, forwarding optional args. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunScriptRequest { + pub app_id: String, + pub script: String, + #[serde(default)] + pub args: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptRunResult { + pub ran: bool, + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub error: Option, +} + +#[tauri::command] +pub async fn miniapp_run_script( + state: State<'_, AppState>, + request: RunScriptRequest, +) -> Result { + let report = state + .miniapp_manager + .run_named_script(&request.app_id, &request.script, request.args) + .await + .map_err(|e| e.to_string())?; + match report { + Some(report) => { + if !report.succeeded { + log::warn!( + "MiniApp script '{}' failed for {}: exit={:?} error={:?} stderr={}", + report.name, + request.app_id, + report.exit_code, + report.error, + report.stderr.trim() + ); + } + emit_miniapp_event("miniapp-script", report.to_event_payload(&request.app_id)).await; + Ok(ScriptRunResult { + ran: true, + succeeded: report.succeeded, + exit_code: report.exit_code, + stdout: report.stdout, + stderr: report.stderr, + error: report.error, + }) + } + None => Err(format!("MiniApp has no script named '{}'", request.script)), + } +} + #[tauri::command] pub async fn miniapp_sync_from_fs( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index fcc2800946..419fd6bb21 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -38,6 +38,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "startup_window_control", "toggle_main_window_fullscreen", "set_main_window_transient_geometry", + "open_miniapp_full_window", "get_prevent_sleep_enabled", "set_prevent_sleep_enabled", "restart_app", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index b724fda7e5..2ab9abd3cc 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1274,6 +1274,11 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "miniapp_render_slide_page", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "miniapp_run_lifecycle_event", + RemoteWorkspacePolicy::LocalOnly, + ), + ("miniapp_run_script", RemoteWorkspacePolicy::LocalOnly), ( "miniapp_runtime_status", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1282,6 +1287,12 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "miniapp_set_draft_permissions", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "miniapp_set_lifecycle_scripts", + RemoteWorkspacePolicy::LocalOnly, + ), + ("miniapp_set_scripts", RemoteWorkspacePolicy::LocalOnly), + ("miniapp_set_view_mode", RemoteWorkspacePolicy::LocalOnly), ( "miniapp_sync_draft_from_fs", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1315,6 +1326,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "open_html_file_in_browser", RemoteWorkspacePolicy::LocalOnly, ), + ("open_miniapp_full_window", RemoteWorkspacePolicy::LocalOnly), ("open_remote_workspace", RemoteWorkspacePolicy::RemoteRouted), ("open_workspace", RemoteWorkspacePolicy::LegacyUnaudited), ( diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index a9f7130e99..9794cd382c 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -977,6 +977,51 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( Ok(()) } +/// Open (or focus) an independent OS window hosting a MiniApp in `full` view +/// mode. The window loads the shared Web UI with the `miniapp` window role so it +/// renders the same runner/bridge as the in-tab host. +#[tauri::command] +pub async fn open_miniapp_full_window( + app: tauri::AppHandle, + app_id: String, + title: Option, +) -> Result<(), String> { + // MiniApp ids are UUIDs or `builtin-*`; both are valid Tauri window labels. + if app_id.is_empty() + || !app_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(format!("Invalid MiniApp id for window label: {app_id}")); + } + let label = format!("miniapp-{app_id}"); + + if let Some(window) = app.get_webview_window(&label) { + let _ = window.unminimize(); + window + .show() + .map_err(|e| format!("Failed to show MiniApp window: {e}"))?; + window + .set_focus() + .map_err(|e| format!("Failed to focus MiniApp window: {e}"))?; + return Ok(()); + } + + let url = app_url(&format!("?bitfunWindow=miniapp&miniAppId={app_id}")); + let window_title = title.unwrap_or_else(|| "BitFun Mini App".to_string()); + let builder = tauri::WebviewWindowBuilder::new(&app, &label, url) + .title(window_title) + .inner_size(1024.0, 720.0) + .min_inner_size(480.0, 360.0) + .resizable(true) + .visible(true); + + builder + .build() + .map_err(|e| format!("Failed to create MiniApp window: {e}"))?; + Ok(()) +} + #[tauri::command] pub async fn resize_agent_companion_desktop_pet( app: tauri::AppHandle, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 8b3a70737a..ff4265b2ab 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1884,6 +1884,12 @@ pub async fn run() { api::miniapp_api::miniapp_runtime_status, api::miniapp_api::miniapp_worker_call, api::miniapp_api::miniapp_host_call, + api::miniapp_api::miniapp_set_view_mode, + api::miniapp_api::miniapp_set_lifecycle_scripts, + api::miniapp_api::miniapp_run_lifecycle_event, + api::miniapp_api::miniapp_set_scripts, + api::miniapp_api::miniapp_run_script, + appearance::open_miniapp_full_window, api::canvas_api::load_canvas_artifact, api::canvas_api::load_canvas_state, api::canvas_api::report_canvas_runtime_error, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs index bdc968d055..32a8269d1a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -568,6 +568,7 @@ mod tests { runtime_profile: Default::default(), view_mode: Default::default(), lifecycle: Default::default(), + scripts: Default::default(), i18n: locale_name.map(|value| MiniAppI18n { locales: HashMap::from([( "en-US".to_string(), diff --git a/src/crates/assembly/core/src/miniapp/manager.rs b/src/crates/assembly/core/src/miniapp/manager.rs index 70127459b8..c9da4d13af 100644 --- a/src/crates/assembly/core/src/miniapp/manager.rs +++ b/src/crates/assembly/core/src/miniapp/manager.rs @@ -12,6 +12,14 @@ use crate::miniapp::types::{ }; use crate::product_domain_runtime::CoreProductDomainRuntime; use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_product_domains::miniapp::runtime::detect_runtime; +use bitfun_product_domains::miniapp::storage::resolve_contained_relative; +use bitfun_product_domains::miniapp::types::{ + find_script_path, MiniAppLifecycleEvent, MiniAppScriptDef, MiniAppViewMode, +}; +use bitfun_services_integrations::miniapp::lifecycle_runner::{ + run_lifecycle_script, run_miniapp_script, DEFAULT_LIFECYCLE_TIMEOUT_MS, +}; use bitfun_product_domains::miniapp::customization::{ MiniAppCustomizationBaseline, MiniAppCustomizationMetadata, MiniAppPermissionDiff, }; @@ -43,6 +51,88 @@ pub fn try_get_global_miniapp_manager() -> Option> { GLOBAL_MINIAPP_MANAGER.get().cloned() } +/// Result of dispatching a MiniApp lifecycle event (install/uninstall/start/stop). +/// +/// Returned by [`MiniAppManager::run_lifecycle_event`] when a script is declared +/// for the event. `succeeded` is false when the script exits non-zero or could +/// not be run (`error` then carries the reason); callers decide how strictly to +/// treat each event (see the design spec: install aborts, uninstall is +/// best-effort, start/stop are reported). +#[derive(Debug, Clone)] +pub struct MiniAppLifecycleReport { + pub event: MiniAppLifecycleEvent, + pub relative_path: String, + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub error: Option, +} + +impl MiniAppLifecycleReport { + fn failed(event: MiniAppLifecycleEvent, relative_path: String, error: String) -> Self { + Self { + event, + relative_path, + succeeded: false, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(error), + } + } + + /// JSON payload for the `miniapp-lifecycle` frontend/telemetry event. + pub fn to_event_payload(&self, app_id: &str) -> serde_json::Value { + serde_json::json!({ + "id": app_id, + "event": self.event.as_str(), + "script": self.relative_path, + "succeeded": self.succeeded, + "exitCode": self.exit_code, + "error": self.error, + }) + } +} + +/// Result of running a named MiniApp script (`scripts` in the manifest). +#[derive(Debug, Clone)] +pub struct MiniAppScriptRunReport { + pub name: String, + pub relative_path: String, + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub error: Option, +} + +impl MiniAppScriptRunReport { + fn failed(name: String, relative_path: String, error: String) -> Self { + Self { + name, + relative_path, + succeeded: false, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(error), + } + } + + /// JSON payload for the `miniapp-script` frontend/telemetry event. + pub fn to_event_payload(&self, app_id: &str) -> serde_json::Value { + serde_json::json!({ + "id": app_id, + "script": self.name, + "path": self.relative_path, + "succeeded": self.succeeded, + "exitCode": self.exit_code, + "error": self.error, + }) + } +} + /// MiniApp manager: create, read, update, delete, list, compile, rollback. pub struct MiniAppManager { storage: MiniAppStorage, @@ -221,11 +311,12 @@ impl MiniAppManager { source, permissions, ai_context, - // View mode and lifecycle scripts are managed through dedicated - // paths (import/meta + view-mode command); the generic update keeps - // them unchanged. + // View mode, lifecycle hooks, and named scripts are managed through + // dedicated paths (import/meta + view-mode / scripts commands); the + // generic update keeps them unchanged. view_mode: None, lifecycle: None, + scripts: None, }; let now = Utc::now().timestamp_millis(); let compiled_html = if self.uses_market_strict_runtime(app_id).await { @@ -263,6 +354,237 @@ impl MiniAppManager { self.storage.delete(app_id).await } + /// Set the app's persisted view mode (background / front / full). + /// + /// View mode does not affect the compiled document, so the existing compiled + /// HTML is preserved rather than recompiled. + pub async fn set_view_mode( + &self, + app_id: &str, + view_mode: MiniAppViewMode, + ) -> BitFunResult { + let previous_app = self.storage.load(app_id).await?; + let now = Utc::now().timestamp_millis(); + let compiled_html = previous_app.compiled_html.clone(); + let patch = MiniAppUpdatePatch { + view_mode: Some(view_mode), + ..Default::default() + }; + self.runtime_facade() + .persist_update_result_for_app(app_id.to_string(), previous_app, patch, compiled_html, now) + .await + .map_err(map_miniapp_port_error) + } + + /// Replace the app's persisted lifecycle scripts + /// (install/uninstall/start/stop). Like [`set_view_mode`], this does not + /// change the compiled document, so the existing compiled HTML is preserved. + /// + /// [`set_view_mode`]: MiniAppManager::set_view_mode + pub async fn set_lifecycle_scripts( + &self, + app_id: &str, + lifecycle: bitfun_product_domains::miniapp::types::MiniAppLifecycleScripts, + ) -> BitFunResult { + let previous_app = self.storage.load(app_id).await?; + let now = Utc::now().timestamp_millis(); + let compiled_html = previous_app.compiled_html.clone(); + let patch = MiniAppUpdatePatch { + lifecycle: Some(lifecycle), + ..Default::default() + }; + self.runtime_facade() + .persist_update_result_for_app(app_id.to_string(), previous_app, patch, compiled_html, now) + .await + .map_err(map_miniapp_port_error) + } + + /// Replace the app's named scripts (`scripts` in the manifest). Like the + /// other metadata setters this preserves the compiled document. + pub async fn set_scripts( + &self, + app_id: &str, + scripts: Vec, + ) -> BitFunResult { + let previous_app = self.storage.load(app_id).await?; + let now = Utc::now().timestamp_millis(); + let compiled_html = previous_app.compiled_html.clone(); + let patch = MiniAppUpdatePatch { + scripts: Some(scripts), + ..Default::default() + }; + self.runtime_facade() + .persist_update_result_for_app(app_id.to_string(), previous_app, patch, compiled_html, now) + .await + .map_err(map_miniapp_port_error) + } + + /// Run a named script declared by the app, forwarding `args` to the process. + /// + /// Returns `Ok(None)` when the app declares no script with `name`. Named + /// scripts are trusted, author-provided host code (like lifecycle hooks): + /// they run in the app directory with the resolved permission policy exposed + /// via environment variables, not inside the iframe sandbox. + pub async fn run_named_script( + &self, + app_id: &str, + name: &str, + args: Vec, + ) -> BitFunResult> { + let meta = self.storage.load_meta(app_id).await?; + let Some(relative) = find_script_path(&meta.scripts, name) else { + return Ok(None); + }; + let relative = relative.to_string(); + let script_name = name.trim().to_string(); + let app_dir = self.path_manager.miniapp_dir(app_id); + + let Some(script_path) = resolve_contained_relative(&app_dir, &relative) else { + return Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + "Script path escapes the app directory".to_string(), + ))); + }; + if tokio::fs::metadata(&script_path).await.is_err() { + return Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + format!("Script not found: {}", script_path.to_string_lossy()), + ))); + } + let Some(runtime) = detect_runtime() else { + return Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + "No JS runtime (Bun/Node) detected for script".to_string(), + ))); + }; + + let policy = self + .resolve_policy_for_app(app_id, &meta.permissions, None) + .await; + let extra_env = vec![ + ("BITFUN_MINIAPP_ID".to_string(), app_id.to_string()), + ( + "BITFUN_MINIAPP_DIR".to_string(), + app_dir.to_string_lossy().to_string(), + ), + ("BITFUN_MINIAPP_SCRIPT".to_string(), script_name.clone()), + ("BITFUN_MINIAPP_POLICY".to_string(), policy.to_string()), + ]; + let timeout_ms = meta + .permissions + .node + .as_ref() + .and_then(|node| node.timeout_ms) + .unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_MS); + + match run_miniapp_script(&runtime, &script_path, &app_dir, &args, &extra_env, timeout_ms) + .await + { + Ok(outcome) => Ok(Some(MiniAppScriptRunReport { + name: script_name, + relative_path: relative, + succeeded: outcome.succeeded, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + error: None, + })), + Err(error) => Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + error, + ))), + } + } + + /// Dispatch a lifecycle event for an app, running its declared script (if + /// any) with the detected JS runtime. + /// + /// Returns `Ok(None)` when the app declares no script for `event`. When a + /// script is declared, returns a report describing the run (including + /// failures) so the caller can emit a `miniapp-lifecycle` event and decide + /// how strictly to react. Lifecycle scripts are trusted, author-provided + /// host code (analogous to npm lifecycle scripts): they run in the app + /// directory with the resolved permission policy exposed via environment + /// variables, not inside the iframe sandbox. + pub async fn run_lifecycle_event( + &self, + app_id: &str, + event: MiniAppLifecycleEvent, + ) -> BitFunResult> { + let meta = self.storage.load_meta(app_id).await?; + let Some(relative) = meta.lifecycle.script_for(event) else { + return Ok(None); + }; + let relative = relative.to_string(); + let app_dir = self.path_manager.miniapp_dir(app_id); + + let Some(script_path) = resolve_contained_relative(&app_dir, &relative) else { + return Ok(Some(MiniAppLifecycleReport::failed( + event, + relative, + "Lifecycle script path escapes the app directory".to_string(), + ))); + }; + + if tokio::fs::metadata(&script_path).await.is_err() { + return Ok(Some(MiniAppLifecycleReport::failed( + event, + relative, + format!( + "Lifecycle script not found: {}", + script_path.to_string_lossy() + ), + ))); + } + + let Some(runtime) = detect_runtime() else { + return Ok(Some(MiniAppLifecycleReport::failed( + event, + relative, + "No JS runtime (Bun/Node) detected for lifecycle script".to_string(), + ))); + }; + + let policy = self + .resolve_policy_for_app(app_id, &meta.permissions, None) + .await; + let extra_env = vec![ + ("BITFUN_MINIAPP_ID".to_string(), app_id.to_string()), + ( + "BITFUN_MINIAPP_DIR".to_string(), + app_dir.to_string_lossy().to_string(), + ), + ( + "BITFUN_MINIAPP_EVENT".to_string(), + event.as_str().to_string(), + ), + ("BITFUN_MINIAPP_POLICY".to_string(), policy.to_string()), + ]; + let timeout_ms = meta + .permissions + .node + .as_ref() + .and_then(|node| node.timeout_ms) + .unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_MS); + + match run_lifecycle_script(&runtime, &script_path, &app_dir, &extra_env, timeout_ms).await { + Ok(outcome) => Ok(Some(MiniAppLifecycleReport { + event, + relative_path: relative, + succeeded: outcome.succeeded, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + error: None, + })), + Err(error) => Ok(Some(MiniAppLifecycleReport::failed(event, relative, error))), + } + } + /// Get the path manager (for external callers that need paths like miniapp_dir). pub fn path_manager(&self) -> &Arc { &self.path_manager @@ -895,6 +1217,158 @@ mod tests { .unwrap() } + #[tokio::test] + async fn set_view_mode_persists_mode_without_recompiling() { + use bitfun_product_domains::miniapp::types::MiniAppViewMode; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + assert_eq!(app.view_mode, MiniAppViewMode::Front); + let original_html = app.compiled_html.clone(); + + let updated = manager + .set_view_mode(&app.id, MiniAppViewMode::Full) + .await + .unwrap(); + assert_eq!(updated.view_mode, MiniAppViewMode::Full); + assert_eq!(updated.compiled_html, original_html); + + let reloaded = manager.get(&app.id).await.unwrap(); + assert_eq!(reloaded.view_mode, MiniAppViewMode::Full); + } + + #[tokio::test] + async fn run_lifecycle_event_is_none_when_no_script_declared() { + use bitfun_product_domains::miniapp::types::MiniAppLifecycleEvent; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + let report = manager + .run_lifecycle_event(&app.id, MiniAppLifecycleEvent::Install) + .await + .unwrap(); + assert!(report.is_none()); + } + + #[tokio::test] + async fn run_lifecycle_event_runs_declared_script() { + use bitfun_product_domains::miniapp::types::{ + MiniAppLifecycleEvent, MiniAppLifecycleScripts, + }; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + + // Write a real hook script under the app directory. + let app_dir = manager.path_manager().miniapp_dir(&app.id); + let hooks_dir = app_dir.join("hooks"); + tokio::fs::create_dir_all(&hooks_dir).await.unwrap(); + tokio::fs::write( + hooks_dir.join("install.js"), + "console.log('installed:' + process.env.BITFUN_MINIAPP_EVENT);\n", + ) + .await + .unwrap(); + + manager + .set_lifecycle_scripts( + &app.id, + MiniAppLifecycleScripts { + install: Some("hooks/install.js".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + + let report = manager + .run_lifecycle_event(&app.id, MiniAppLifecycleEvent::Install) + .await + .unwrap() + .expect("a report is produced when a script is declared"); + assert_eq!(report.event, MiniAppLifecycleEvent::Install); + assert_eq!(report.relative_path, "hooks/install.js"); + + // A JS runtime is available in this environment; assert the script ran. + // If none is present the report carries an explanatory error instead. + if report.error.is_none() { + assert!(report.succeeded, "stderr: {}", report.stderr); + assert!(report.stdout.contains("installed:install")); + } else { + assert!(!report.succeeded); + } + + // Traversal is rejected even if declared. + manager + .set_lifecycle_scripts( + &app.id, + MiniAppLifecycleScripts { + start: Some("../escape.js".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + let escape = manager + .run_lifecycle_event(&app.id, MiniAppLifecycleEvent::Start) + .await + .unwrap() + .expect("declared script yields a report"); + assert!(!escape.succeeded); + assert!(escape.error.is_some()); + } + + #[tokio::test] + async fn run_named_script_runs_declared_script_with_args() { + use bitfun_product_domains::miniapp::types::MiniAppScriptDef; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + + let app_dir = manager.path_manager().miniapp_dir(&app.id); + let scripts_dir = app_dir.join("scripts"); + tokio::fs::create_dir_all(&scripts_dir).await.unwrap(); + tokio::fs::write( + scripts_dir.join("echo.js"), + "console.log('script:' + process.env.BITFUN_MINIAPP_SCRIPT + ':' + (process.argv[2] || ''));\n", + ) + .await + .unwrap(); + + // Unknown script -> None. + assert!(manager + .run_named_script(&app.id, "missing", vec![]) + .await + .unwrap() + .is_none()); + + manager + .set_scripts( + &app.id, + vec![MiniAppScriptDef { + name: "echo".to_string(), + path: "scripts/echo.js".to_string(), + description: Some("Echo a value".to_string()), + }], + ) + .await + .unwrap(); + + let report = manager + .run_named_script(&app.id, "echo", vec!["hello".to_string()]) + .await + .unwrap() + .expect("declared script yields a report"); + assert_eq!(report.name, "echo"); + assert_eq!(report.relative_path, "scripts/echo.js"); + if report.error.is_none() { + assert!(report.succeeded, "stderr: {}", report.stderr); + assert!(report.stdout.contains("script:echo:hello")); + } else { + assert!(!report.succeeded); + } + } + #[test] fn miniapp_port_error_mapping_preserves_manager_error_shape() { let not_found = map_miniapp_port_error(MiniAppPortError::new( @@ -947,6 +1421,7 @@ mod tests { runtime_profile: Default::default(), view_mode: Default::default(), lifecycle: Default::default(), + scripts: Default::default(), i18n: None, }; tokio::fs::write( diff --git a/src/crates/assembly/core/src/miniapp/mod.rs b/src/crates/assembly/core/src/miniapp/mod.rs index fc2a7bdc74..7850a8d809 100644 --- a/src/crates/assembly/core/src/miniapp/mod.rs +++ b/src/crates/assembly/core/src/miniapp/mod.rs @@ -25,7 +25,8 @@ pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, pub use host_dispatch::{dispatch_host, is_host_primitive}; pub use js_worker_pool::{InstallResult, JsWorkerPool}; pub use manager::{ - initialize_global_miniapp_manager, try_get_global_miniapp_manager, MiniAppManager, + initialize_global_miniapp_manager, try_get_global_miniapp_manager, MiniAppLifecycleReport, + MiniAppManager, }; pub use permission_policy::resolve_policy; pub use runtime_detect::{DetectedRuntime, RuntimeKind}; diff --git a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs index 62f719d454..4cb417b2bb 100644 --- a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs +++ b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs @@ -4,8 +4,9 @@ use std::path::{Path, PathBuf}; use crate::miniapp::storage::MiniAppStorageLayout; use crate::miniapp::types::{ - MiniApp, MiniAppAiContext, MiniAppLifecycleEvent, MiniAppLifecycleScripts, MiniAppMeta, - MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, MiniAppViewMode, + find_script_path, MiniApp, MiniAppAiContext, MiniAppLifecycleEvent, MiniAppLifecycleScripts, + MiniAppMeta, MiniAppPermissions, MiniAppRuntimeState, MiniAppScriptDef, MiniAppSource, + MiniAppViewMode, }; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -34,6 +35,7 @@ pub struct MiniAppUpdatePatch { pub ai_context: Option, pub view_mode: Option, pub lifecycle: Option, + pub scripts: Option>, } impl MiniAppUpdatePatch { @@ -81,6 +83,7 @@ pub fn miniapp_content_hash(app: &MiniApp) -> String { "runtimeProfile": app.runtime_profile, "viewMode": app.view_mode, "lifecycle": &app.lifecycle, + "scripts": &app.scripts, "i18n": &app.i18n, })); let encoded = serde_json::to_vec(&payload) @@ -152,6 +155,7 @@ pub fn build_created_app( runtime_profile: Default::default(), view_mode: MiniAppViewMode::default(), lifecycle: MiniAppLifecycleScripts::default(), + scripts: Vec::new(), i18n: None, }; refresh_content_hash(&mut app); @@ -198,6 +202,9 @@ pub fn apply_update_patch( if let Some(lifecycle) = patch.lifecycle { app.lifecycle = lifecycle; } + if let Some(scripts) = patch.scripts { + app.scripts = scripts; + } app.version += 1; app.updated_at = now; @@ -292,6 +299,7 @@ pub fn apply_draft_to_active( app.ai_context = draft.ai_context; app.view_mode = draft.view_mode; app.lifecycle = draft.lifecycle; + app.scripts = draft.scripts; app.i18n = draft.i18n; app.version = current.version + 1; app.updated_at = now; @@ -505,6 +513,51 @@ pub fn plan_lifecycle_script( }) } +/// A resolved named-script invocation the host runtime should execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppScriptPlan { + pub name: String, + /// Absolute path to the script file, guaranteed to be inside the app dir. + pub script_path: PathBuf, + /// The manifest-relative path, retained for logging/telemetry. + pub relative_path: String, +} + +/// Resolve a named script (declared in `meta.json` `scripts`) to an absolute +/// path within the app directory. +/// +/// Returns `None` when no script matches `name` or when the declared path would +/// escape the app directory. Pure decision: the services layer confirms the +/// file exists and executes it. +pub fn plan_named_script( + layout: &MiniAppStorageLayout, + scripts: &[MiniAppScriptDef], + name: &str, +) -> Option { + let relative = find_script_path(scripts, name)?; + let script_path = layout.resolve_contained_relative(relative)?; + Some(MiniAppScriptPlan { + name: name.trim().to_string(), + script_path, + relative_path: relative.to_string(), + }) +} + +/// Frontend/telemetry payload emitted when a named script runs. +pub fn miniapp_script_event_payload( + app_id: &str, + name: &str, + relative_path: &str, + succeeded: bool, +) -> Value { + json!({ + "id": app_id, + "script": name, + "path": relative_path, + "succeeded": succeeded, + }) +} + /// Frontend/telemetry payload emitted when a lifecycle script runs. pub fn miniapp_lifecycle_event_payload( app_id: &str, @@ -542,6 +595,7 @@ mod tests { runtime_profile: Default::default(), view_mode: Default::default(), lifecycle: Default::default(), + scripts: Default::default(), i18n: None, }; @@ -741,4 +795,71 @@ mod tests { ); assert_eq!(updated.version, previous.version + 1); } + + #[test] + fn named_scripts_lookup_and_resolution_guard_traversal() { + let scripts = vec![ + MiniAppScriptDef { + name: "build".to_string(), + path: "scripts/build.js".to_string(), + description: Some("Build the project".to_string()), + }, + MiniAppScriptDef { + name: "escape".to_string(), + path: "../evil.js".to_string(), + description: None, + }, + // Blank name/path entries are ignored. + MiniAppScriptDef { + name: " ".to_string(), + path: "scripts/x.js".to_string(), + description: None, + }, + ]; + + assert_eq!(find_script_path(&scripts, "build"), Some("scripts/build.js")); + assert_eq!(find_script_path(&scripts, "missing"), None); + assert_eq!(find_script_path(&scripts, " "), None); + + let layout = MiniAppStorageLayout::new("/root/miniapps", "app-1"); + let plan = plan_named_script(&layout, &scripts, "build").expect("build resolves"); + assert_eq!(plan.name, "build"); + assert_eq!(plan.relative_path, "scripts/build.js"); + assert_eq!( + plan.script_path, + layout.app_dir().join("scripts").join("build.js") + ); + // Traversal is rejected. + assert_eq!(plan_named_script(&layout, &scripts, "escape"), None); + assert_eq!(plan_named_script(&layout, &scripts, "missing"), None); + } + + #[test] + fn content_hash_and_update_patch_track_named_scripts() { + let base = sample_app(); + let base_hash = miniapp_content_hash(&base); + + let mut with_scripts = base.clone(); + with_scripts.scripts.push(MiniAppScriptDef { + name: "sync".to_string(), + path: "scripts/sync.js".to_string(), + description: None, + }); + assert_ne!(miniapp_content_hash(&with_scripts), base_hash); + + let patch = MiniAppUpdatePatch { + scripts: Some(vec![MiniAppScriptDef { + name: "build".to_string(), + path: "scripts/build.js".to_string(), + description: None, + }]), + ..Default::default() + }; + let updated = apply_update_patch(&base, patch, "".to_string(), 789); + assert_eq!(updated.scripts.len(), 1); + assert_eq!( + find_script_path(&updated.scripts, "build"), + Some("scripts/build.js") + ); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs index 5ed1592595..58820d9355 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs @@ -194,6 +194,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { ai_context: None, view_mode: None, lifecycle: None, + scripts: None, }, compiled_html, now, @@ -870,6 +871,7 @@ mod tests { runtime_profile: Default::default(), view_mode: Default::default(), lifecycle: Default::default(), + scripts: Default::default(), i18n: None, } } diff --git a/src/crates/contracts/product-domains/src/miniapp/storage.rs b/src/crates/contracts/product-domains/src/miniapp/storage.rs index e6da575e47..2035529c06 100644 --- a/src/crates/contracts/product-domains/src/miniapp/storage.rs +++ b/src/crates/contracts/product-domains/src/miniapp/storage.rs @@ -25,6 +25,11 @@ pub const VERSIONS_DIR: &str = "versions"; /// relative to the app root, so hooks may also live at the app root; this /// constant documents the recommended layout. pub const HOOKS_DIR: &str = "hooks"; +/// Conventional directory for named capability scripts inside an app +/// (`{app_dir}/scripts/`). Like hooks, a named script's path in `meta.json` is +/// resolved relative to the app root, so scripts may live elsewhere; this +/// constant documents the recommended layout and is carried on import. +pub const SCRIPTS_DIR: &str = "scripts"; pub const DRAFTS_DIR: &str = ".drafts"; pub const DRAFTS_CLEANUP_PREFIX: &str = ".drafts.cleanup-"; pub const DRAFTS_CLEANUP_MARKER: &str = ".cleanup-pending"; diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs index cbfd90f0a6..26dad16e8a 100644 --- a/src/crates/contracts/product-domains/src/miniapp/types.rs +++ b/src/crates/contracts/product-domains/src/miniapp/types.rs @@ -349,6 +349,46 @@ impl MiniAppLifecycleScripts { } } +/// A named, user-declared script that extends a MiniApp's capabilities. +/// +/// Unlike the fixed lifecycle hooks, named scripts are arbitrary commands the +/// app author ships (recommended under `scripts/`), invokable on demand by the +/// user, the app UI (via the host bridge), or the agent. `path` is resolved +/// against the app root with the same traversal guard as lifecycle scripts and +/// run with the detected JS runtime (Bun/Node). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MiniAppScriptDef { + /// Stable identifier used to invoke the script (e.g. `build`, `sync`). + pub name: String, + /// Path to the script file, relative to the app root. + pub path: String, + /// Optional human-facing description shown in the UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +impl MiniAppScriptDef { + /// The script path if both `name` and `path` are non-empty after trimming. + pub fn resolved_path(&self) -> Option<&str> { + let path = self.path.trim(); + if self.name.trim().is_empty() || path.is_empty() { + None + } else { + Some(path) + } + } +} + +/// Look up a declared script by name (trimmed, exact match) and return its +/// relative path. +pub fn find_script_path<'a>(scripts: &'a [MiniAppScriptDef], name: &str) -> Option<&'a str> { + let target = name.trim(); + scripts + .iter() + .find(|script| script.name.trim() == target) + .and_then(MiniAppScriptDef::resolved_path) +} + /// Full MiniApp entity (in-memory / API). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MiniApp { @@ -388,6 +428,10 @@ pub struct MiniApp { #[serde(default, skip_serializing_if = "MiniAppLifecycleScripts::is_empty")] pub lifecycle: MiniAppLifecycleScripts, + /// Named scripts the app ships to extend its capabilities. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scripts: Vec, + /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, @@ -420,6 +464,9 @@ pub struct MiniAppMeta { /// User-declared lifecycle scripts (install / uninstall / start / stop). #[serde(default, skip_serializing_if = "MiniAppLifecycleScripts::is_empty")] pub lifecycle: MiniAppLifecycleScripts, + /// Named scripts the app ships to extend its capabilities. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scripts: Vec, /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, @@ -443,6 +490,7 @@ impl From<&MiniApp> for MiniAppMeta { runtime_profile: app.runtime_profile, view_mode: app.view_mode, lifecycle: app.lifecycle.clone(), + scripts: app.scripts.clone(), i18n: app.i18n.clone(), } } diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index fd9251010f..4f2851a9ce 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -2310,6 +2310,7 @@ fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { runtime_profile: Default::default(), view_mode: Default::default(), lifecycle: Default::default(), + scripts: Default::default(), i18n: None, } } diff --git a/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs b/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs index 6ac8fb5149..72faafdb77 100644 --- a/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs +++ b/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs @@ -43,14 +43,30 @@ impl LifecycleScriptOutcome { /// Run `script_path` with `runtime`, using `app_dir` as the working directory. /// /// The script path must already be validated as contained within the app -/// directory by the caller. Returns an error only when the process cannot be -/// spawned/awaited or exceeds `timeout_ms`; a script that runs but exits -/// non-zero returns `Ok` with `succeeded == false` so the caller can decide how -/// to react per event. +/// directory by the caller. `extra_env` is injected into the child environment +/// (app id, app dir, event, resolved permission policy) so a trusted script has +/// its context. Returns an error only when the process cannot be spawned/awaited +/// or exceeds `timeout_ms`; a script that runs but exits non-zero returns `Ok` +/// with `succeeded == false` so the caller can decide how to react per event. pub async fn run_lifecycle_script( runtime: &DetectedRuntime, script_path: &Path, app_dir: &Path, + extra_env: &[(String, String)], + timeout_ms: u64, +) -> Result { + run_miniapp_script(runtime, script_path, app_dir, &[], extra_env, timeout_ms).await +} + +/// Run a MiniApp script (lifecycle hook or named script) with optional CLI +/// arguments, identical to [`run_lifecycle_script`] but forwarding `args` to the +/// script process. +pub async fn run_miniapp_script( + runtime: &DetectedRuntime, + script_path: &Path, + app_dir: &Path, + args: &[String], + extra_env: &[(String, String)], timeout_ms: u64, ) -> Result { let exe = runtime.path.to_string_lossy(); @@ -59,11 +75,15 @@ pub async fn run_lifecycle_script( let mut command = bitfun_services_core::process_manager::create_tokio_command(&*exe); command .arg(&*script) + .args(args) .current_dir(app_dir) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true); + for (key, value) in extra_env { + command.env(key, value); + } let effective_timeout = if timeout_ms == 0 { DEFAULT_LIFECYCLE_TIMEOUT_MS @@ -131,7 +151,8 @@ mod tests { ) .unwrap(); - let outcome = run_lifecycle_script(&runtime, &script, &dir, 10_000) + let env = [("BITFUN_MINIAPP_EVENT".to_string(), "install".to_string())]; + let outcome = run_lifecycle_script(&runtime, &script, &dir, &env, 10_000) .await .expect("script should run"); diff --git a/src/crates/services/services-integrations/src/miniapp/storage.rs b/src/crates/services/services-integrations/src/miniapp/storage.rs index 73688c9aa9..cde2309ba7 100644 --- a/src/crates/services/services-integrations/src/miniapp/storage.rs +++ b/src/crates/services/services-integrations/src/miniapp/storage.rs @@ -7,9 +7,9 @@ use bitfun_product_domains::miniapp::ports::{ use bitfun_product_domains::miniapp::storage::{ build_package_json, parse_npm_dependencies, MiniAppImportBundleWriteRequest, MiniAppImportLayout, MiniAppStorageLayout, COMPILED_HTML, CUSTOMIZATION_JSON, - DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, ESM_DEPS_JSON, - INDEX_HTML, META_JSON, PACKAGE_JSON, REQUIRED_SOURCE_FILES, STORAGE_JSON, STYLE_CSS, UI_JS, - VERSIONS_DIR, WORKER_JS, + DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, ESM_DEPS_JSON, HOOKS_DIR, + INDEX_HTML, META_JSON, PACKAGE_JSON, REQUIRED_SOURCE_FILES, SCRIPTS_DIR, STORAGE_JSON, + STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, }; use bitfun_product_domains::miniapp::types::{MiniApp, MiniAppMeta, MiniAppSource, NpmDep}; use serde_json; @@ -277,12 +277,57 @@ impl MiniAppStorage { .map_err(|_| MiniAppStorageError::io("Failed to write storage.json"))?; } + // Carry optional capability directories so declared lifecycle hooks and + // named scripts exist after import. + for extra_dir in [HOOKS_DIR, SCRIPTS_DIR] { + let from = request.source_path.join(extra_dir); + if from.is_dir() { + Self::copy_dir_recursive(&from, &dest_dir.join(extra_dir)).await?; + } + } + tokio::fs::write(dest_dir.join(COMPILED_HTML), request.compiled_html) .await .map_err(|_| MiniAppStorageError::io("Failed to write placeholder compiled.html"))?; Ok(()) } + /// Recursively copy a directory tree (used to carry the `hooks/` directory on + /// import). Iterative to avoid boxing an async recursion. + async fn copy_dir_recursive(from: &Path, to: &Path) -> MiniAppStorageResult<()> { + let mut pending: Vec<(PathBuf, PathBuf)> = vec![(from.to_path_buf(), to.to_path_buf())]; + while let Some((src, dst)) = pending.pop() { + tokio::fs::create_dir_all(&dst).await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to create {}: {}", dst.display(), e)) + })?; + let mut entries = tokio::fs::read_dir(&src).await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to read {}: {}", src.display(), e)) + })?; + while let Some(entry) = entries.next_entry().await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to enumerate {}: {}", src.display(), e)) + })? { + let file_type = entry.file_type().await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to stat entry: {}", e)) + })?; + let child_src = entry.path(); + let child_dst = dst.join(entry.file_name()); + if file_type.is_dir() { + pending.push((child_src, child_dst)); + } else if file_type.is_file() { + tokio::fs::copy(&child_src, &child_dst).await.map_err(|e| { + MiniAppStorageError::io(format!( + "Failed to copy {}: {}", + child_src.display(), + e + )) + })?; + } + // Symlinks and other node types are intentionally skipped. + } + } + Ok(()) + } + /// Ensure app directory and source subdir exist. pub async fn ensure_app_dir(&self, app_id: &str) -> MiniAppStorageResult<()> { let dir = self.app_dir(app_id); @@ -365,6 +410,7 @@ impl MiniAppStorage { runtime_profile: meta.runtime_profile, view_mode: meta.view_mode, lifecycle: meta.lifecycle, + scripts: meta.scripts, i18n: meta.i18n, }) } @@ -751,6 +797,7 @@ impl MiniAppStorage { runtime_profile: meta.runtime_profile, view_mode: meta.view_mode, lifecycle: meta.lifecycle, + scripts: meta.scripts, i18n: meta.i18n, }) } @@ -1453,6 +1500,14 @@ mod tests { ) .unwrap(); fs::write(import_source_dir.join(WORKER_JS), "").unwrap(); + // Lifecycle hooks directory must travel with the imported app. + let import_hooks_dir = import_root.join(HOOKS_DIR); + fs::create_dir_all(&import_hooks_dir).unwrap(); + fs::write( + import_hooks_dir.join("install.js"), + "console.log('installed');", + ) + .unwrap(); let storage = MiniAppStorage::new(miniapps_dir.clone()); let read_meta = storage.read_import_meta_json(&import_root).await.unwrap(); @@ -1489,6 +1544,11 @@ mod tests { fs::read_to_string(layout.compiled_path()).unwrap(), "placeholder" ); + // The hooks directory and its scripts are carried into the app dir. + assert_eq!( + fs::read_to_string(layout.app_dir().join(HOOKS_DIR).join("install.js")).unwrap(), + "console.log('installed');" + ); } #[tokio::test] @@ -1852,6 +1912,7 @@ mod tests { runtime_profile: Default::default(), view_mode: Default::default(), lifecycle: Default::default(), + scripts: Default::default(), i18n: None, } } diff --git a/src/crates/services/services-integrations/src/miniapp_market/package.rs b/src/crates/services/services-integrations/src/miniapp_market/package.rs index f037e19682..c6b353d7fa 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/package.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/package.rs @@ -433,6 +433,7 @@ mod tests { runtime_profile: Default::default(), view_mode: Default::default(), lifecycle: Default::default(), + scripts: Default::default(), i18n: None, }; diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 32d94bd012..0711fbcc7f 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -54,6 +54,9 @@ const ToolbarMode = lazy(() => const FloatingMiniChat = lazy(() => import('./FloatingMiniChat').then(module => ({ default: module.FloatingMiniChat })) ); +const MiniAppBackgroundDock = lazy(() => + import('../scenes/miniapps/components/MiniAppBackgroundDock') +); const AboutDialog = lazy(() => import('../components/AboutDialog').then(module => ({ default: module.AboutDialog })) ); @@ -783,6 +786,11 @@ const AppLayout: React.FC = ({ className = '' }) => { )} + + {/* Resident dock for background view-mode MiniApps */} + + + {/* Dialogs (previously owned by TitleBar) */} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.scss b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.scss new file mode 100644 index 0000000000..a66f643383 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.scss @@ -0,0 +1,128 @@ +/** + * MiniAppBackgroundDock styles — resident panel for background view-mode apps. + */ +@use '../../../../component-library/styles/tokens' as *; + +.miniapp-bg-dock { + position: fixed; + right: $size-gap-4; + bottom: $size-gap-4; + z-index: 40; + display: flex; + flex-direction: column; + width: 360px; + max-width: calc(100vw - #{$size-gap-6}); + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-lg; + background: var(--bf-appearance-token-color-bg-elevated); + box-shadow: var(--bf-appearance-token-shadow-base); + overflow: hidden; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + } + + &__title { + font-size: var(--bf-appearance-token-font-size-sm); + font-weight: $font-weight-semibold; + color: var(--bf-appearance-token-color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__tabs { + display: flex; + flex-wrap: wrap; + gap: $size-gap-1; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + } + + &__tab { + display: inline-flex; + align-items: center; + gap: $size-gap-1; + padding: 2px $size-gap-2; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-base; + background: var(--bf-appearance-token-color-bg-secondary); + color: var(--bf-appearance-token-color-text-muted); + font-size: var(--bf-appearance-token-font-size-xs); + cursor: pointer; + + &--active { + background: var(--bf-appearance-token-color-bg-primary); + color: var(--bf-appearance-token-color-text-primary); + } + } + + &__tab-label { + max-width: 120px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + + &__body { + display: flex; + flex-direction: column; + height: 420px; + max-height: calc(100vh - #{$size-gap-8}); + } + + &__body-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-1 $size-gap-3; + } + + &__body-title { + font-size: var(--bf-appearance-token-font-size-xs); + color: var(--bf-appearance-token-color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__runner { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + } + + &__loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $size-gap-2; + width: 100%; + color: var(--bf-appearance-token-color-text-muted); + font-size: var(--bf-appearance-token-font-size-sm); + } + + &__spinner { + animation: miniapp-bg-dock-spin 1s linear infinite; + } +} + +@keyframes miniapp-bg-dock-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.tsx new file mode 100644 index 0000000000..dd8b82795b --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.tsx @@ -0,0 +1,193 @@ +/** + * MiniAppBackgroundDock — resident panel hosting `background` view-mode apps. + * + * Background apps do not claim a scene tab; they stay collapsed into this dock + * (bottom-right), where the user can expand one to interact with it while it + * keeps running. Closing an entry stops its worker and removes it from the dock. + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useAppearance } from '@/infrastructure/appearance'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { createLogger } from '@/shared/utils/logger'; +import { IconButton } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { useMiniAppStore } from '../miniAppStore'; +import { pickLocalizedString } from '../utils/pickLocalizedString'; +import MiniAppRunner from './MiniAppRunner'; +import './MiniAppBackgroundDock.scss'; + +const log = createLogger('MiniAppBackgroundDock'); + +const MiniAppBackgroundDock: React.FC = () => { + const apps = useMiniAppStore((state) => state.apps); + const backgroundAppIds = useMiniAppStore((state) => state.backgroundAppIds); + const closeBackground = useMiniAppStore((state) => state.closeBackground); + const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped); + const { current: appearance } = useAppearance(); + const appearanceMode = appearance?.mode ?? 'dark'; + const { workspacePath } = useCurrentWorkspace(); + const { t, currentLanguage } = useI18n('scenes/miniapp'); + + const [expanded, setExpanded] = useState(true); + const [activeId, setActiveId] = useState(null); + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(false); + const [strictRuntime, setStrictRuntime] = useState(false); + + const backgroundApps = useMemo( + () => backgroundAppIds + .map((id) => apps.find((candidate) => candidate.id === id)) + .filter((value): value is NonNullable => Boolean(value)), + [backgroundAppIds, apps], + ); + + // Keep an active selection that always points at a resident app. + useEffect(() => { + if (backgroundAppIds.length === 0) { + setActiveId(null); + return; + } + setActiveId((current) => + current && backgroundAppIds.includes(current) ? current : backgroundAppIds[0] + ); + }, [backgroundAppIds]); + + const loadApp = useCallback(async (id: string) => { + setLoading(true); + try { + const loaded = await miniAppAPI.getMiniApp(id, appearanceMode, workspacePath || undefined); + setStrictRuntime(loaded.runtime_profile === 'market_strict'); + setApp(loaded); + } catch (error) { + log.error('Failed to load background MiniApp', error); + setApp(null); + } finally { + setLoading(false); + } + }, [appearanceMode, workspacePath]); + + useEffect(() => { + if (expanded && activeId) { + void loadApp(activeId); + } else { + setApp(null); + } + }, [expanded, activeId, loadApp]); + + const handleClose = useCallback(async (id: string) => { + try { + await miniAppAPI.workerStop(id); + } catch (error) { + log.warn('Stop background worker failed', error); + } finally { + markWorkerStopped(id); + closeBackground(id); + } + }, [closeBackground, markWorkerStopped]); + + if (backgroundApps.length === 0) { + return null; + } + + const activeApp = backgroundApps.find((candidate) => candidate.id === activeId) ?? backgroundApps[0]; + const activeName = activeApp ? pickLocalizedString(activeApp, currentLanguage, 'name') : 'Mini App'; + + return ( +
+
+ + {t('dock.title', { count: backgroundApps.length })} + + setExpanded((value) => !value)} + tooltip={expanded ? t('dock.collapse') : t('dock.expand')} + aria-label={expanded ? t('dock.collapse') : t('dock.expand')} + > + {expanded ? : } + +
+ + {backgroundApps.length > 1 && expanded && ( +
+ {backgroundApps.map((candidate) => { + const name = pickLocalizedString(candidate, currentLanguage, 'name'); + const isActive = candidate.id === activeApp?.id; + return ( + + ); + })} +
+ )} + + {expanded && activeApp && ( +
+
+ {activeName} + void handleClose(activeApp.id)} + tooltip={t('dock.close')} + aria-label={t('dock.close')} + > + + +
+
+ {loading && !app && ( +
+ + {t('dock.loading')} +
+ )} + {app && ( + + )} +
+
+ )} +
+ ); +}; + +export default MiniAppBackgroundDock; diff --git a/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts b/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts index 1057a986d4..6e76295324 100644 --- a/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts +++ b/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts @@ -148,6 +148,8 @@ interface MiniAppState { loading: boolean; /** App IDs whose scenes are currently open in the viewport. */ openedAppIds: string[]; + /** App IDs resident in the background dock panel (view_mode = background). */ + backgroundAppIds: string[]; /** App IDs whose JS workers are currently running. */ runningWorkerIds: string[]; /** App IDs with an active customization surface in the MiniApp tab. */ @@ -170,6 +172,10 @@ interface MiniAppState { setMarketOrigin: (appId: string, origin: InstalledMarketOrigin) => void; openApp: (id: string) => void; closeApp: (id: string) => void; + /** Add an app to the resident background dock. */ + openBackground: (id: string) => void; + /** Remove an app from the resident background dock. */ + closeBackground: (id: string) => void; setRunningWorkerIds: (ids: string[]) => void; markWorkerRunning: (id: string) => void; markWorkerStopped: (id: string) => void; @@ -188,6 +194,7 @@ export const useMiniAppStore = create((set) => ({ apps: [], loading: false, openedAppIds: [], + backgroundAppIds: [], runningWorkerIds: [], customizingAppIds: [], composerClaims: {}, @@ -199,6 +206,7 @@ export const useMiniAppStore = create((set) => ({ return { apps, openedAppIds: state.openedAppIds.filter((id) => validIds.has(id)), + backgroundAppIds: state.backgroundAppIds.filter((id) => validIds.has(id)), runningWorkerIds: state.runningWorkerIds.filter((id) => validIds.has(id)), customizingAppIds: state.customizingAppIds.filter((id) => validIds.has(id)), composerClaims: Object.fromEntries( @@ -236,6 +244,16 @@ export const useMiniAppStore = create((set) => ({ composerClaims, }; }), + openBackground: (id) => + set((state) => + state.backgroundAppIds.includes(id) + ? state + : { backgroundAppIds: [...state.backgroundAppIds, id] } + ), + closeBackground: (id) => + set((state) => ({ + backgroundAppIds: state.backgroundAppIds.filter((value) => value !== id), + })), setRunningWorkerIds: (ids) => set({ runningWorkerIds: Array.from(new Set(ids)) }), markWorkerRunning: (id) => set((state) => diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index 581379e5cd..7fc8663bb8 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -13,7 +13,7 @@ import { import { open } from '@tauri-apps/plugin-dialog'; import { useSceneManager } from '@/app/hooks/useSceneManager'; import MiniAppCard from '../components/MiniAppCard'; -import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import type { MiniAppMeta, MiniAppViewMode } from '@/infrastructure/api/service-api/MiniAppAPI'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; import { miniAppMarketAPI, @@ -54,6 +54,8 @@ const MiniAppGalleryView: React.FC = () => { const setMarketOrigins = useMiniAppStore((state) => state.setMarketOrigins); const setRunningWorkerIds = useMiniAppStore((state) => state.setRunningWorkerIds); const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped); + const upsertApp = useMiniAppStore((state) => state.upsertApp); + const openBackground = useMiniAppStore((state) => state.openBackground); const { workspacePath } = useCurrentWorkspace(); const notification = useNotification(); const { openScene, activateScene, closeScene, openTabs } = useSceneManager(); @@ -109,6 +111,29 @@ const MiniAppGalleryView: React.FC = () => { const handleOpenApp = useCallback( (appId: string) => { setSelectedApp(null); + const app = apps.find((candidate) => candidate.id === appId); + const viewMode = app?.view_mode ?? 'front'; + + // Fire the `start` lifecycle hook on activation (host runs it if declared). + void miniAppAPI + .runLifecycleEvent(appId, 'start') + .catch((error) => log.warn('MiniApp start lifecycle failed', error)); + + if (viewMode === 'full') { + // Full mode opens an independent OS window instead of a scene tab. + void miniAppAPI + .openFullWindow(appId, app?.name) + .catch((error) => log.error('Open MiniApp window failed', error)); + return; + } + + if (viewMode === 'background') { + // Background mode stays resident in the collapsed dock panel. + openBackground(appId); + return; + } + + // Front (default): open in a scene tab in the main shell. const tabId: SceneTabId = `miniapp:${appId}`; if (openTabIds.has(tabId)) { activateScene(tabId); @@ -116,7 +141,7 @@ const MiniAppGalleryView: React.FC = () => { openScene(tabId); } }, - [openTabIds, activateScene, openScene] + [apps, openBackground, openTabIds, activateScene, openScene] ); const handleStopRunning = useCallback( @@ -136,6 +161,43 @@ const MiniAppGalleryView: React.FC = () => { [markWorkerStopped, closeScene, openTabIds] ); + const handleSetViewMode = useCallback( + async (appId: string, mode: MiniAppViewMode) => { + try { + const updated = await miniAppAPI.setViewMode(appId, mode); + upsertApp(updated); + setSelectedApp((current) => (current && current.id === appId ? updated : current)); + } catch (error) { + log.error('Set view mode failed', error); + } + }, + [upsertApp] + ); + + const handleRunScript = useCallback( + async (appId: string, scriptName: string) => { + try { + const result = await miniAppAPI.runScript(appId, scriptName); + if (result.succeeded) { + notification.success(t('detail.scripts.ran', { name: scriptName })); + } else { + notification.error( + t('detail.scripts.failed', { + name: scriptName, + error: result.error ?? (result.stderr || '').trim(), + }) + ); + } + } catch (error) { + log.error('Run script failed', error); + notification.error( + t('detail.scripts.failed', { name: scriptName, error: String(error) }) + ); + } + }, + [notification, t] + ); + const handleDeleteRequest = (appId: string) => { setPendingDeleteId(appId); }; @@ -433,16 +495,97 @@ const MiniAppGalleryView: React.FC = () => { > {selectedApp ? (() => { const detailTags = pickLocalizedTags(selectedApp, currentLanguage); - return detailTags.length ? ( -
- {detailTags.map((tag) => ( - - - {tag} + const activeMode: MiniAppViewMode = selectedApp.view_mode ?? 'front'; + const modeVariant = (mode: MiniAppViewMode) => + activeMode === mode ? 'primary' : 'secondary'; + return ( + <> +
+ + {t('detail.viewMode.label')} - ))} -
- ) : null; +
+ + + +
+

+ {t('detail.viewMode.hint')} +

+
+ {selectedApp.scripts && selectedApp.scripts.length > 0 ? ( +
+ + {t('detail.scripts.label')} + +
+ {selectedApp.scripts.map((script) => ( +
+ + {script.description ? ( + + {script.description} + + ) : null} +
+ ))} +
+
+ ) : null} + {detailTags.length ? ( +
+ {detailTags.map((tag) => ( + + + {tag} + + ))} +
+ ) : null} + + ); })() : null} diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index a391a7b966..ad32dc0fc5 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -28,6 +28,7 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'startup_window_control', 'toggle_main_window_fullscreen', 'set_main_window_transient_geometry', + 'open_miniapp_full_window', 'get_prevent_sleep_enabled', 'set_prevent_sleep_enabled', 'restart_app', diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts index f4ce7081f5..be39cd1c4a 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts @@ -176,6 +176,51 @@ export interface MiniAppI18n { locales: Record; } +/** + * How a MiniApp is presented in the host shell: + * - `background`: collapsed into a compact resident panel + * - `front` (default): opens inside a tab in the main content area + * - `full`: opens in its own independent OS window + */ +export type MiniAppViewMode = 'background' | 'front' | 'full'; + +/** A MiniApp lifecycle transition that can trigger a user-defined script. */ +export type MiniAppLifecycleEvent = 'install' | 'uninstall' | 'start' | 'stop'; + +/** Per-event lifecycle script paths, relative to the app root. */ +export interface MiniAppLifecycleScripts { + install?: string; + uninstall?: string; + start?: string; + stop?: string; +} + +/** A named script the app ships to extend its capabilities. */ +export interface MiniAppScriptDef { + name: string; + path: string; + description?: string; +} + +/** Result of running a named script via `runScript`. */ +export interface ScriptRunResult { + ran: boolean; + succeeded: boolean; + exitCode?: number | null; + stdout: string; + stderr: string; + error?: string | null; +} + +/** Result of triggering a lifecycle event via `runLifecycleEvent`. */ +export interface LifecycleRunResult { + /** Whether a script was declared and therefore run. */ + ran: boolean; + succeeded: boolean; + exitCode?: number | null; + error?: string | null; +} + export interface MiniAppMeta { id: string; name: string; @@ -189,6 +234,12 @@ export interface MiniAppMeta { permissions: MiniAppPermissions; runtime?: MiniAppRuntimeState; runtime_profile?: MiniAppRuntimeProfile; + /** Presentation mode (background panel / front tab / full window). */ + view_mode?: MiniAppViewMode; + /** User-declared lifecycle scripts (install/uninstall/start/stop). */ + lifecycle?: MiniAppLifecycleScripts; + /** Named scripts the app ships to extend its capabilities. */ + scripts?: MiniAppScriptDef[]; /** Optional per-locale overrides for `name` / `description` / `tags`. */ i18n?: MiniAppI18n; } @@ -345,6 +396,64 @@ export class MiniAppAPI { } } + /** Set the app's persisted view mode (background / front / full). */ + async setViewMode(appId: string, viewMode: MiniAppViewMode): Promise { + try { + return await api.invoke('miniapp_set_view_mode', { request: { appId, viewMode } }); + } catch (error) { + throw createTauriCommandError('miniapp_set_view_mode', error, { appId, viewMode }); + } + } + + /** Replace the app's lifecycle scripts (install/uninstall/start/stop). */ + async setLifecycleScripts(appId: string, lifecycle: MiniAppLifecycleScripts): Promise { + try { + return await api.invoke('miniapp_set_lifecycle_scripts', { request: { appId, lifecycle } }); + } catch (error) { + throw createTauriCommandError('miniapp_set_lifecycle_scripts', error, { appId }); + } + } + + /** + * Explicitly trigger a lifecycle event. Install/uninstall/stop also fire + * automatically host-side; the UI uses this for `start` (and `stop`) on + * activation / deactivation. + */ + async runLifecycleEvent(appId: string, event: MiniAppLifecycleEvent): Promise { + try { + return await api.invoke('miniapp_run_lifecycle_event', { request: { appId, event } }); + } catch (error) { + throw createTauriCommandError('miniapp_run_lifecycle_event', error, { appId, event }); + } + } + + /** Open (or focus) an independent OS window hosting the app in full mode. */ + async openFullWindow(appId: string, title?: string): Promise { + try { + await api.invoke('open_miniapp_full_window', { appId, title }); + } catch (error) { + throw createTauriCommandError('open_miniapp_full_window', error, { appId }); + } + } + + /** Replace the app's named scripts (scripts that extend its capabilities). */ + async setScripts(appId: string, scripts: MiniAppScriptDef[]): Promise { + try { + return await api.invoke('miniapp_set_scripts', { request: { appId, scripts } }); + } catch (error) { + throw createTauriCommandError('miniapp_set_scripts', error, { appId }); + } + } + + /** Run a named script the app declared, forwarding optional args. */ + async runScript(appId: string, script: string, args: string[] = []): Promise { + try { + return await api.invoke('miniapp_run_script', { request: { appId, script, args } }); + } catch (error) { + throw createTauriCommandError('miniapp_run_script', error, { appId, script }); + } + } + async getMiniAppVersions(appId: string): Promise { try { return await api.invoke('get_miniapp_versions', { appId }); diff --git a/src/web-ui/src/locales/en-US/scenes/miniapp.json b/src/web-ui/src/locales/en-US/scenes/miniapp.json index 52f488d02e..bd89e02d76 100644 --- a/src/web-ui/src/locales/en-US/scenes/miniapp.json +++ b/src/web-ui/src/locales/en-US/scenes/miniapp.json @@ -21,7 +21,26 @@ "detail": { "stop": "Stop", "delete": "Delete", - "open": "Open" + "open": "Open", + "viewMode": { + "label": "View mode", + "background": "Background", + "front": "Tab", + "full": "Window", + "hint": "Background keeps the app in a panel, Tab opens it in the workspace, Window opens a separate window." + }, + "scripts": { + "label": "Scripts", + "ran": "Script \"{{name}}\" finished", + "failed": "Script \"{{name}}\" failed: {{error}}" + } + }, + "dock": { + "title": "Background apps ({{count}})", + "expand": "Expand", + "collapse": "Collapse", + "close": "Close", + "loading": "Loading…" }, "confirmDelete": { "title": "Delete \"{{name}}\"?", diff --git a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json index 95b3989326..5afcaf7d55 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json @@ -21,7 +21,26 @@ "detail": { "stop": "停止", "delete": "删除", - "open": "打开" + "open": "打开", + "viewMode": { + "label": "打开方式", + "background": "后台面板", + "front": "标签页", + "full": "独立窗口", + "hint": "后台面板常驻侧栏面板,标签页在工作区中打开,独立窗口在单独的系统窗口中打开。" + }, + "scripts": { + "label": "脚本", + "ran": "脚本“{{name}}”已执行完成", + "failed": "脚本“{{name}}”执行失败:{{error}}" + } + }, + "dock": { + "title": "后台应用({{count}})", + "expand": "展开", + "collapse": "收起", + "close": "关闭", + "loading": "加载中…" }, "confirmDelete": { "title": "删除 \"{{name}}\"?", diff --git a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json index bc0fb2d23b..4fd91c0ce7 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json @@ -21,7 +21,26 @@ "detail": { "stop": "停止", "delete": "刪除", - "open": "開啟" + "open": "開啟", + "viewMode": { + "label": "開啟方式", + "background": "背景面板", + "front": "分頁", + "full": "獨立視窗", + "hint": "背景面板常駐側欄面板,分頁在工作區中開啟,獨立視窗在單獨的系統視窗中開啟。" + }, + "scripts": { + "label": "腳本", + "ran": "腳本「{{name}}」已執行完成", + "failed": "腳本「{{name}}」執行失敗:{{error}}" + } + }, + "dock": { + "title": "背景應用({{count}})", + "expand": "展開", + "collapse": "收起", + "close": "關閉", + "loading": "載入中…" }, "confirmDelete": { "title": "刪除 \"{{name}}\"?", diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index fe7ce2b532..29a56ff8f9 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -1,8 +1,10 @@ import ReactDOM from "react-dom/client"; import App from "./app/App"; import AgentCompanionDesktopPet from "./app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet"; +import MiniAppScene from "./app/scenes/miniapps/MiniAppScene"; import AppErrorBoundary from "./app/components/AppErrorBoundary"; import { STARTUP_OVERLAY_HIDDEN_EVENT } from "./app/startup/startupSignals"; +import { hideStartupOverlay } from "./app/startup/startupOverlay"; import { WorkspaceProvider } from "./infrastructure/contexts/WorkspaceProvider"; import { PeerDeviceProvider } from "./infrastructure/peer-device/PeerDeviceContext"; import { PeerHostInvokeBridge } from "./infrastructure/peer-device/PeerHostInvokeBridge"; @@ -340,10 +342,37 @@ async function startApplication(): Promise { durationMs: 0, mode: 'static', }); - const isAgentCompanionWindow = new URLSearchParams(window.location.search) - .get('bitfunWindow') === 'agent-companion'; + const windowParams = new URLSearchParams(window.location.search); + const isAgentCompanionWindow = windowParams.get('bitfunWindow') === 'agent-companion'; + const miniAppWindowId = + windowParams.get('bitfunWindow') === 'miniapp' + ? (windowParams.get('miniAppId') ?? '').trim() + : ''; const renderStartedAt = nowMs(); + if (miniAppWindowId) { + // Full view mode: host the MiniApp in its own OS window, reusing the shared + // scene component inside the minimal provider set it needs. + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + ); + // This standalone window does not run the full post-render startup pipeline, + // so dismiss the static startup overlay explicitly or it would cover the app. + void hideStartupOverlay(); + logElapsed(log, 'Startup step completed', renderStartedAt, { + data: { + step: 'scheduleMiniAppWindowRender', + sinceStartupMs: elapsedMs(appStartedAt), + }, + }); + return; + } if (isAgentCompanionWindow) { ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( From c9d984beaee3252e404b6404b73507df718967fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 13:37:35 +0000 Subject: [PATCH 5/5] feat(secrets): add {{name}} secret variables resolved only at tool time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store user secrets in an AES-GCM vault under the user data directory and expose a Settings → Secrets tab for write-only CRUD. Chat and model history keep {{name}} placeholders unchanged; the tool pipeline resolves them into a local copy used only for tool.call, so agents never see plaintext values. Unknown placeholders fail closed at execution. Co-authored-by: erow --- Cargo.lock | 2 + src/apps/desktop/src/api/app_state.rs | 1 + src/apps/desktop/src/api/mod.rs | 1 + .../src/api/remote_workspace_policy.rs | 3 + src/apps/desktop/src/api/user_secrets_api.rs | 45 +++ src/apps/desktop/src/lib.rs | 4 + src/crates/assembly/core/Cargo.toml | 2 + .../agentic/tools/pipeline/tool_pipeline.rs | 34 +- .../infrastructure/app_paths/path_manager.rs | 5 + src/crates/assembly/core/src/lib.rs | 2 + src/crates/assembly/core/src/user_secrets.rs | 64 ++++ src/crates/contracts/core-types/src/lib.rs | 2 + .../contracts/core-types/src/user_secret.rs | 59 ++++ .../execution/tool-contracts/src/lib.rs | 5 + .../tool-contracts/src/secret_placeholders.rs | 188 +++++++++++ src/crates/services/services-core/Cargo.toml | 12 + src/crates/services/services-core/src/lib.rs | 2 + .../services-core/src/user_secrets.rs | 300 ++++++++++++++++++ .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsConfig.ts | 17 + .../settings/settingsContentRegistry.ts | 3 + .../app/scenes/settings/settingsTabI18n.ts | 1 + .../settings/settingsTabSearchContent.ts | 10 + .../api/service-api/UserSecretsAPI.ts | 44 +++ .../config/components/SecretsConfig.scss | 34 ++ .../config/components/SecretsConfig.tsx | 179 +++++++++++ .../i18n/presets/namespaceRegistry.ts | 1 + src/web-ui/src/locales/en-US/settings.json | 3 + .../src/locales/en-US/settings/secrets.json | 54 ++++ src/web-ui/src/locales/zh-CN/settings.json | 7 + .../src/locales/zh-CN/settings/secrets.json | 54 ++++ src/web-ui/src/locales/zh-TW/settings.json | 7 + .../src/locales/zh-TW/settings/secrets.json | 54 ++++ 33 files changed, 1197 insertions(+), 4 deletions(-) create mode 100644 src/apps/desktop/src/api/user_secrets_api.rs create mode 100644 src/crates/assembly/core/src/user_secrets.rs create mode 100644 src/crates/contracts/core-types/src/user_secret.rs create mode 100644 src/crates/execution/tool-contracts/src/secret_placeholders.rs create mode 100644 src/crates/services/services-core/src/user_secrets.rs create mode 100644 src/web-ui/src/infrastructure/api/service-api/UserSecretsAPI.ts create mode 100644 src/web-ui/src/infrastructure/config/components/SecretsConfig.scss create mode 100644 src/web-ui/src/infrastructure/config/components/SecretsConfig.tsx create mode 100644 src/web-ui/src/locales/en-US/settings/secrets.json create mode 100644 src/web-ui/src/locales/zh-CN/settings/secrets.json create mode 100644 src/web-ui/src/locales/zh-TW/settings/secrets.json diff --git a/Cargo.lock b/Cargo.lock index dbc807b104..b516d61d35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,7 @@ dependencies = [ name = "bitfun-services-core" version = "0.2.19" dependencies = [ + "aes-gcm", "anyhow", "async-trait", "base64 0.22.1", @@ -1558,6 +1559,7 @@ dependencies = [ "libc", "log", "notify", + "rand 0.8.7", "regex", "rusqlite", "serde", diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 945fca9010..bca9f4839f 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -164,6 +164,7 @@ impl AppState { let miniapp_manager = Arc::new(MiniAppManager::new(path_manager.clone())); initialize_global_miniapp_manager(miniapp_manager.clone()); + bitfun_core::user_secrets::initialize_global_user_secrets(path_manager.clone()); match miniapp_manager.mark_stale_drafts_for_cleanup().await { Ok(cleanup_targets) if !cleanup_targets.is_empty() => { let cleanup_manager = miniapp_manager.clone(); diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index f7adf9ce6f..d6a4a1378b 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -57,6 +57,7 @@ pub mod system_api; pub mod terminal_api; pub mod token_usage_api; pub mod tool_api; +pub mod user_secrets_api; pub mod workspace_activation; pub mod worktree_api; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 2ab9abd3cc..269537a57f 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -382,6 +382,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("delete_session", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_subagent", RemoteWorkspacePolicy::LegacyUnaudited), + ("delete_user_secret", RemoteWorkspacePolicy::LocalOnly), // Detached dispatch is routed by its own immutable target and observer // index, never by the currently open workspace. ("dispatch_cancel", RemoteWorkspacePolicy::WorkspaceAgnostic), @@ -957,6 +958,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "list_subscription_accounts", RemoteWorkspacePolicy::LocalOnly, ), + ("list_user_secrets", RemoteWorkspacePolicy::LocalOnly), ( "list_visible_subagents", RemoteWorkspacePolicy::RemoteRouted, @@ -2046,6 +2048,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "upload_image_contexts", RemoteWorkspacePolicy::LegacyUnaudited, ), + ("upsert_user_secret", RemoteWorkspacePolicy::LocalOnly), ("validate_config", RemoteWorkspacePolicy::LegacyUnaudited), ( "validate_skill_path", diff --git a/src/apps/desktop/src/api/user_secrets_api.rs b/src/apps/desktop/src/api/user_secrets_api.rs new file mode 100644 index 0000000000..266837dad8 --- /dev/null +++ b/src/apps/desktop/src/api/user_secrets_api.rs @@ -0,0 +1,45 @@ +//! User secret variables — settings CRUD (values never returned). + +use bitfun_core::user_secrets::{ + delete_user_secret as delete_user_secret_inner, list_user_secrets as list_user_secrets_inner, + upsert_user_secret as upsert_user_secret_inner, +}; +use bitfun_core_types::{UserSecretSummary, UserSecretUpsert}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserSecretRequest { + pub name: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteUserSecretRequest { + pub name: String, +} + +#[tauri::command] +pub async fn list_user_secrets() -> Result, String> { + list_user_secrets_inner().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn upsert_user_secret( + request: UpsertUserSecretRequest, +) -> Result { + upsert_user_secret_inner(UserSecretUpsert { + name: request.name, + value: request.value, + }) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_user_secret(request: DeleteUserSecretRequest) -> Result { + delete_user_secret_inner(&request.name) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index ff4265b2ab..6bfc714bb9 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1972,6 +1972,10 @@ pub async fn run() { api::insights_api::cancel_insights_generation, // Token usage statistics API api::token_usage_api::get_token_usage_statistics, + // User secret variables (settings); values never leave the host vault + api::user_secrets_api::list_user_secrets, + api::user_secrets_api::upsert_user_secret, + api::user_secrets_api::delete_user_secret, // SSH Remote API api::ssh_api::ssh_list_saved_connections, api::ssh_api::ssh_save_connection, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 5ae029aee2..4c9606b919 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -233,6 +233,7 @@ agent-runtime = [ "bitfun-services-core/workspace-text-runtime", "bitfun-services-core/session-git", "bitfun-services-core/token-usage-statistics", + "user-secrets", "filesystem", "local-storage", "process-runtime", @@ -420,6 +421,7 @@ review-platform = ["bitfun-services-integrations/review-platform"] service-integrations = ["announcement", "file-watch", "git", "review-platform"] diagnostics = ["bitfun-services-core/diagnostics"] diff = ["bitfun-services-core/diff"] +user-secrets = ["bitfun-services-core/user-secrets"] dispatch-store = ["dep:base64", "local-storage", "bitfun-services-core/dispatch-workspace"] filesystem = ["bitfun-services-core/filesystem"] local-storage = ["dep:bitfun-agent-tools", "bitfun-services-core/local-storage"] diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index b9ce46f837..6e3ca39cda 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -28,9 +28,10 @@ use bitfun_agent_tools::{ build_tool_execution_timeout_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, build_write_tail_closure_notice, - render_tool_result_for_assistant, validate_tool_execution_admission, PermissionIntent, - ResolvedToolInvocation, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, - ToolExecutionErrorPresentation, GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, + render_tool_result_for_assistant, resolve_secret_placeholders_in_value, + validate_tool_execution_admission, PermissionIntent, ResolvedToolInvocation, + ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, ToolExecutionErrorPresentation, + GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, }; use bitfun_runtime_ports::{ PermissionReply, PermissionRequest, PermissionRequestSource, PermissionRequestSourceKind, @@ -2128,7 +2129,11 @@ impl ToolPipeline { let tool_context = self.build_tool_use_context(task, cancellation_token); - let execution_future = tool.call(task.effective_arguments(), &tool_context); + // Resolve {{secret}} placeholders into a local copy used only for + // execution. Wire/history arguments stay unresolved so the model never + // sees plaintext secret values. + let execution_arguments = resolve_tool_execution_arguments(task).await?; + let execution_future = tool.call(&execution_arguments, &tool_context); let timeout_owner = resolve_contextual_tool( Arc::clone(&tool), @@ -2194,7 +2199,28 @@ impl ToolPipeline { cancellation_token, ) } +} +/// Resolve `{{secret}}` placeholders for tool execution only. +/// +/// Persisted / model-visible arguments stay on `task.effective_arguments()`; +/// this returns a separate Value used solely as `tool.call` input. +async fn resolve_tool_execution_arguments(task: &ToolTask) -> BitFunResult { + let wire_args = task.effective_arguments().clone(); + #[cfg(feature = "user-secrets")] + { + let secrets = crate::user_secrets::load_user_secret_values().await?; + resolve_secret_placeholders_in_value(&wire_args, &secrets) + .map_err(|e| BitFunError::Tool(e.to_string())) + } + #[cfg(not(feature = "user-secrets"))] + { + let _ = resolve_secret_placeholders_in_value; + Ok(wire_args) + } +} + +impl ToolPipeline { /// Handle streaming results async fn handle_streaming_results( &self, diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index bbfbbd5e71..4582a48964 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -282,6 +282,11 @@ impl PathManager { self.user_root.join("data") } + /// Directory that holds the encrypted user-secrets vault files. + pub fn user_secrets_dir(&self) -> PathBuf { + self.user_data_dir() + } + /// User-level managed model resources shared across workspaces. pub fn user_models_dir(&self) -> PathBuf { self.user_data_dir().join("models") diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 2f0bc52fe0..0b7a0822f7 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -61,6 +61,8 @@ mod runtime_ownership_tests; pub mod service; // Workspace, Config, FileSystem, Terminal, Git #[cfg(feature = "agent-runtime")] pub(crate) mod service_agent_runtime; +#[cfg(feature = "user-secrets")] +pub mod user_secrets; pub mod util; // General types, errors, helper functions // Re-export debug_log from infrastructure for backward compatibility. diff --git a/src/crates/assembly/core/src/user_secrets.rs b/src/crates/assembly/core/src/user_secrets.rs new file mode 100644 index 0000000000..8fc423fc7a --- /dev/null +++ b/src/crates/assembly/core/src/user_secrets.rs @@ -0,0 +1,64 @@ +//! User secret variables (`{{name}}`) facade for settings CRUD and tool resolution. +//! +//! Values live in an AES-GCM vault under the user data directory. Chat / model +//! history keep placeholders unchanged; only tool-argument resolution loads +//! plaintext on the executing host. + +use crate::infrastructure::app_paths::path_manager::{get_path_manager_arc, PathManager}; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_core_types::{UserSecretSummary, UserSecretUpsert}; +use bitfun_services_core::user_secrets::UserSecretsVault; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +static GLOBAL_USER_SECRETS: OnceLock> = OnceLock::new(); + +fn vault_for(path_manager: &PathManager) -> Arc { + Arc::new(UserSecretsVault::new(path_manager.user_secrets_dir())) +} + +/// Initialize the global vault (called once at desktop/CLI startup). +pub fn initialize_global_user_secrets(path_manager: Arc) { + let _ = GLOBAL_USER_SECRETS.set(vault_for(path_manager.as_ref())); +} + +fn global_vault() -> BitFunResult> { + if let Some(vault) = GLOBAL_USER_SECRETS.get() { + return Ok(vault.clone()); + } + // Fall back to the global PathManager when startup did not initialize yet + // (tests / late callers). + let path_manager = get_path_manager_arc(); + let vault = vault_for(path_manager.as_ref()); + let _ = GLOBAL_USER_SECRETS.set(Arc::clone(&vault)); + Ok(GLOBAL_USER_SECRETS.get().cloned().unwrap_or(vault)) +} + +pub async fn list_user_secrets() -> BitFunResult> { + global_vault()? + .list() + .await + .map_err(|e| BitFunError::service(e.to_string())) +} + +pub async fn upsert_user_secret(request: UserSecretUpsert) -> BitFunResult { + global_vault()? + .upsert(&request.name, &request.value) + .await + .map_err(|e| BitFunError::service(e.to_string())) +} + +pub async fn delete_user_secret(name: &str) -> BitFunResult { + global_vault()? + .delete(name) + .await + .map_err(|e| BitFunError::service(e.to_string())) +} + +/// Load plaintext map for tool-argument resolution only. Do not expose to UI. +pub async fn load_user_secret_values() -> BitFunResult> { + global_vault()? + .load_all_values() + .await + .map_err(|e| BitFunError::service(e.to_string())) +} diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 8766e5b8ea..837da08e39 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -12,6 +12,7 @@ pub mod session_usage; pub mod speech; pub mod surface; pub mod tool_image_attachment; +pub mod user_secret; pub mod worktree; pub use ai::{ @@ -43,6 +44,7 @@ pub use surface::{ RuntimeArtifactKind, RuntimeArtifactRef, SurfaceKind, ThreadEnvironment, ThreadEnvironmentKind, }; pub use tool_image_attachment::ToolImageAttachment; +pub use user_secret::{is_valid_user_secret_name, UserSecretSummary, UserSecretUpsert}; pub use worktree::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, diff --git a/src/crates/contracts/core-types/src/user_secret.rs b/src/crates/contracts/core-types/src/user_secret.rs new file mode 100644 index 0000000000..0bdba43f29 --- /dev/null +++ b/src/crates/contracts/core-types/src/user_secret.rs @@ -0,0 +1,59 @@ +//! User-defined secret variables used as `{{name}}` placeholders in chat. +//! +//! Values never appear in read models. List/summary APIs expose only names and +//! timestamps so settings UIs and peers cannot read plaintext. + +use serde::{Deserialize, Serialize}; + +/// Allowed secret name: `[A-Za-z_][A-Za-z0-9_]*`. +pub fn is_valid_user_secret_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Secret-safe listing row. Never includes the value. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UserSecretSummary { + pub name: String, + /// Unix millis when the secret was last written. + pub updated_at: i64, +} + +/// Write-only upsert. Empty `value` is rejected by the host. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSecretUpsert { + pub name: String, + pub value: String, +} + +impl std::fmt::Debug for UserSecretUpsert { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UserSecretUpsert") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_secret_names() { + assert!(is_valid_user_secret_name("api_token")); + assert!(is_valid_user_secret_name("_private")); + assert!(is_valid_user_secret_name("A1")); + assert!(!is_valid_user_secret_name("")); + assert!(!is_valid_user_secret_name("1bad")); + assert!(!is_valid_user_secret_name("has-dash")); + assert!(!is_valid_user_secret_name("has space")); + assert!(!is_valid_user_secret_name("中文")); + } +} diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index 4bbdaf97ec..c142c246c4 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -18,6 +18,7 @@ pub mod input_validator; #[cfg(feature = "mcp-bridge")] pub mod mcp_tool_bridge; pub mod permission_intent; +pub mod secret_placeholders; pub mod tool_execution_presentation; pub mod tool_result_storage; pub mod tool_snapshot; @@ -108,6 +109,10 @@ pub use mcp_tool_bridge::{ MCP_TOOL_DELIMITER, MCP_TOOL_PREFIX, }; pub use permission_intent::PermissionIntent; +pub use secret_placeholders::{ + resolve_secret_placeholders_in_text, resolve_secret_placeholders_in_value, + text_contains_secret_placeholder, SecretPlaceholderError, +}; pub use tool_execution_presentation::{ build_invalid_tool_call_error_message, build_normal_tool_json_repair_notice, build_permission_denied_tool_presentation, build_tool_call_truncation_recovery_notice, diff --git a/src/crates/execution/tool-contracts/src/secret_placeholders.rs b/src/crates/execution/tool-contracts/src/secret_placeholders.rs new file mode 100644 index 0000000000..b6761b18b5 --- /dev/null +++ b/src/crates/execution/tool-contracts/src/secret_placeholders.rs @@ -0,0 +1,188 @@ +//! Resolve `{{secret_name}}` placeholders in tool-call JSON arguments. +//! +//! This helper is intentionally pure: callers supply the secret map. Chat +//! messages and model-visible history must keep placeholders unchanged; only +//! the tool-execution argument path should call this. + +use bitfun_core_types::is_valid_user_secret_name; +use serde_json::Value; +use std::collections::HashMap; + +/// Error when a placeholder cannot be resolved for tool execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecretPlaceholderError { + pub name: String, +} + +impl std::fmt::Display for SecretPlaceholderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Unknown or unset secret variable '{{{{{}}}}}'. Add it in Settings → Secrets.", + self.name + ) + } +} + +impl std::error::Error for SecretPlaceholderError {} + +/// Walk `value` and replace every `{{name}}` in string leaves using `secrets`. +/// +/// - Placeholders whose name is a valid secret id but missing from `secrets` +/// fail closed. +/// - Text that does not match `{{valid_name}}` is left unchanged. +/// - Non-string JSON nodes are walked recursively; structure is preserved. +pub fn resolve_secret_placeholders_in_value( + value: &Value, + secrets: &HashMap, +) -> Result { + match value { + Value::String(text) => Ok(Value::String(resolve_secret_placeholders_in_text( + text, secrets, + )?)), + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(resolve_secret_placeholders_in_value(item, secrets)?); + } + Ok(Value::Array(out)) + } + Value::Object(map) => { + let mut out = serde_json::Map::new(); + for (key, child) in map { + out.insert( + key.clone(), + resolve_secret_placeholders_in_value(child, secrets)?, + ); + } + Ok(Value::Object(out)) + } + other => Ok(other.clone()), + } +} + +/// Replace `{{name}}` occurrences in a single string. +pub fn resolve_secret_placeholders_in_text( + text: &str, + secrets: &HashMap, +) -> Result { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { + if let Some((name, end)) = parse_placeholder(text, i) { + let value = secrets.get(name).ok_or_else(|| SecretPlaceholderError { + name: name.to_string(), + })?; + out.push_str(value); + i = end; + continue; + } + } + // Copy one UTF-8 char safely. + let ch = text[i..].chars().next().expect("index inside string"); + out.push(ch); + i += ch.len_utf8(); + } + Ok(out) +} + +/// Returns `(name, end_index_exclusive)` when `text[start..]` begins with +/// `{{valid_name}}`. +fn parse_placeholder(text: &str, start: usize) -> Option<(&str, usize)> { + if !text[start..].starts_with("{{") { + return None; + } + let name_start = start + 2; + let rest = &text[name_start..]; + let name_end_rel = rest.find("}}")?; + let name = &rest[..name_end_rel]; + if !is_valid_user_secret_name(name) { + return None; + } + Some((name, name_start + name_end_rel + 2)) +} + +/// True when `text` contains at least one resolvable-shape placeholder. +pub fn text_contains_secret_placeholder(text: &str) -> bool { + let mut i = 0; + let bytes = text.as_bytes(); + while i < bytes.len() { + if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { + if parse_placeholder(text, i).is_some() { + return true; + } + } + let ch = text[i..].chars().next().expect("index inside string"); + i += ch.len_utf8(); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn map(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn resolves_nested_json_strings() { + let secrets = map(&[("token", "s3cret"), ("host", "example.com")]); + let input = json!({ + "url": "https://{{host}}/v1", + "headers": { "Authorization": "Bearer {{token}}" }, + "tags": ["plain", "x-{{token}}"] + }); + let resolved = resolve_secret_placeholders_in_value(&input, &secrets).unwrap(); + assert_eq!( + resolved, + json!({ + "url": "https://example.com/v1", + "headers": { "Authorization": "Bearer s3cret" }, + "tags": ["plain", "x-s3cret"] + }) + ); + } + + #[test] + fn unknown_secret_fails_closed() { + let secrets = map(&[("token", "s3cret")]); + let err = resolve_secret_placeholders_in_text("use {{missing}}", &secrets).unwrap_err(); + assert_eq!(err.name, "missing"); + assert!(err.to_string().contains("{{{{missing}}}}") || err.to_string().contains("missing")); + } + + #[test] + fn invalid_placeholder_shape_is_left_alone() { + let secrets = map(&[]); + let text = "keep {{bad-name}} and {single} and {{}}"; + assert_eq!( + resolve_secret_placeholders_in_text(text, &secrets).unwrap(), + text + ); + } + + #[test] + fn detects_placeholder_presence() { + assert!(text_contains_secret_placeholder("hi {{api_key}}")); + assert!(!text_contains_secret_placeholder("hi {{bad-name}}")); + assert!(!text_contains_secret_placeholder("no placeholders")); + } + + #[test] + fn history_shape_is_unchanged_when_not_resolved() { + // Document the invariant: callers must keep the original Value for + // persistence; this test just shows resolve returns a new tree. + let secrets = map(&[("x", "1")]); + let original = json!({ "cmd": "echo {{x}}" }); + let _resolved = resolve_secret_placeholders_in_value(&original, &secrets).unwrap(); + assert_eq!(original, json!({ "cmd": "echo {{x}}" })); + } +} diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 455119c07e..d829ae68ad 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -23,6 +23,8 @@ serde_yaml = { workspace = true, optional = true } base64 = { workspace = true, optional = true } chrono = { workspace = true, optional = true } chrono-tz = { workspace = true, optional = true } +aes-gcm = { workspace = true, optional = true } +rand = { workspace = true, optional = true } git2 = { workspace = true, optional = true } dunce = { workspace = true, optional = true } zip = { workspace = true, optional = true } @@ -148,6 +150,16 @@ permission = [ dispatch-workspace = ["dep:anyhow", "dep:sha2"] session-git = ["local-storage", "dep:git2"] workspace-text-runtime = ["dep:tokio", "tokio/rt"] +user-secrets = [ + "dep:aes-gcm", + "dep:base64", + "dep:bitfun-core-types", + "dep:rand", + "dep:tokio", + "tokio/fs", + "tokio/rt", + "tokio/sync", +] [dev-dependencies] filetime = { workspace = true } diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index e744185412..820094277f 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -49,6 +49,8 @@ pub mod storage_cleanup; pub mod system; #[cfg(feature = "local-storage")] pub mod token_usage; +#[cfg(feature = "user-secrets")] +pub mod user_secrets; #[cfg(feature = "workspace-runtime")] pub mod workspace; #[cfg(feature = "workspace-identity")] diff --git a/src/crates/services/services-core/src/user_secrets.rs b/src/crates/services/services-core/src/user_secrets.rs new file mode 100644 index 0000000000..45738d66ef --- /dev/null +++ b/src/crates/services/services-core/src/user_secrets.rs @@ -0,0 +1,300 @@ +//! Encrypted file-backed vault for user secret variables (`{{name}}`). +//! +//! Layout (under the product user data directory): +//! - `.user_secrets_vault.key` — 32-byte AES key (0600 on Unix) +//! - `user_secrets_vault.json` — base64 ciphertext map keyed by secret name +//! +//! List APIs expose names and timestamps only. Plaintext is loaded only for +//! tool-argument resolution on the executing host. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use bitfun_core_types::{is_valid_user_secret_name, UserSecretSummary}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::Mutex; + +const NONCE_LEN: usize = 12; +pub const USER_SECRETS_VAULT_FILE: &str = "user_secrets_vault.json"; +pub const USER_SECRETS_KEY_FILE: &str = ".user_secrets_vault.key"; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum UserSecretsError { + #[error("{0}")] + InvalidName(String), + #[error("{0}")] + InvalidValue(String), + #[error("{0}")] + Io(String), + #[error("{0}")] + Crypto(String), +} + +#[derive(Serialize, Deserialize, Default)] +struct VaultFile { + /// name -> { ciphertext, updated_at } + entries: HashMap, +} + +#[derive(Serialize, Deserialize)] +struct VaultEntry { + ciphertext: String, + updated_at: i64, +} + +/// AES-GCM vault for user-defined secret variables. +pub struct UserSecretsVault { + key_path: PathBuf, + vault_path: PathBuf, + lock: Mutex<()>, +} + +impl UserSecretsVault { + pub fn new(data_dir: impl AsRef) -> Self { + let data_dir = data_dir.as_ref(); + Self { + key_path: data_dir.join(USER_SECRETS_KEY_FILE), + vault_path: data_dir.join(USER_SECRETS_VAULT_FILE), + lock: Mutex::new(()), + } + } + + pub fn vault_path(&self) -> &Path { + &self.vault_path + } + + async fn ensure_key(&self) -> Result<[u8; 32], UserSecretsError> { + if self.key_path.exists() { + let bytes = tokio::fs::read(&self.key_path) + .await + .map_err(|e| UserSecretsError::Io(format!("read user secrets vault key: {e}")))?; + if bytes.len() != 32 { + return Err(UserSecretsError::Crypto( + "invalid user secrets vault key length".into(), + )); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + return Ok(key); + } + if let Some(parent) = self.key_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| UserSecretsError::Io(format!("create secrets vault dir: {e}")))?; + } + let mut key = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + tokio::fs::write(&self.key_path, key.as_slice()) + .await + .map_err(|e| UserSecretsError::Io(format!("write user secrets vault key: {e}")))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + std::fs::set_permissions(&self.key_path, std::fs::Permissions::from_mode(0o600)); + } + Ok(key) + } + + fn encrypt(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| UserSecretsError::Crypto(format!("aes init: {e}")))?; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let ct = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext.as_bytes()) + .map_err(|e| UserSecretsError::Crypto(format!("encrypt: {e}")))?; + let mut blob = Vec::with_capacity(NONCE_LEN + ct.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ct); + Ok(B64.encode(blob)) + } + + fn decrypt(key: &[u8; 32], blob_b64: &str) -> Result { + let blob = B64 + .decode(blob_b64) + .map_err(|e| UserSecretsError::Crypto(format!("base64 decode: {e}")))?; + if blob.len() <= NONCE_LEN { + return Err(UserSecretsError::Crypto( + "user secrets vault entry too short".into(), + )); + } + let (nonce, ct) = blob.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| UserSecretsError::Crypto(format!("aes init: {e}")))?; + let pt = cipher + .decrypt(Nonce::from_slice(nonce), ct) + .map_err(|e| UserSecretsError::Crypto(format!("decrypt: {e}")))?; + String::from_utf8(pt).map_err(|e| UserSecretsError::Crypto(format!("utf8: {e}"))) + } + + async fn read_file(&self) -> Result { + if !self.vault_path.exists() { + return Ok(VaultFile::default()); + } + let s = tokio::fs::read_to_string(&self.vault_path) + .await + .map_err(|e| UserSecretsError::Io(format!("read user secrets vault: {e}")))?; + Ok(serde_json::from_str(&s).unwrap_or_default()) + } + + async fn write_file(&self, file: &VaultFile) -> Result<(), UserSecretsError> { + if let Some(parent) = self.vault_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| UserSecretsError::Io(format!("create secrets vault dir: {e}")))?; + } + let body = serde_json::to_string_pretty(file) + .map_err(|e| UserSecretsError::Io(format!("serialize secrets vault: {e}")))?; + tokio::fs::write(&self.vault_path, body) + .await + .map_err(|e| UserSecretsError::Io(format!("write user secrets vault: {e}")))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + std::fs::set_permissions(&self.vault_path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) + } + + fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } + + /// List secret names and timestamps (never values). + pub async fn list(&self) -> Result, UserSecretsError> { + let _g = self.lock.lock().await; + let file = self.read_file().await?; + let mut rows: Vec = file + .entries + .into_iter() + .map(|(name, entry)| UserSecretSummary { + name, + updated_at: entry.updated_at, + }) + .collect(); + rows.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(rows) + } + + /// Upsert a secret value. Rejects invalid names and empty values. + pub async fn upsert( + &self, + name: &str, + value: &str, + ) -> Result { + if !is_valid_user_secret_name(name) { + return Err(UserSecretsError::InvalidName(format!( + "Invalid secret name '{name}'. Use letters, digits, and underscore; must start with a letter or underscore." + ))); + } + if value.is_empty() { + return Err(UserSecretsError::InvalidValue( + "Secret value must not be empty".into(), + )); + } + let _g = self.lock.lock().await; + let key = self.ensure_key().await?; + let mut file = self.read_file().await?; + let updated_at = Self::now_ms(); + let ciphertext = Self::encrypt(&key, value)?; + file.entries.insert( + name.to_string(), + VaultEntry { + ciphertext, + updated_at, + }, + ); + self.write_file(&file).await?; + Ok(UserSecretSummary { + name: name.to_string(), + updated_at, + }) + } + + /// Delete a secret. Returns true when an entry was removed. + pub async fn delete(&self, name: &str) -> Result { + let _g = self.lock.lock().await; + let mut file = self.read_file().await?; + let removed = file.entries.remove(name).is_some(); + if removed { + self.write_file(&file).await?; + } + Ok(removed) + } + + /// Load all secrets as name → plaintext for tool-argument resolution. + pub async fn load_all_values(&self) -> Result, UserSecretsError> { + let _g = self.lock.lock().await; + if !self.vault_path.exists() || !self.key_path.exists() { + return Ok(HashMap::new()); + } + let key = self.ensure_key().await?; + let file = self.read_file().await?; + let mut out = HashMap::new(); + for (name, entry) in file.entries { + match Self::decrypt(&key, &entry.ciphertext) { + Ok(value) => { + out.insert(name, value); + } + Err(error) => { + log::warn!("Failed to decrypt user secret '{}': {}", name, error); + } + } + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn round_trips_secrets_and_lists_without_values() { + let dir = tempfile::tempdir().unwrap(); + let vault = UserSecretsVault::new(dir.path()); + + let summary = vault.upsert("api_token", "s3cret-value").await.unwrap(); + assert_eq!(summary.name, "api_token"); + + let listed = vault.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "api_token"); + + let values = vault.load_all_values().await.unwrap(); + assert_eq!( + values.get("api_token").map(String::as_str), + Some("s3cret-value") + ); + + // Ciphertext file must not contain plaintext. + let raw = tokio::fs::read_to_string(vault.vault_path()).await.unwrap(); + assert!(!raw.contains("s3cret-value")); + + assert!(vault.delete("api_token").await.unwrap()); + assert!(vault.list().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn rejects_invalid_names_and_empty_values() { + let dir = tempfile::tempdir().unwrap(); + let vault = UserSecretsVault::new(dir.path()); + assert!(matches!( + vault.upsert("bad-name", "x").await, + Err(UserSecretsError::InvalidName(_)) + )); + assert!(matches!( + vault.upsert("ok", "").await, + Err(UserSecretsError::InvalidValue(_)) + )); + } +} diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index db0f4f7b8d..c76a074a7a 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -28,6 +28,7 @@ import { KeyboardShortcutsTab, McpToolsConfig, MemoriesConfig, + SecretsConfig, QuickActionsConfig, ReviewConfig, SessionPermissionsConfig, @@ -71,6 +72,7 @@ function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { case 'voice-input': return VoiceInputConfig; case 'review': return ReviewConfig; case 'memories': return MemoriesConfig; + case 'secrets': return SecretsConfig; case 'mcp-tools': return McpToolsConfig; case 'external-sources': return ExternalSourcesConfig; // Hooks are part of the external AI applications surface. diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index d57f18cf19..f6e2707d96 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -18,6 +18,7 @@ export type ConfigTab = | 'voice-input' | 'review' | 'memories' + | 'secrets' | 'mcp-tools' | 'external-sources' | 'hooks' @@ -256,6 +257,22 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'knowledge', ], }, + { + id: 'secrets', + labelKey: 'configCenter.tabs.secrets', + descriptionKey: 'configCenter.tabDescriptions.secrets', + keywords: [ + 'secret', + 'secrets', + 'variable', + 'variables', + 'token', + 'password', + 'credential', + 'api key', + 'placeholder', + ], + }, { id: 'external-sources', labelKey: 'configCenter.tabs.externalSources', diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts index 3cda2c3427..9ee79a98b1 100644 --- a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -11,6 +11,7 @@ const loadBasicsConfig = () => import('../../../infrastructure/config/components const loadAppearanceConfig = () => import('../../../infrastructure/config/components/AppearanceConfig'); const loadReviewConfig = () => import('../../../infrastructure/config/components/ReviewConfig'); const loadMemoriesConfig = () => import('../../../infrastructure/config/components/MemoriesConfig'); +const loadSecretsConfig = () => import('../../../infrastructure/config/components/SecretsConfig'); const loadQuickActionsConfig = () => import('../../../infrastructure/config/components/QuickActionsConfig'); const loadVoiceInputConfig = () => import('../../../infrastructure/config/components/VoiceInputConfig'); const loadArchivedSessionsConfig = () => import('./components/ArchivedSessionsConfig'); @@ -28,6 +29,7 @@ export const BasicsConfig = lazy(loadBasicsConfig); export const AppearanceConfig = lazy(loadAppearanceConfig); export const ReviewConfig = lazy(loadReviewConfig); export const MemoriesConfig = lazy(loadMemoriesConfig); +export const SecretsConfig = lazy(loadSecretsConfig); export const QuickActionsConfig = lazy(loadQuickActionsConfig); export const VoiceInputConfig = lazy(loadVoiceInputConfig); export const ArchivedSessionsConfig = lazy(loadArchivedSessionsConfig); @@ -58,6 +60,7 @@ const SETTINGS_CONTENT_LOADERS: Partial Promise 'voice-input': loadVoiceInputConfig, review: loadReviewConfig, memories: loadMemoriesConfig, + secrets: loadSecretsConfig, 'mcp-tools': loadMcpToolsConfig, 'external-sources': loadExternalSourcesConfig, // Hooks no longer have a standalone GUI page; a stale deep link resolves to diff --git a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts index 42ef96dcae..4ff602f232 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts @@ -33,6 +33,7 @@ export const SETTINGS_TAB_I18N_NAMESPACES: Record { + try { + return await api.invoke('list_user_secrets'); + } catch (error) { + throw createTauriCommandError('list_user_secrets', error); + } + } + + async upsert(name: string, value: string): Promise { + try { + return await api.invoke('upsert_user_secret', { + request: { name, value }, + }); + } catch (error) { + throw createTauriCommandError('upsert_user_secret', error, { name }); + } + } + + async delete(name: string): Promise { + try { + return await api.invoke('delete_user_secret', { + request: { name }, + }); + } catch (error) { + throw createTauriCommandError('delete_user_secret', error, { name }); + } + } +} + +export const userSecretsAPI = new UserSecretsAPI(); diff --git a/src/web-ui/src/infrastructure/config/components/SecretsConfig.scss b/src/web-ui/src/infrastructure/config/components/SecretsConfig.scss new file mode 100644 index 0000000000..232f1ddee3 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SecretsConfig.scss @@ -0,0 +1,34 @@ +.bitfun-secrets-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2, 8px); +} + +.bitfun-secrets-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3, 12px); + padding: var(--space-2, 8px) 0; + border-bottom: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.08)); +} + +.bitfun-secrets-list__meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.bitfun-secrets-list__name { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.9rem; +} + +.bitfun-secrets-list__hint { + color: var(--text-secondary, #666); + font-size: 0.8rem; +} diff --git a/src/web-ui/src/infrastructure/config/components/SecretsConfig.tsx b/src/web-ui/src/infrastructure/config/components/SecretsConfig.tsx new file mode 100644 index 0000000000..f668bba101 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SecretsConfig.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, Trash2 } from 'lucide-react'; +import { Button, IconButton, Input, confirmDanger } from '@/component-library'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { + userSecretsAPI, + type UserSecretSummary, +} from '@/infrastructure/api/service-api/UserSecretsAPI'; +import { + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageLoading, + ConfigPageMessage, + ConfigPageRow, + ConfigPageSection, +} from './common'; +import './SecretsConfig.scss'; + +const log = createLogger('SecretsConfig'); + +const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const SecretsConfig: React.FC = () => { + const { t } = useTranslation('settings/secrets'); + const { success, error: notifyError } = useNotification(); + const [loading, setLoading] = useState(true); + const [secrets, setSecrets] = useState([]); + const [name, setName] = useState(''); + const [value, setValue] = useState(''); + const [saving, setSaving] = useState(false); + const [nameError, setNameError] = useState(); + + const refresh = useCallback(async () => { + try { + const rows = await userSecretsAPI.list(); + setSecrets(rows); + } catch (err) { + log.error('Failed to list user secrets', err); + notifyError(t('errors.loadFailed')); + } finally { + setLoading(false); + } + }, [notifyError, t]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const validateName = (next: string): boolean => { + if (!next.trim()) { + setNameError(t('errors.nameRequired')); + return false; + } + if (!NAME_PATTERN.test(next.trim())) { + setNameError(t('errors.nameInvalid')); + return false; + } + setNameError(undefined); + return true; + }; + + const handleSave = async () => { + const trimmed = name.trim(); + if (!validateName(trimmed)) return; + if (!value) { + notifyError(t('errors.valueRequired')); + return; + } + setSaving(true); + try { + await userSecretsAPI.upsert(trimmed, value); + setName(''); + setValue(''); + success(t('toast.saved', { name: trimmed })); + await refresh(); + } catch (err) { + log.error('Failed to upsert user secret', err); + notifyError(err instanceof Error ? err.message : t('errors.saveFailed')); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (secretName: string) => { + const confirmed = await confirmDanger( + t('delete.title'), + t('delete.message', { name: secretName }), + { + confirmText: t('delete.confirm'), + cancelText: t('delete.cancel'), + } + ); + if (!confirmed) return; + try { + await userSecretsAPI.delete(secretName); + success(t('toast.deleted', { name: secretName })); + await refresh(); + } catch (err) { + log.error('Failed to delete user secret', err); + notifyError(t('errors.deleteFailed')); + } + }; + + if (loading) { + return ; + } + + return ( + + + + + + { + setName(e.target.value); + if (nameError) validateName(e.target.value); + }} + placeholder={t('fields.name.placeholder')} + error={Boolean(nameError)} + errorMessage={nameError} + autoComplete="off" + spellCheck={false} + /> + + + setValue(e.target.value)} + placeholder={t('fields.value.placeholder')} + autoComplete="new-password" + spellCheck={false} + /> + + + + + + + + {secrets.length === 0 ? ( + + ) : ( +
    + {secrets.map((secret) => ( +
  • +
    + {`{{${secret.name}}}`} + {t('list.valueHidden')} +
    + void handleDelete(secret.name)} + aria-label={t('actions.delete', { name: secret.name })} + > + + +
  • + ))} +
+ )} +
+
+
+ ); +}; + +export default SecretsConfig; diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index db9d7a45f3..33cd0958b0 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -37,6 +37,7 @@ export const ALL_NAMESPACES = [ 'settings/mcp', 'settings/mcp-tools', 'settings/memories', + 'settings/secrets', 'settings/quick-actions', 'settings/review', 'settings/session-config', diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 07190c7d76..b96ae08dd0 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -14,6 +14,7 @@ "quickActions": [], "review": [], "memories": [], + "secrets": [], "usageStatistics": [] }, "tabDescriptions": { @@ -28,6 +29,7 @@ "voiceInput": "Local microphone input and speech-to-text model.", "review": "Review strategy, coverage depth, capacity, cost, and latency controls.", "memories": "Automatic memory generation, injection, retention windows, and memory models.", + "secrets": "Secret variables referenced in chat as {{name}}; resolved only when tools run.", "mcpTools": "MCP servers and tool integrations.", "externalSources": "Load compatible commands and extensions from other AI applications.", "hooks": "Run your own commands at Agent lifecycle points. Codex-compatible.", @@ -54,6 +56,7 @@ "voiceInput": "Voice Input", "review": "Review", "memories": "Memory", + "secrets": "Secrets", "skills": "Skills", "mcpTools": "MCP", "externalSources": "External AI Apps", diff --git a/src/web-ui/src/locales/en-US/settings/secrets.json b/src/web-ui/src/locales/en-US/settings/secrets.json new file mode 100644 index 0000000000..c7ea1c3fbf --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/secrets.json @@ -0,0 +1,54 @@ +{ + "title": "Secrets", + "subtitle": "Store secret values and reference them in chat as {{name}}. The agent never sees the value; it is resolved only when a tool runs.", + "sections": { + "add": { + "title": "Add or update", + "description": "Names are case-sensitive. Saving the same name replaces the previous value." + }, + "list": { + "title": "Stored secrets", + "description": "Values stay encrypted on this device and are never shown again after save." + } + }, + "fields": { + "name": { + "label": "Name", + "description": "Letters, digits, and underscore. Must start with a letter or underscore.", + "placeholder": "api_token" + }, + "value": { + "label": "Value", + "description": "Write-only. Paste the secret here; it will not be readable from Settings afterward.", + "placeholder": "••••••••" + }, + "usageHint": "In chat, write {{api_token}}. The message body keeps that placeholder; tools receive the real value." + }, + "actions": { + "save": "Save secret", + "saving": "Saving…", + "delete": "Delete {{name}}" + }, + "list": { + "empty": "No secrets yet.", + "valueHidden": "Value hidden" + }, + "delete": { + "title": "Delete secret", + "message": "Delete {{name}}? Chat placeholders that still use it will fail at tool time until you recreate it.", + "confirm": "Delete", + "cancel": "Cancel" + }, + "toast": { + "saved": "Saved {{name}}", + "deleted": "Deleted {{name}}" + }, + "errors": { + "loadFailed": "Failed to load secrets.", + "saveFailed": "Failed to save secret.", + "deleteFailed": "Failed to delete secret.", + "nameRequired": "Name is required.", + "nameInvalid": "Use letters, digits, and underscore only.", + "valueRequired": "Value is required." + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 803074badd..4401e6e7bf 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -35,6 +35,11 @@ "长期记忆", "学习" ], + "secrets": [ + "密钥", + "密钥变量", + "secret" + ], "usageStatistics": [ "调用统计", "用量", @@ -56,6 +61,7 @@ "voiceInput": "本地麦克风输入与语音转文字模型。", "review": "Review 策略、覆盖深度、容量、成本和耗时控制。", "memories": "自动记忆生成、注入、整理窗口与记忆模型。", + "secrets": "在对话中用 {{name}} 引用的密钥变量;仅在工具执行时解析。", "mcpTools": "MCP 服务器与工具集成。", "externalSources": "加载其他 AI 应用中兼容的命令与扩展。", "hooks": "在 Agent 生命周期节点运行你自己的命令,与 Codex Hooks 兼容。", @@ -82,6 +88,7 @@ "voiceInput": "语音输入", "review": "审核", "memories": "记忆", + "secrets": "密钥变量", "skills": "技能", "mcpTools": "MCP", "externalSources": "外部 AI 应用", diff --git a/src/web-ui/src/locales/zh-CN/settings/secrets.json b/src/web-ui/src/locales/zh-CN/settings/secrets.json new file mode 100644 index 0000000000..3a5e4e21cd --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/secrets.json @@ -0,0 +1,54 @@ +{ + "title": "密钥变量", + "subtitle": "在设置中保存密钥,对话里用 {{name}} 引用。Agent 看不到明文,只有工具真正执行时才会解析。", + "sections": { + "add": { + "title": "添加或更新", + "description": "名称区分大小写。使用同名会覆盖旧值。" + }, + "list": { + "title": "已保存的密钥", + "description": "值在本机加密存储,保存后无法在设置中再次查看。" + } + }, + "fields": { + "name": { + "label": "名称", + "description": "仅字母、数字和下划线,且须以字母或下划线开头。", + "placeholder": "api_token" + }, + "value": { + "label": "值", + "description": "只写不读。粘贴密钥后,设置页不会再展示明文。", + "placeholder": "••••••••" + }, + "usageHint": "在对话中写 {{api_token}}。消息正文始终保留占位符;工具调用时才会替换为真实值。" + }, + "actions": { + "save": "保存密钥", + "saving": "保存中…", + "delete": "删除 {{name}}" + }, + "list": { + "empty": "还没有密钥。", + "valueHidden": "值已隐藏" + }, + "delete": { + "title": "删除密钥", + "message": "删除 {{name}}?仍引用它的对话占位符会在工具执行时失败,直到你重新创建。", + "confirm": "删除", + "cancel": "取消" + }, + "toast": { + "saved": "已保存 {{name}}", + "deleted": "已删除 {{name}}" + }, + "errors": { + "loadFailed": "加载密钥失败。", + "saveFailed": "保存密钥失败。", + "deleteFailed": "删除密钥失败。", + "nameRequired": "名称不能为空。", + "nameInvalid": "只能使用字母、数字和下划线。", + "valueRequired": "值不能为空。" + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 2a1ba87f85..1c8993e76a 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -35,6 +35,11 @@ "長期記憶", "學習" ], + "secrets": [ + "密鑰", + "密鑰變數", + "secret" + ], "usageStatistics": [ "調用統計", "用量", @@ -54,6 +59,7 @@ "sessionPermissions": "工具權限、執行方式,以及桌面和瀏覽器控制。", "review": "Review 策略、覆蓋深度、容量、成本和耗時控制。", "memories": "自動記憶生成、注入、整理窗口與記憶模型。", + "secrets": "在對話中用 {{name}} 引用的密鑰變數;僅在工具執行時解析。", "mcpTools": "MCP 伺服器與工具集成。", "externalSources": "載入其他 AI 應用中相容的命令與擴充。", "hooks": "在 Agent 生命週期節點執行你自己的命令,與 Codex Hooks 相容。", @@ -80,6 +86,7 @@ "sessionPermissions": "權限管理", "review": "審核", "memories": "記憶", + "secrets": "密鑰變數", "skills": "技能", "mcpTools": "MCP", "externalSources": "外部 AI 應用", diff --git a/src/web-ui/src/locales/zh-TW/settings/secrets.json b/src/web-ui/src/locales/zh-TW/settings/secrets.json new file mode 100644 index 0000000000..c9838e13e5 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/secrets.json @@ -0,0 +1,54 @@ +{ + "title": "密鑰變數", + "subtitle": "在設定中保存密鑰,對話裡用 {{name}} 引用。Agent 看不到明文,只有工具真正執行時才會解析。", + "sections": { + "add": { + "title": "新增或更新", + "description": "名稱區分大小寫。使用同名會覆蓋舊值。" + }, + "list": { + "title": "已保存的密鑰", + "description": "值在本機加密儲存,保存後無法在設定中再次查看。" + } + }, + "fields": { + "name": { + "label": "名稱", + "description": "僅字母、數字和底線,且須以字母或底線開頭。", + "placeholder": "api_token" + }, + "value": { + "label": "值", + "description": "只寫不讀。貼上密鑰後,設定頁不會再顯示明文。", + "placeholder": "••••••••" + }, + "usageHint": "在對話中寫 {{api_token}}。訊息本文始終保留佔位符;工具呼叫時才會替換為真實值。" + }, + "actions": { + "save": "保存密鑰", + "saving": "保存中…", + "delete": "刪除 {{name}}" + }, + "list": { + "empty": "還沒有密鑰。", + "valueHidden": "值已隱藏" + }, + "delete": { + "title": "刪除密鑰", + "message": "刪除 {{name}}?仍引用它的對話佔位符會在工具執行時失敗,直到你重新建立。", + "confirm": "刪除", + "cancel": "取消" + }, + "toast": { + "saved": "已保存 {{name}}", + "deleted": "已刪除 {{name}}" + }, + "errors": { + "loadFailed": "載入密鑰失敗。", + "saveFailed": "保存密鑰失敗。", + "deleteFailed": "刪除密鑰失敗。", + "nameRequired": "名稱不能為空。", + "nameInvalid": "只能使用字母、數字和底線。", + "valueRequired": "值不能為空。" + } +}