Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,68 @@
[package]
name = "nomifun-gateway"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[features]
# P3-GW1 路线 A:网关浏览器工具(per-companion BrowserTool registry)。门控因为它拉入
# `nomi-browser`(→ chromiumoxide CDP 栈 + 受管 Chromium)——无浏览器的 headless 宿主不应被迫依赖。
# 由 `nomifun-app` 的同名 feature 经 `nomifun-gateway/browser-use` 向下传导;关时网关不暴露
# nomi_browser_* 工具(dispatch 走 unknown toolschema 不注册)。依赖方向:gateway → nomi-browser
# nomi-browser 不反依赖 gateway,已核对无环)。
browser-use = ["dep:nomi-browser", "dep:nomi-types", "dep:nomi-config", "dep:nomi-tools", "dep:nomifun-secret", "dep:futures"]
# Gateway computer-use tools (single shared desktop ComputerTool). Forwarded by
# `nomifun-app`'s `computer-use` feature via `nomifun-gateway/computer-use`; off
# on headless/web hosts (which never pull the native screen/input/UIA stack).
# Reuses the optional nomi-types/-config/-tools deps shared with browser-use.
computer-use = ["dep:nomi-computer", "dep:nomi-types", "dep:nomi-config", "dep:nomi-tools"]
[dependencies]
nomifun-common.workspace = true
nomifun-api-types.workspace = true
nomifun-conversation.workspace = true
nomifun-cron.workspace = true
nomifun-requirement.workspace = true
nomifun-companion.workspace = true
nomifun-ai-agent.workspace = true
nomifun-db.workspace = true
nomifun-terminal.workspace = true
nomifun-idmm.workspace = true
nomifun-knowledge.workspace = true
# System domain capabilities (settings / client preferences / providers / model fetch).
nomifun-system.workspace = true
# Channel domain capabilities (IM bot plugins / pairing / users / companion binding).
nomifun-channel.workspace = true
# Filesystem + shell-open domain capabilities.
nomifun-file.workspace = true
nomifun-shell.workspace = true
# MCP-server + extensions/hub/skills domain capabilities.
nomifun-mcp.workspace = true
nomifun-extension.workspace = true
# P3-GW1: per-companion BrowserTool registry(路线 A)。optional + feature 门控,关时不拉浏览器栈。
nomi-browser = { workspace = true, optional = true }
# Computer-use: shared desktop ComputerTool. optional + feature 门控,关时不拉 screen/input/UIA 栈。
nomi-computer = { workspace = true, optional = true }
# P3-X2: per-pet secret vault path resolver for gateway-driven secret:NAME (browser-use only).
nomifun-secret = { workspace = true, optional = true }
nomi-types = { workspace = true, optional = true }
nomi-config = { workspace = true, optional = true }
# `Tool` traitexecute/category)——facade BrowserTool 实现它,registry 经它驱动动作。
nomi-tools = { workspace = true, optional = true }
# CC: BrowserRegistry::execute_parallel 用 futures::future::join_all 并行多 key(异 key 真并发/同 key 串行)。
futures = { workspace = true, optional = true }
axum.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
# Capability registry: single-source JSON Schema generation from each capability's
# typed Request struct (same schemars major as rmcp 1.7 re-exports → identical shape
# for the MCP clients consuming tools/list). The bridge passes these schema maps
# straight through to `rmcp::model::Tool`, so only the gateway needs schemars.
schemars = "1"
tracing.workspace = true
dirs.workspace = true
[dev-dependencies]
reqwest.workspace = true
@@ -0,0 +1,646 @@
//! P3-GW1 (route A): a per-companion [`BrowserTool`] registry that lives in the
//! **main process** [`crate::deps::GatewayDeps`].
//!
//! ## Why a registry here (the route-A architecture)
//!
//! `GatewayDeps` is constructed in the main backend process; the bootstrap that
//! builds a session's `BrowserTool` runs in a *separate* agent/session process.
//! Route A does NOT migrate the engine across processes — it relies on the fact
//! that [`BrowserTool`] is **fully self-contained**:
//!
//! - `BrowserTool::with_data_dir(data_dir, headful).workspace(ws)` constructs the
//! facade WITHOUT launching anything (the engine is built lazily inside the
//! facade's own `Mutex` on the first action, and a launch failure is cached).
//! - so the gateway can simply OWN one `BrowserTool` per companion in the main
//! process. Each companion's tool spins up its own in-process CDP engine on its
//! first action — the same lazy mechanism the session bootstrap uses, just
//! anchored in the gateway instead of a session.
//!
//! No cross-process engine handle, no engine-ownership migration: the registry is
//! the engine's owner for gateway-driven browsing.
//!
//! ## Per-companion engine slot + serialization (X5); shared browser IDENTITY
//!
//! [`BrowserTool::is_concurrency_safe`] is `false` — observe ⊥ act and per-target
//! actions must be serialized. The registry gives each companion key its own
//! [`tokio::sync::Mutex`]; [`BrowserRegistry::execute`] holds that mutex for the
//! whole tool call, so the same companion's `observe`/`act`/`navigate` never run
//! concurrently. Different companion keys hold different mutexes (and different
//! Chrome processes / `user-data-dir`s), so they run independently.
//!
//! **User decision (去 per-pet 隔离): browser IDENTITY is globally shared.** The
//! per-companion *engine slot* (separate Chrome process + serialization mutex) is
//! kept — collapsing to one engine would turn per-companion serialization into a
//! global one, a behavior change we avoid. But every slot points at the **same
//! shared credential vault** (`nomifun_secret::pet_vault_path` now ignores its key
//! and routes to `{data_dir}/browser-secrets/shared`), so `secret:NAME` /
//! login / domain policy are SHARED across companions and sessions (consistent with
//! the unified-memory model). Per-companion slots isolate only the live Chrome
//! process, not the persisted identity.
//!
//! ## Workspace layout (默认 ④)
//!
//! Each key gets `{data_dir}/browser-profiles/{key}` as its workspace dir, so
//! gateway downloads (E4) land in a per-companion sandbox, never the user's real
//! Downloads. The key is the companion id when the caller carries one, else a
//! `conversation:<id>` fallback (a master/IM session driving a browser without a
//! companion binding still gets its own isolated tool).
//!
//! ## GW2 hook (left for the next task)
//!
//! Out-of-band approval of irreversible actions is GW2. GW1 wires the tool
//! exposure + execution path; the dispatch layer marks where an
//! `ApprovalTier::Irreversible` hit would be routed to the confirm channel. The
//! gateway-driven `BrowserTool` is constructed as a **non-bypassing** session
//! (`session_bypasses_approval = false`), so its own fail-closed redline gate does
//! NOT hard-deny — irreversible actions flow through to the engine today and will
//! be intercepted by the GW2 confirm hook once that task lands.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use nomi_browser::{BrowserSecretSource, BrowserTool, OUT_OF_BAND_CONFIRMED_KEY};
use nomi_config::config::BrowserConfig;
use nomi_tools::Tool;
use nomi_types::tool::ToolResult;
use serde_json::{Value, json};
use tokio::sync::Mutex as AsyncMutex;
/// One companion's browser slot: a lazily-engined [`BrowserTool`] plus the mutex
/// that serializes that companion's tool calls (X5).
struct CompanionBrowser {
tool: Arc<BrowserTool>,
/// Per-companion serialization gate. Held for the duration of a single
/// `execute` so observe/act/navigate for the SAME companion never overlap
/// (the facade engine is `is_concurrency_safe = false`).
lock: AsyncMutex<()>,
}
/// **P3-GW2**: a browser action held awaiting out-of-band approval. Stashed by the
/// dispatch layer when an action classifies as `ApprovalTier::Irreversible` in this
/// (auto-approving) gateway session, keyed by a synthetic `call_id` the phone/front-end
/// confirms. On approval, the registry re-issues `input` with the
/// [`OUT_OF_BAND_CONFIRMED_KEY`] sentinel injected so the facade's redline gate
/// releases it.
#[derive(Clone, Debug)]
pub struct PendingBrowserAction {
/// The registry key (companion / conversation) the action belongs to — the
/// engine it must run against once approved.
pub key: String,
/// The original, already-sanitized facade input (`{"action": "...", ...}`)
/// WITHOUT any out-of-band sentinel (the caller-supplied one is stripped before
/// stashing; the trusted one is injected only at resolve time).
pub input: Value,
}
/// **P3-GW2**: cap on actions awaiting out-of-band approval across all keys. A
/// driving agent that keeps triggering irreversible actions without the user ever
/// approving must not be able to grow the store without bound; past this, the
/// dispatch layer fails closed (denies + tells the model to retry after the queue
/// drains) rather than stashing.
const MAX_PENDING: usize = 64;
/// The per-companion [`BrowserTool`] registry held by [`crate::deps::GatewayDeps`]
/// (route A). Clone-cheap: the inner map is behind an `Arc`.
#[derive(Clone)]
pub struct BrowserRegistry {
/// Application data dir; per-companion workspaces hang under
/// `{data_dir}/browser-profiles/{key}`.
data_dir: PathBuf,
/// Whether to request a visible (headful) window. The engine forces headless
/// when no display is available regardless.
headful: bool,
/// PKG-1: bundled Chrome resource dir (Tauri resource dir). When `Some`, each
/// lazily-built slot tool prefers `<bundled_dir>/chrome-<platform>/...` over the
/// network download fallback. `None` (default / non-packaged) → unchanged
/// behavior (env > data_dir > download).
bundled_dir: Option<PathBuf>,
/// companion-key → slot. A `std::sync::Mutex` guards only the (fast) map
/// lookup/insert; the per-companion `AsyncMutex` inside the slot is what's
/// held across an await-bound tool call.
slots: Arc<std::sync::Mutex<HashMap<String, Arc<CompanionBrowser>>>>,
/// **P3-GW2**: actions awaiting out-of-band approval, keyed by the synthetic
/// `call_id` the phone/front-end confirms. An irreversible action in this
/// auto-approving gateway session is stashed here (instead of forwarded) until
/// the user approves it via `nomi_browser_confirm`. Bounded-ish: capped per the
/// `MAX_PENDING` guard so a misbehaving agent cannot grow it without bound.
pending: Arc<std::sync::Mutex<HashMap<String, PendingBrowserAction>>>,
/// **P3-X2: machine-bound `encryption_key`** for loading the **shared** secret
/// vault (`{data_dir}/browser-secrets/shared/secrets.json` — user decision: 去
/// per-pet 键化, browser identity globally shared). When `Some`, each lazily-built
/// slot tool gets a [`BrowserSecretSource`] pointing at that one shared vault so
/// gateway-driven `secret:NAME` resolves (origin-gated) and the firewall domain
/// allowlist is derived from the registered `allowed_origins` (裁决⑤) — shared
/// across companions. `None` (the `default_for_browser_use` convenience ctor) →
/// no secret source (empty store → `secret:NAME` fails closed, current behavior).
secret_key: Option<[u8; 32]>,
}
impl BrowserRegistry {
/// Build the registry from the browser config. Reads `headless` (inverted to
/// `headful`) and the app data dir (same derivation as `BrowserTool::new`),
/// under which each companion gets an isolated `browser-profiles/{key}`
/// workspace. Constructs NO tools and launches NO browser — slots are created
/// lazily on first use per companion.
pub fn new(config: &BrowserConfig) -> Self {
let data_dir = nomi_config::config::app_config_dir()
.map(|d| d.join("browser-data"))
.unwrap_or_else(|| std::env::temp_dir().join("nomi-browser-data"));
Self {
data_dir,
headful: !config.headless,
bundled_dir: None,
slots: Arc::new(std::sync::Mutex::new(HashMap::new())),
pending: Arc::new(std::sync::Mutex::new(HashMap::new())),
secret_key: None,
}
}
/// **P3-X2**: set the machine-bound `encryption_key` so each companion's slot tool
/// loads the **shared** secret vault (`{data_dir}/browser-secrets/shared/secrets.json`
/// — 去 per-pet 键化, browser identity globally shared) — gateway-driven `secret:NAME`
/// then resolves (origin-gated) and the firewall `allow_etld1` is derived from the
/// registered `allowed_origins` (裁决⑤), shared across companions. Must be the app's
/// `encryption_key` (the same one the registration endpoint encrypted with).
pub fn with_secret_key(mut self, key: [u8; 32]) -> Self {
self.secret_key = Some(key);
self
}
/// **PKG-1**: set the bundled Chrome-for-Testing resource dir so each
/// lazily-built companion slot tool prefers bundled chrome over the network
/// download fallback. `None` → unchanged (env > data_dir > download).
pub fn with_bundled_dir(mut self, dir: Option<PathBuf>) -> Self {
self.bundled_dir = dir;
self
}
/// Convenience constructor for `nomifun-app`'s gateway wiring: build the
/// registry with the default browser config so the app does not need a direct
/// `nomi-config` dependency (the gateway already has one behind this feature).
/// The engine forces headless when no display is available regardless, so the
/// default (headful-requesting) config is the right gateway default.
pub fn default_for_browser_use() -> Self {
Self::new(&BrowserConfig::default())
}
/// Resolve the registry key for a caller. A companion binding scopes the
/// browser to that companion (multi-companion isolation); a session without
/// one (e.g. an IM master agent) gets a `conversation:<id>` key so it still
/// has its own isolated tool. An empty/unknown caller falls back to a shared
/// `"_default"` key.
pub fn key_for(companion_id: Option<&str>, conversation_id: &str) -> String {
match companion_id {
Some(c) if !c.trim().is_empty() => c.trim().to_string(),
_ if !conversation_id.trim().is_empty() => format!("conversation:{}", conversation_id.trim()),
_ => "_default".to_string(),
}
}
/// The per-companion workspace dir (`{data_dir}/browser-profiles/{key}`).
/// Pure path join — no I/O (the engine materializes `downloads/` on demand).
/// The key is sanitized of path separators so a `conversation:<id>` (or any
/// caller-influenced id) can never escape the profiles root.
pub fn workspace_for(&self, key: &str) -> PathBuf {
let safe: String = key
.chars()
.map(|c| if c == '/' || c == '\\' || c == ':' { '_' } else { c })
.collect();
self.data_dir.join("browser-profiles").join(safe)
}
/// Get (or lazily create) the slot for a key. The `BrowserTool` is constructed
/// but its engine is NOT launched (that happens lazily inside the facade on the
/// first action). The gateway-driven tool is a **non-bypassing** session
/// (`session_bypasses_approval = false`, `evaluate_full_power = false`): its own
/// fail-closed redline gate does not hard-deny, leaving irreversible actions for
/// the GW2 confirm hook (TODO at the dispatch layer).
fn slot(&self, key: &str) -> Arc<CompanionBrowser> {
let mut map = self.slots.lock().expect("browser registry slots poisoned");
if let Some(existing) = map.get(key) {
return existing.clone();
}
let workspace = self.workspace_for(key);
let mut tool = BrowserTool::with_data_dir(self.data_dir.clone(), self.headful)
.workspace(workspace)
.bundled_dir(self.bundled_dir.clone());
// P3-X2: give the slot tool the SHARED secret vault source so gateway-driven
// `secret:NAME` resolves and the firewall allowlist is derived from the
// registered allowed_origins (裁决⑤). User decision (去 per-pet 键化):
// `pet_vault_path` now ignores `key` and routes every slot to the one shared
// vault `{data_dir}/browser-secrets/shared`, so credentials/login/domain policy
// are shared across all companions — the same shared vault the registration
// endpoint and the session factory write to/read from.
if let Some(secret_key) = self.secret_key {
let vault_path = nomifun_secret::pet_vault_path(&self.data_dir, key);
tool = tool.secret_source(BrowserSecretSource { vault_path, key: secret_key });
}
let slot = Arc::new(CompanionBrowser {
tool: Arc::new(tool),
lock: AsyncMutex::new(()),
});
map.insert(key.to_string(), slot.clone());
slot
}
/// Drive a browser tool call for `key`, serialized against that companion's
/// other calls (X5: observe ⊥ act, per-target serial). `input` is the
/// `BrowserTool` action object (`{"action": "...", ...}`). Returns the facade's
/// [`ToolResult`] for the caller to render to JSON.
pub async fn execute(&self, key: &str, input: Value) -> ToolResult {
let slot = self.slot(key);
// Hold the per-companion mutex for the whole call so the same companion's
// observe/act/navigate never run concurrently against one engine.
let _guard = slot.lock.lock().await;
slot.tool.execute(input).await
}
/// **并行浏览(DESIGN §26 P7 / §22 per-BrowserContext 可并发)**。批量跑 `(key, input)` 浏览器调用:
/// **异 key** 并发(各 key 自有 Chrome 引擎 + 序列化锁,相互独立——浏览器**身份**仍经唯一共享 vault
/// 全局共享,只有活进程 per-key);**同 key** 仍经该 key 的 [`CompanionBrowser`] `lock` 串行
/// observe⊥act 成立)。结果**按输入序**返回(`join_all` 保序),调用方可一一对应。单个调用的错误作为
/// 其 [`ToolResult`] 返回(引擎不可用 / 被拒),绝不中断整批。
pub async fn execute_parallel(&self, calls: Vec<(String, Value)>) -> Vec<ToolResult> {
// 复用串行 `execute`:异 key 持不同 `CompanionBrowser` 锁 → 真并发;同 key 第二个 future 在该锁上
// 等第一个 → 串行,无交错。`join_all` 在当前任务上并发驱动所有 future 并**保输入序**返回。
let futs = calls
.into_iter()
.map(|(key, input)| async move { self.execute(&key, input).await });
futures::future::join_all(futs).await
}
/// **P3-GW2: classify an action's approval tier using the per-key facade's full
/// runtime context** (its cached observe snapshot resolves a dangerous accname by
/// `ref`). This is the AUTHORITATIVE classification the dispatch layer routes on —
/// it sees the submit/Pay/删除 button signals a bare `classify_action` (without the
/// snapshot) cannot. Pure read (no browser launch); creates the slot lazily if the
/// caller classifies before its first execute (the tool, not the engine, is built).
pub fn classify(&self, key: &str, action: &str, input: &Value) -> nomi_browser::ApprovalTier {
self.slot(key).tool.classify_action_tier(action, input)
}
/// **P3-GW2**: stash an irreversible action awaiting out-of-band approval and
/// return the synthetic `call_id` the phone/front-end will confirm. The `input`
/// MUST already be sanitized of any caller-supplied out-of-band sentinel (the
/// dispatch layer strips it before classifying). Returns `None` (so the caller
/// fails closed and denies) when the pending store is at capacity — a
/// misbehaving agent cannot grow it without bound.
pub fn stash_pending(&self, key: &str, input: Value) -> Option<String> {
let call_id = nomifun_common::generate_prefixed_id("browser_oob");
let mut map = self.pending.lock().expect("browser registry pending poisoned");
if map.len() >= MAX_PENDING {
return None;
}
map.insert(
call_id.clone(),
PendingBrowserAction {
key: key.to_string(),
input,
},
);
Some(call_id)
}
/// **P3-GW2**: remove and return a pending action by its `call_id`. `None` when
/// the id is unknown (already resolved / never existed / expired). The caller
/// (resolve path) treats `None` as "no such pending decision".
pub fn take_pending(&self, call_id: &str) -> Option<PendingBrowserAction> {
self.pending
.lock()
.expect("browser registry pending poisoned")
.remove(call_id)
}
/// **P3-GW2**: how many actions are currently awaiting approval (diagnostics /
/// the `MAX_PENDING` guard; also used by tests).
pub fn pending_count(&self) -> usize {
self.pending
.lock()
.expect("browser registry pending poisoned")
.len()
}
/// **P3-GW2**: execute an out-of-band-APPROVED action — inject the trusted
/// [`OUT_OF_BAND_CONFIRMED_KEY`] sentinel into the (sanitized) input so the
/// facade's redline gate releases the irreversible action, then forward through
/// the normal serialized `execute`. The sentinel is injected HERE (past the
/// gateway trust boundary), never copied from caller input.
pub async fn execute_confirmed(&self, key: &str, input: Value) -> ToolResult {
self.execute(key, inject_out_of_band(input)).await
}
}
/// **P3-GW2 [pure]: inject the trusted out-of-band sentinel** into a (sanitized)
/// action input so the facade's redline gate releases the irreversible action.
/// Called only by [`BrowserRegistry::execute_confirmed`] — past the gateway trust
/// boundary, after a real user approval. Pure (no I/O) so the injection is unit
/// testable without launching a browser.
fn inject_out_of_band(mut input: Value) -> Value {
if let Some(obj) = input.as_object_mut() {
obj.insert(OUT_OF_BAND_CONFIRMED_KEY.to_string(), Value::Bool(true));
}
input
}
/// Render a facade [`ToolResult`] into the gateway's JSON envelope. An error
/// result becomes `{"error": ...}`; a success result carries the text and any
/// images (base64 PNG) so a remote master agent can relay/inspect them.
pub fn tool_result_to_value(result: ToolResult) -> Value {
if result.is_error {
return json!({"error": result.content});
}
let mut payload = json!({"text": result.content});
if !result.images.is_empty() {
let imgs: Vec<Value> = result
.images
.iter()
.map(|img| json!({"media_type": img.media_type, "data": img.data}))
.collect();
payload["images"] = Value::Array(imgs);
}
json!({"result": payload})
}
#[cfg(test)]
mod tests {
use super::*;
fn registry() -> BrowserRegistry {
BrowserRegistry::new(&BrowserConfig::default())
}
#[test]
fn key_prefers_companion_then_conversation_then_default() {
assert_eq!(BrowserRegistry::key_for(Some("companion_x"), "5"), "companion_x");
// Whitespace-only companion id is treated as absent.
assert_eq!(BrowserRegistry::key_for(Some(" "), "5"), "conversation:5");
assert_eq!(BrowserRegistry::key_for(None, "5"), "conversation:5");
assert_eq!(BrowserRegistry::key_for(None, ""), "_default");
}
#[test]
fn workspace_is_per_key_and_sanitized() {
let r = registry();
let a = r.workspace_for("companion_a");
let b = r.workspace_for("companion_b");
assert_ne!(a, b, "different companions must get different workspaces");
assert!(a.ends_with(PathBuf::from("browser-profiles").join("companion_a")));
// A conversation key's ':' / separators are sanitized so it stays under
// the profiles root (no traversal).
let conv = r.workspace_for("conversation:5");
assert!(
conv.ends_with(PathBuf::from("browser-profiles").join("conversation_5")),
"got {conv:?}"
);
let evil = r.workspace_for("../../etc");
assert!(
evil.ends_with(PathBuf::from("browser-profiles").join(".._.._etc")),
"path separators in a key must be neutralized: {evil:?}"
);
}
#[test]
fn slot_is_stable_per_key_and_distinct_across_keys() {
let r = registry();
let a1 = r.slot("companion_a");
let a2 = r.slot("companion_a");
let b = r.slot("companion_b");
// Same key → same slot (so the engine + its mutex are reused, not rebuilt).
assert!(Arc::ptr_eq(&a1, &a2), "same key must reuse the same slot");
// Different key → different slot (live Chrome process / mutex isolated per
// companion; the persisted IDENTITY is still shared — see secret vault below).
assert!(!Arc::ptr_eq(&a1, &b), "different keys must get isolated engine slots");
}
#[test]
fn all_companions_resolve_the_same_shared_secret_vault() {
// User decision (去 per-pet 键化): every companion key routes to the ONE shared
// secret vault, so a secret registered for one companion is usable by every
// companion's gateway-driven browser (shared browser identity).
let r = registry();
let shared_tail = std::path::Path::new("browser-secrets").join("shared").join("secrets.json");
for key in ["companion_a", "companion_b", "conversation:5", "_default"] {
let p = nomifun_secret::pet_vault_path(&r.data_dir, key);
assert!(p.ends_with(&shared_tail), "key {key:?} must resolve the shared secret vault, got {p:?}");
}
// Distinct companion keys → identical shared vault path (the硬 evidence of sharing).
assert_eq!(
nomifun_secret::pet_vault_path(&r.data_dir, "companion_a"),
nomifun_secret::pet_vault_path(&r.data_dir, "companion_b"),
"去 per-pet 键化: two companions share one secret vault file"
);
}
#[test]
fn new_constructs_no_slots() {
let r = registry();
assert!(
r.slots.lock().unwrap().is_empty(),
"registry must not pre-create any companion slot (lazy per companion)"
);
assert_eq!(r.pending_count(), 0, "registry must start with no pending approvals");
}
// ── P3-GW2: pending out-of-band approval store ───────────────────────────
#[test]
fn stash_then_take_round_trips_the_pending_action() {
let r = registry();
let input = json!({"action": "click", "ref": "f0e3"});
let call_id = r.stash_pending("companion_a", input.clone()).expect("under cap");
assert!(call_id.starts_with("browser_oob"), "synthetic call_id prefix: {call_id}");
assert_eq!(r.pending_count(), 1);
let pending = r.take_pending(&call_id).expect("the just-stashed action");
assert_eq!(pending.key, "companion_a");
assert_eq!(pending.input, input);
// Taken once → gone (a second take is None; a confirm cannot be replayed).
assert!(r.take_pending(&call_id).is_none(), "take must be single-shot");
assert_eq!(r.pending_count(), 0);
}
#[test]
fn take_unknown_call_id_is_none() {
let r = registry();
assert!(r.take_pending("browser_oob_nope").is_none());
}
#[test]
fn stash_keys_are_unique_per_action() {
let r = registry();
let a = r.stash_pending("k", json!({"action": "click"})).unwrap();
let b = r.stash_pending("k", json!({"action": "click"})).unwrap();
assert_ne!(a, b, "each stashed action must get its own call_id");
assert_eq!(r.pending_count(), 2);
}
#[test]
fn stash_fails_closed_at_capacity() {
let r = registry();
for _ in 0..MAX_PENDING {
assert!(r.stash_pending("k", json!({"action": "click"})).is_some());
}
// At cap → None (the dispatch layer denies rather than growing unbounded).
assert!(
r.stash_pending("k", json!({"action": "click"})).is_none(),
"stash must fail closed at MAX_PENDING"
);
assert_eq!(r.pending_count(), MAX_PENDING);
}
#[test]
fn inject_out_of_band_sets_the_trusted_sentinel() {
// execute_confirmed's pure core: the sentinel is injected here (past the trust
// boundary), so the facade's out_of_band_confirmed reads true and the redline
// gate releases the held irreversible action.
let injected = inject_out_of_band(json!({"action": "click", "ref": "f0e3"}));
assert_eq!(injected.get(OUT_OF_BAND_CONFIRMED_KEY).and_then(Value::as_bool), Some(true));
assert_eq!(injected.get("action").and_then(Value::as_str), Some("click"));
// Overwrites any pre-existing value to a strict bool true (never trusts input).
let over = inject_out_of_band(json!({"action": "click", OUT_OF_BAND_CONFIRMED_KEY: "nope"}));
assert_eq!(over.get(OUT_OF_BAND_CONFIRMED_KEY).and_then(Value::as_bool), Some(true));
}
#[test]
fn classify_builds_slot_and_returns_a_tier_without_launching() {
// The dispatch layer's authoritative routing read: classify a benign read-only
// action against a fresh key. The slot (tool) is built lazily but NO engine is
// launched (pure read of the not-yet-existing snapshot → conservative tier).
let r = registry();
use nomi_browser::ApprovalTier;
assert_eq!(
r.classify("companion_c", "observe", &json!({"action": "observe"})),
ApprovalTier::Info,
"observe is read-only (Info)"
);
// A bare click with no cached snapshot → Exec (no accname to upgrade on).
assert_eq!(
r.classify("companion_c", "click", &json!({"action": "click", "ref": "f0e1"})),
ApprovalTier::Exec
);
// press_key bare Enter → Irreversible even without a snapshot (args-derivable).
assert_eq!(
r.classify("companion_c", "press_key", &json!({"action": "press_key", "keys": "Enter"})),
ApprovalTier::Irreversible
);
}
#[test]
fn error_result_maps_to_error_envelope() {
let v = tool_result_to_value(ToolResult::error("boom"));
assert_eq!(v.get("error").and_then(Value::as_str), Some("boom"));
assert!(v.get("result").is_none());
}
#[test]
fn text_result_maps_to_result_envelope() {
let v = tool_result_to_value(ToolResult::text("Navigated to https://example.com"));
assert_eq!(
v.pointer("/result/text").and_then(Value::as_str),
Some("Navigated to https://example.com")
);
assert_eq!(v.get("error"), None);
}
#[test]
fn image_result_carries_base64_png() {
let img = nomi_types::tool::ToolImage {
media_type: "image/png".into(),
data: "QUJD".into(), // base64("ABC")
};
let v = tool_result_to_value(ToolResult::text("Screenshot captured.").with_images(vec![img]));
let arr = v.pointer("/result/images").and_then(Value::as_array).expect("images array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0].get("media_type").and_then(Value::as_str), Some("image/png"));
assert_eq!(arr[0].get("data").and_then(Value::as_str), Some("QUJD"));
}
// ── real-device end-to-end (needs a local/bundled chrome) ────────────────
// GW1 round-trip through the registry: a gateway-driven navigate → observe
// against a real Chromium, plus per-companion isolation (two keys → two
// engines / user-data-dirs, distinct slots). Set NOMIFUN_CHROME_BINARY then:
// set NOMIFUN_CHROME_BINARY=C:\Program Files\Google\Chrome\Application\chrome.exe
// cargo nextest run -p nomifun-gateway --features browser-use --run-ignored all -E 'test(gateway_browser)'
// Asserts the navigate result is non-error and the observe surfaces the
// generation header + a frame-local `[ref=f0e…]` ref — i.e. a remote master
// agent can drive a browser scoped to its companion. Clean up: no residual
// chrome (the facade's engine Drop releases; the Builder kill_on_drop reaps).
#[tokio::test]
#[ignore = "需本机/打包 chromeset NOMIFUN_CHROME_BINARY 后 --run-ignored all -E 'test(gateway_browser)'"]
async fn gateway_browser_navigate_then_observe_round_trip() {
let r = registry();
let key = BrowserRegistry::key_for(Some("companion_e2e"), "1");
let nav = tool_result_to_value(
r.execute(&key, json!({"action": "navigate", "url": "https://example.com"}))
.await,
);
assert!(nav.get("error").is_none(), "navigate should succeed: {nav}");
assert!(
nav.pointer("/result/text").and_then(Value::as_str).is_some(),
"navigate result should carry text: {nav}"
);
let obs = tool_result_to_value(r.execute(&key, json!({"action": "observe"})).await);
let text = obs
.pointer("/result/text")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("observe should carry text: {obs}"));
assert!(text.contains("[browser observation"), "missing generation header: {text}");
assert!(text.contains("[ref=f0e"), "missing a frame-local ref: {text}");
// Isolation: a second companion gets a distinct slot (separate engine /
// user-data-dir) — gateway-driven browsing is per-companion.
let other = BrowserRegistry::key_for(Some("companion_other"), "2");
assert!(
!Arc::ptr_eq(&r.slot(&key), &r.slot(&other)),
"different companions must get isolated browser slots"
);
}
// ── P3-GW2 real-device: held-then-confirmed irreversible action round-trip ──
// Drives the full out-of-band approval state machine against a real Chromium:
// an irreversible action is stashed (NOT run), then the held action is approved
// and runs via execute_confirmed (the trusted sentinel makes the facade release
// it). Mirrors what `tools_browser::act` → `tools_browser::confirm` do, minus the
// GatewayDeps wiring (which the #[ignore] gateway integration covers separately).
// set NOMIFUN_CHROME_BINARY=C:\Program Files\Google\Chrome\Application\chrome.exe
// cargo nextest run -p nomifun-gateway --features browser-use --run-ignored all -E 'test(gw2_confirmed)'
#[tokio::test]
#[ignore = "需本机/打包 chromeset NOMIFUN_CHROME_BINARY 后 --run-ignored all -E 'test(gw2_confirmed)'"]
async fn gw2_confirmed_action_runs_held_then_approved() {
let r = registry();
let key = BrowserRegistry::key_for(Some("companion_gw2"), "1");
// A data: URL with a real <form> whose submit button navigates on click.
let page = "data:text/html,<form action='https://example.com/' method='get'>\
<button type='submit' id='go'>Pay now</button></form>";
let nav = tool_result_to_value(r.execute(&key, json!({"action": "navigate", "url": page})).await);
assert!(nav.get("error").is_none(), "navigate should succeed: {nav}");
let obs = tool_result_to_value(r.execute(&key, json!({"action": "observe"})).await);
let text = obs.pointer("/result/text").and_then(Value::as_str).unwrap_or("");
// Find the submit button's ref from the snapshot.
let r#ref = text
.split("[ref=")
.find(|seg| seg.to_lowercase().contains("pay") || seg.contains("button"))
.and_then(|seg| seg.split(']').next())
.unwrap_or("f0e1")
.to_string();
// GW2 gate decision: clicking a submit/Pay button is irreversible → stash it.
let action = json!({"action": "click", "ref": r#ref});
let call_id = r.stash_pending(&key, action.clone()).expect("under cap");
assert_eq!(r.pending_count(), 1, "the irreversible click must be HELD, not run");
// Approve: take the held action and run it confirmed (sentinel injected).
let pending = r.take_pending(&call_id).expect("the held action");
assert_eq!(pending.key, key);
let result = tool_result_to_value(r.execute_confirmed(&key, pending.input).await);
assert!(
result.get("error").is_none(),
"an approved (out-of-band-confirmed) irreversible action must RUN, not be Blocked: {result}"
);
assert_eq!(r.pending_count(), 0, "the pending action is consumed once resolved");
}
}
@@ -0,0 +1,712 @@
//! Agent-stack domain capabilities: agent catalog/health, custom agent CRUD,
//! remote agent management, and model failover configuration.
//!
//! Backed by:
//! - `nomifun_ai_agent::AgentService` — installed agent listing, health checks,
//! custom agent CRUD, enable/disable.
//! - `nomifun_ai_agent::RemoteAgentService` — remote (A2A/MCP) agent CRUD +
//! connection testing.
//! - `nomifun_conversation::model_failover` — global model-failover config read/write
//! (stored in `client_preferences` key `agent.model_failover`).
//!
//! NEW GatewayDeps fields assumed (parent wires):
//! - `agent_service: Arc<nomifun_ai_agent::AgentService>`
//! - `remote_agent_service: Arc<nomifun_ai_agent::RemoteAgentService>`
//! - `client_pref_repo: Arc<dyn nomifun_db::IClientPreferenceRepository>`
use std::sync::Arc;
use nomifun_api_types::{
CustomAgentUpsertRequest, ModelFailoverConfig, ProviderHealthCheckRequest,
TestRemoteAgentConnectionRequest, TryConnectCustomAgentRequest,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
// ── param structs (single source: schema + runtime) ──────────────────────
/// List all installed agent backends with their status and metadata.
#[derive(Deserialize, JsonSchema)]
struct AgentListParams {}
/// Run an ACP health check against a specific agent backend.
#[derive(Deserialize, JsonSchema)]
struct AgentHealthCheckParams {
/// The agent backend identifier to health-check (e.g. "claude", "codex").
backend: String,
}
/// Run a provider-level health check (verify model reachability via a provider).
#[derive(Deserialize, JsonSchema)]
struct AgentProviderHealthCheckParams {
/// Provider id to test against.
provider_id: String,
/// Model name to probe (must be enabled on the provider).
model: String,
}
/// Enable or disable an agent backend.
#[derive(Deserialize, JsonSchema)]
struct AgentSetEnabledParams {
/// Agent id to toggle.
id: String,
/// Whether to enable (true) or disable (false) the agent.
enabled: bool,
}
/// Create a custom (user-registered) agent backend.
#[derive(Deserialize, JsonSchema)]
struct AgentCustomCreateParams {
/// Display name for the custom agent.
name: String,
/// CLI command to launch the agent process (absolute path or PATH-resolvable).
command: String,
/// Optional icon URL or data URI.
#[serde(default)]
icon: Option<String>,
/// Extra CLI arguments passed after `command`.
#[serde(default)]
args: Vec<String>,
/// Environment variables injected into the agent process.
#[serde(default)]
env: Vec<AgentEnvEntryParam>,
/// Advanced behavior overrides (yolo_id, native_skills_dirs, behavior_policy, description).
#[serde(default)]
advanced: Option<Value>,
}
/// Update an existing custom agent backend.
#[derive(Deserialize, JsonSchema)]
struct AgentCustomUpdateParams {
/// The custom agent id to update.
id: String,
/// Display name for the custom agent.
name: String,
/// CLI command to launch the agent process.
command: String,
/// Optional icon URL or data URI.
#[serde(default)]
icon: Option<String>,
/// Extra CLI arguments passed after `command`.
#[serde(default)]
args: Vec<String>,
/// Environment variables injected into the agent process.
#[serde(default)]
env: Vec<AgentEnvEntryParam>,
/// Advanced behavior overrides.
#[serde(default)]
advanced: Option<Value>,
}
/// Delete a custom agent backend (irreversible).
#[derive(Deserialize, JsonSchema)]
struct AgentCustomDeleteParams {
/// The custom agent id to permanently delete.
id: String,
}
/// Test connectivity to a custom agent binary (try-connect handshake).
#[derive(Deserialize, JsonSchema)]
struct AgentCustomTryConnectParams {
/// CLI command to launch the agent process.
command: String,
/// ACP protocol arguments (if any).
#[serde(default)]
acp_args: Vec<String>,
/// Environment variables for the test subprocess.
#[serde(default)]
env: std::collections::HashMap<String, String>,
}
/// An environment variable entry for custom agent configuration.
#[derive(Deserialize, JsonSchema, Clone)]
struct AgentEnvEntryParam {
/// Variable name.
name: String,
/// Variable value.
value: String,
/// Optional human-readable description of what this variable controls.
#[serde(default)]
description: Option<String>,
}
// ── Remote agent param structs ──────────────────────────────────────────
/// List all registered remote agents.
#[derive(Deserialize, JsonSchema)]
struct RemoteAgentListParams {}
/// Get details of a single remote agent by id.
#[derive(Deserialize, JsonSchema)]
struct RemoteAgentGetParams {
/// Remote agent id (numeric, as string for consistency).
id: String,
}
/// Register a new remote agent.
#[derive(Deserialize, JsonSchema)]
struct RemoteAgentCreateParams {
/// Display name.
name: String,
/// Protocol: "a2a" or "mcp-sse".
protocol: String,
/// Agent endpoint URL.
url: String,
/// Authentication type: "none", "bearer", or "header".
auth_type: String,
/// Auth token (required when auth_type is "bearer" or "header").
#[serde(default)]
auth_token: Option<String>,
/// Allow connecting to HTTP (non-TLS) endpoints.
#[serde(default)]
allow_insecure: bool,
/// Optional avatar URL.
#[serde(default)]
avatar: Option<String>,
/// Optional description.
#[serde(default)]
description: Option<String>,
}
/// Update an existing remote agent (partial — only provided fields are changed).
#[derive(Deserialize, JsonSchema)]
struct RemoteAgentUpdateParams {
/// Remote agent id to update.
id: String,
/// New display name.
#[serde(default)]
name: Option<String>,
/// New protocol.
#[serde(default)]
protocol: Option<String>,
/// New endpoint URL.
#[serde(default)]
url: Option<String>,
/// New auth type.
#[serde(default)]
auth_type: Option<String>,
/// New auth token (null to clear).
#[serde(default)]
auth_token: Option<Option<String>>,
/// New allow_insecure flag.
#[serde(default)]
allow_insecure: Option<bool>,
/// New avatar (null to clear).
#[serde(default)]
avatar: Option<Option<String>>,
/// New description (null to clear).
#[serde(default)]
description: Option<Option<String>>,
}
/// Delete a remote agent registration (irreversible).
#[derive(Deserialize, JsonSchema)]
struct RemoteAgentDeleteParams {
/// Remote agent id to permanently delete.
id: String,
}
/// Test connectivity to a remote agent endpoint without persisting it.
#[derive(Deserialize, JsonSchema)]
struct RemoteAgentTestParams {
/// Endpoint URL to test.
url: String,
/// Auth type for the test connection.
#[serde(default)]
auth_type: Option<String>,
/// Auth token for the test connection.
#[serde(default)]
auth_token: Option<String>,
/// Allow HTTP (non-TLS) endpoints.
#[serde(default)]
allow_insecure: bool,
}
// ── Model failover param structs ────────────────────────────────────────
/// Read the global model-failover configuration.
#[derive(Deserialize, JsonSchema)]
struct ModelFailoverGetParams {}
/// Set the global model-failover configuration.
#[derive(Deserialize, JsonSchema)]
struct ModelFailoverSetParams {
/// Whether model failover is enabled.
enabled: bool,
/// Ordered list of provider+model pairs to try on failure (first = primary fallback).
/// Each entry: { "provider_id": "...", "model": "...", "use_model": null | "..." }.
#[serde(default)]
queue: Vec<Value>,
/// Maximum number of model switches per conversation turn (default: 4).
#[serde(default = "default_max_switches")]
max_switches: u32,
/// Whether to mark the failed provider-model as unhealthy after failover (default: true).
#[serde(default = "default_stamp_unhealthy")]
stamp_unhealthy: bool,
}
fn default_max_switches() -> u32 {
4
}
fn default_stamp_unhealthy() -> bool {
true
}
// ── handlers ──────────────────────────────────────────────────────────────
async fn agent_list(deps: Arc<GatewayDeps>, _p: AgentListParams) -> Value {
match deps.agent_service.list_agents().await {
Ok(agents) => ok(agents),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn agent_health_check(deps: Arc<GatewayDeps>, p: AgentHealthCheckParams) -> Value {
let req = nomifun_api_types::AcpHealthCheckRequest {
backend: p.backend,
};
match deps.agent_service.acp_health_check(req).await {
Ok(resp) => ok(resp),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn agent_provider_health_check(
deps: Arc<GatewayDeps>,
p: AgentProviderHealthCheckParams,
) -> Value {
let req = ProviderHealthCheckRequest {
provider_id: p.provider_id,
model: p.model,
};
match deps.agent_service.provider_health_check(req).await {
Ok(resp) => ok(resp),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn agent_set_enabled(deps: Arc<GatewayDeps>, p: AgentSetEnabledParams) -> Value {
match deps.agent_service.set_agent_enabled(&p.id, p.enabled).await {
Ok(meta) => ok(meta),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn agent_custom_create(deps: Arc<GatewayDeps>, p: AgentCustomCreateParams) -> Value {
let advanced = match p.advanced {
Some(val) => match serde_json::from_value(val) {
Ok(adv) => Some(adv),
Err(e) => return json!({ "error": format!("invalid advanced field: {e}") }),
},
None => None,
};
let req = CustomAgentUpsertRequest {
name: p.name,
command: p.command,
icon: p.icon,
args: p.args,
env: p
.env
.into_iter()
.map(|e| nomifun_api_types::AgentEnvEntry {
name: e.name,
value: e.value,
description: e.description,
})
.collect(),
advanced,
};
match deps.agent_service.create_custom_agent(req).await {
Ok(meta) => ok(meta),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn agent_custom_update(deps: Arc<GatewayDeps>, p: AgentCustomUpdateParams) -> Value {
let advanced = match p.advanced {
Some(val) => match serde_json::from_value(val) {
Ok(adv) => Some(adv),
Err(e) => return json!({ "error": format!("invalid advanced field: {e}") }),
},
None => None,
};
let req = CustomAgentUpsertRequest {
name: p.name,
command: p.command,
icon: p.icon,
args: p.args,
env: p
.env
.into_iter()
.map(|e| nomifun_api_types::AgentEnvEntry {
name: e.name,
value: e.value,
description: e.description,
})
.collect(),
advanced,
};
match deps.agent_service.update_custom_agent(&p.id, req).await {
Ok(meta) => ok(meta),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn agent_custom_delete(deps: Arc<GatewayDeps>, p: AgentCustomDeleteParams) -> Value {
match deps.agent_service.delete_custom_agent(&p.id).await {
Ok(()) => ok(json!({ "deleted": p.id })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn agent_custom_try_connect(
deps: Arc<GatewayDeps>,
p: AgentCustomTryConnectParams,
) -> Value {
let req = TryConnectCustomAgentRequest {
command: p.command,
acp_args: p.acp_args,
env: p.env,
};
match deps.agent_service.try_connect_custom_agent(req).await {
Ok(resp) => ok(resp),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ── remote agent handlers ───────────────────────────────────────────────
async fn remote_agent_list(deps: Arc<GatewayDeps>, _p: RemoteAgentListParams) -> Value {
match deps.remote_agent_service.list().await {
Ok(list) => ok(list),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn remote_agent_get(deps: Arc<GatewayDeps>, p: RemoteAgentGetParams) -> Value {
match deps.remote_agent_service.get(&p.id).await {
Ok(resp) => ok(resp),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn remote_agent_create(deps: Arc<GatewayDeps>, p: RemoteAgentCreateParams) -> Value {
// Deserialize protocol/auth_type from string to the typed enums via serde.
let protocol = match serde_json::from_value(json!(p.protocol)) {
Ok(v) => v,
Err(e) => return json!({ "error": format!("invalid protocol: {e}") }),
};
let auth_type = match serde_json::from_value(json!(p.auth_type)) {
Ok(v) => v,
Err(e) => return json!({ "error": format!("invalid auth_type: {e}") }),
};
let req = nomifun_api_types::CreateRemoteAgentRequest {
name: p.name,
protocol,
url: p.url,
auth_type,
auth_token: p.auth_token,
allow_insecure: p.allow_insecure,
avatar: p.avatar,
description: p.description,
};
match deps.remote_agent_service.create(req).await {
Ok(resp) => ok(resp),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn remote_agent_update(deps: Arc<GatewayDeps>, p: RemoteAgentUpdateParams) -> Value {
let protocol = match p.protocol {
Some(v) => match serde_json::from_value(json!(v)) {
Ok(parsed) => Some(parsed),
Err(e) => return json!({ "error": format!("invalid protocol: {e}") }),
},
None => None,
};
let auth_type = match p.auth_type {
Some(v) => match serde_json::from_value(json!(v)) {
Ok(parsed) => Some(parsed),
Err(e) => return json!({ "error": format!("invalid auth_type: {e}") }),
},
None => None,
};
let req = nomifun_api_types::UpdateRemoteAgentRequest {
name: p.name,
protocol,
url: p.url,
auth_type,
auth_token: p.auth_token,
allow_insecure: p.allow_insecure,
avatar: p.avatar,
description: p.description,
};
match deps.remote_agent_service.update(&p.id, req).await {
Ok(resp) => ok(resp),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn remote_agent_delete(deps: Arc<GatewayDeps>, p: RemoteAgentDeleteParams) -> Value {
match deps.remote_agent_service.delete(&p.id).await {
Ok(()) => ok(json!({ "deleted": p.id })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn remote_agent_test(deps: Arc<GatewayDeps>, p: RemoteAgentTestParams) -> Value {
let auth_type = match p.auth_type {
Some(v) => match serde_json::from_value(json!(v)) {
Ok(parsed) => Some(parsed),
Err(e) => return json!({ "error": format!("invalid auth_type: {e}") }),
},
None => None,
};
let req = TestRemoteAgentConnectionRequest {
url: p.url,
auth_type,
auth_token: p.auth_token,
allow_insecure: p.allow_insecure,
};
match deps.remote_agent_service.test_connection(req).await {
Ok(()) => ok(json!({ "connected": true })),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ── model failover handlers ─────────────────────────────────────────────
async fn model_failover_get(deps: Arc<GatewayDeps>, _p: ModelFailoverGetParams) -> Value {
let cfg =
nomifun_conversation::model_failover::get_global_failover_config(&deps.client_pref_repo)
.await;
ok(cfg)
}
async fn model_failover_set(deps: Arc<GatewayDeps>, p: ModelFailoverSetParams) -> Value {
// Deserialize queue entries into the typed ProviderWithModel vec.
let queue: Vec<nomifun_common::ProviderWithModel> = match p
.queue
.into_iter()
.map(serde_json::from_value)
.collect::<Result<Vec<_>, _>>()
{
Ok(q) => q,
Err(e) => {
return json!({ "error": format!("invalid queue entry: {e}. Each entry must have provider_id and model fields.") })
}
};
let cfg = ModelFailoverConfig {
enabled: p.enabled,
queue,
max_switches: p.max_switches,
stamp_unhealthy: p.stamp_unhealthy,
};
match nomifun_conversation::model_failover::set_global_failover_config(
&deps.client_pref_repo,
&cfg,
)
.await
{
Ok(()) => ok(cfg),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ── registration ─────────────────────────────────────────────────────────
/// Register the agent-stack domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
// ─── Agent catalog ───────────────────────────────────────────────────
// 1. List agents (Read)
out.push(Capability::new::<AgentListParams, _, _>(
CapabilityMeta::new(
"nomi_agent_list",
"agent",
"List all installed agent backends with their availability status, type, and configuration.",
DangerTier::Read,
),
|deps, _ctx, p| agent_list(deps, p),
));
// 2. ACP health check (Read)
out.push(Capability::new::<AgentHealthCheckParams, _, _>(
CapabilityMeta::new(
"nomi_agent_health_check",
"agent",
"Run an ACP health check against a specific agent backend to verify it is responsive.",
DangerTier::Read,
),
|deps, _ctx, p| agent_health_check(deps, p),
));
// 3. Provider health check (Read)
out.push(Capability::new::<AgentProviderHealthCheckParams, _, _>(
CapabilityMeta::new(
"nomi_agent_provider_health_check",
"agent",
"Test model reachability through a specific provider (verify API key, model availability, latency).",
DangerTier::Read,
),
|deps, _ctx, p| agent_provider_health_check(deps, p),
));
// 4. Set agent enabled (Write)
out.push(Capability::new::<AgentSetEnabledParams, _, _>(
CapabilityMeta::new(
"nomi_agent_set_enabled",
"agent",
"Enable or disable an agent backend. Disabled agents are not available for new conversations.",
DangerTier::Write,
),
|deps, _ctx, p| agent_set_enabled(deps, p),
));
// ─── Custom agents ───────────────────────────────────────────────────
// 5. Create custom agent (Write)
out.push(Capability::new::<AgentCustomCreateParams, _, _>(
CapabilityMeta::new(
"nomi_agent_custom_create",
"agent",
"Register a new custom agent backend (user-provided CLI binary). The process will be launched on demand.",
DangerTier::Write,
),
|deps, _ctx, p| agent_custom_create(deps, p),
));
// 6. Update custom agent (Write)
out.push(Capability::new::<AgentCustomUpdateParams, _, _>(
CapabilityMeta::new(
"nomi_agent_custom_update",
"agent",
"Update an existing custom agent backend's configuration (name, command, args, env, advanced overrides).",
DangerTier::Write,
),
|deps, _ctx, p| agent_custom_update(deps, p),
));
// 7. Delete custom agent (Destructive, deny_on Channel)
out.push(Capability::new::<AgentCustomDeleteParams, _, _>(
CapabilityMeta::new(
"nomi_agent_custom_delete",
"agent",
"Permanently delete a custom agent backend registration. Running sessions using this agent will fail on next turn.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, _ctx, p| agent_custom_delete(deps, p),
));
// 8. Try-connect custom agent (Read — network probe, no state change)
out.push(Capability::new::<AgentCustomTryConnectParams, _, _>(
CapabilityMeta::new(
"nomi_agent_custom_try_connect",
"agent",
"Test connectivity to a custom agent binary by spawning it and performing an ACP handshake (dry-run, no persistence).",
DangerTier::Read,
),
|deps, _ctx, p| agent_custom_try_connect(deps, p),
));
// ─── Remote agents ───────────────────────────────────────────────────
// 9. List remote agents (Read)
out.push(Capability::new::<RemoteAgentListParams, _, _>(
CapabilityMeta::new(
"nomi_remote_agent_list",
"agent",
"List all registered remote agents (A2A / MCP-SSE) with their connection status.",
DangerTier::Read,
),
|deps, _ctx, p| remote_agent_list(deps, p),
));
// 10. Get remote agent (Read)
out.push(Capability::new::<RemoteAgentGetParams, _, _>(
CapabilityMeta::new(
"nomi_remote_agent_get",
"agent",
"Get full details of a remote agent by id (includes auth token if present).",
DangerTier::Read,
),
|deps, _ctx, p| remote_agent_get(deps, p),
));
// 11. Create remote agent (Write)
out.push(Capability::new::<RemoteAgentCreateParams, _, _>(
CapabilityMeta::new(
"nomi_remote_agent_create",
"agent",
"Register a new remote agent endpoint (A2A or MCP-SSE protocol) with optional authentication.",
DangerTier::Write,
),
|deps, _ctx, p| remote_agent_create(deps, p),
));
// 12. Update remote agent (Write)
out.push(Capability::new::<RemoteAgentUpdateParams, _, _>(
CapabilityMeta::new(
"nomi_remote_agent_update",
"agent",
"Update an existing remote agent's configuration. Only provided fields are changed.",
DangerTier::Write,
),
|deps, _ctx, p| remote_agent_update(deps, p),
));
// 13. Delete remote agent (Destructive, deny_on Channel)
out.push(Capability::new::<RemoteAgentDeleteParams, _, _>(
CapabilityMeta::new(
"nomi_remote_agent_delete",
"agent",
"Permanently delete a remote agent registration. Active delegations to this agent will fail.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, _ctx, p| remote_agent_delete(deps, p),
));
// 14. Test remote agent connection (Read — network probe only)
out.push(Capability::new::<RemoteAgentTestParams, _, _>(
CapabilityMeta::new(
"nomi_remote_agent_test",
"agent",
"Test connectivity to a remote agent endpoint without persisting it (dry-run handshake).",
DangerTier::Read,
),
|deps, _ctx, p| remote_agent_test(deps, p),
));
// ─── Model failover ──────────────────────────────────────────────────
// 15. Get model failover config (Read)
out.push(Capability::new::<ModelFailoverGetParams, _, _>(
CapabilityMeta::new(
"nomi_model_failover_get",
"agent",
"Read the global model-failover configuration (enabled flag, ordered queue of fallback provider+model pairs, max switches).",
DangerTier::Read,
),
|deps, _ctx, p| model_failover_get(deps, p),
));
// 16. Set model failover config (Write)
out.push(Capability::new::<ModelFailoverSetParams, _, _>(
CapabilityMeta::new(
"nomi_model_failover_set",
"agent",
"Set the global model-failover configuration. Controls automatic fallback to alternative models when the primary provider fails.",
DangerTier::Write,
),
|deps, _ctx, p| model_failover_set(deps, p),
));
}
@@ -0,0 +1,185 @@
//! AutoWork-domain capabilities (registry form): enable/disable + inspect the
//! AutoWork binding for a conversation or terminal target.
//!
//! Mirrors `POST /api/requirements/autowork`: persist the config via
//! `RequirementService`, then start/stop the live orchestrator loop and
//! broadcast the state — a config write alone would only take effect after the
//! next desktop boot.
use std::sync::Arc;
use nomifun_api_types::{AutoWorkState, AutoWorkTargetKind};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
#[derive(Deserialize, JsonSchema)]
struct SetAutoworkParams {
/// Target kind: "conversation" or "terminal".
kind: String,
/// The conversation id or terminal id to bind.
target_id: String,
/// Enable (true) or disable (false) AutoWork on the target.
enabled: bool,
/// Requirement tag the session works through. REQUIRED when enabling.
#[serde(default)]
tag: Option<String>,
/// Stop after this many completed requirements (omit for unlimited).
#[serde(default)]
max_requirements: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
struct GetAutoworkParams {
/// Target kind: "conversation" or "terminal".
kind: String,
/// The conversation id or terminal id to inspect.
target_id: String,
}
fn parse_kind(raw: &str) -> Result<AutoWorkTargetKind, Value> {
AutoWorkTargetKind::parse(raw)
.ok_or_else(|| json!({ "error": format!("unknown kind '{raw}' (expected conversation | terminal)") }))
}
/// Parse the string `target_id` into the integer conversation id the requirement
/// service's owner check uses (the AutoWork target handle stays a string).
fn parse_conv_id(target_id: &str) -> Result<i64, nomifun_common::AppError> {
target_id
.parse::<i64>()
.map_err(|_| nomifun_common::AppError::NotFound(format!("conversation {target_id}")))
}
/// Assemble the persisted config + the orchestrator's live view into one
/// `AutoWorkState` (the same shape the REST routes return and broadcast).
async fn build_state(deps: &GatewayDeps, kind: AutoWorkTargetKind, target_id: &str) -> Result<AutoWorkState, Value> {
let (enabled, tag, _max) = deps
.requirement_service
.read_autowork_config(kind, target_id)
.await
.map_err(|e| json!({ "error": e.to_string() }))?;
let running = deps.autowork_orchestrator.is_running(kind, target_id);
let live_tag = deps.autowork_orchestrator.running_tag(kind, target_id).or(tag);
let (current_requirement_id, completed_count) = deps
.autowork_orchestrator
.live_progress(kind, target_id)
.unwrap_or((None, 0));
let run_state = AutoWorkState::run_state(enabled, current_requirement_id.as_deref());
Ok(AutoWorkState {
kind,
target_id: target_id.to_owned(),
enabled,
tag: live_tag,
running,
run_state,
current_requirement_id,
completed_count,
})
}
async fn set(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: SetAutoworkParams) -> Value {
if ctx.user_id.is_empty() {
return json!({ "error": "missing caller user identity" });
}
let kind = match parse_kind(&p.kind) {
Ok(k) => k,
Err(e) => return e,
};
let target_id = p.target_id;
if p.enabled && p.tag.is_none() {
return json!({ "error": "tag is required when enabling autowork (the tag groups the requirements this session will work through)" });
}
// Ownership + (terminal) eligibility — same gates as the REST route.
let owner_check = match kind {
AutoWorkTargetKind::Conversation => match parse_conv_id(&target_id) {
Ok(conv_id) => deps.requirement_service.verify_conversation_owner(conv_id, &ctx.user_id).await,
Err(e) => Err(e),
},
AutoWorkTargetKind::Terminal => deps.requirement_service.verify_terminal_owner(&target_id, &ctx.user_id).await,
};
if let Err(e) = owner_check {
return json!({ "error": e.to_string() });
}
if p.enabled
&& kind == AutoWorkTargetKind::Terminal
&& let Err(e) = deps.requirement_service.ensure_terminal_autowork_eligible(&target_id).await
{
return json!({ "error": e.to_string() });
}
if let Err(e) = deps
.requirement_service
.save_autowork_config(kind, &target_id, p.enabled, p.tag.as_deref(), p.max_requirements)
.await
{
return json!({ "error": e.to_string() });
}
if p.enabled {
if let Some(tag) = p.tag.clone() {
deps.autowork_orchestrator
.start(kind, target_id.clone(), tag, p.max_requirements);
}
} else {
deps.autowork_orchestrator.stop(kind, &target_id);
}
match build_state(&deps, kind, &target_id).await {
Ok(state) => {
deps.requirement_service.emit_autowork_state(&state);
ok(state)
}
Err(e) => e,
}
}
async fn get(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: GetAutoworkParams) -> Value {
if ctx.user_id.is_empty() {
return json!({ "error": "missing caller user identity" });
}
let kind = match parse_kind(&p.kind) {
Ok(k) => k,
Err(e) => return e,
};
let target_id = p.target_id;
let owner_check = match kind {
AutoWorkTargetKind::Conversation => match parse_conv_id(&target_id) {
Ok(conv_id) => deps.requirement_service.verify_conversation_owner(conv_id, &ctx.user_id).await,
Err(e) => Err(e),
},
AutoWorkTargetKind::Terminal => deps.requirement_service.verify_terminal_owner(&target_id, &ctx.user_id).await,
};
if let Err(e) = owner_check {
return json!({ "error": e.to_string() });
}
match build_state(&deps, kind, &target_id).await {
Ok(state) => ok(state),
Err(e) => e,
}
}
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<SetAutoworkParams, _, _>(
CapabilityMeta::new(
"nomi_set_autowork",
"autowork",
"Enable/disable AutoWork (autonomous requirement execution) on a conversation or terminal and bind a requirement tag.",
DangerTier::Write,
),
set,
));
out.push(Capability::new::<GetAutoworkParams, _, _>(
CapabilityMeta::new(
"nomi_get_autowork",
"autowork",
"Read the current AutoWork binding + live run state for a conversation or terminal.",
DangerTier::Read,
),
get,
));
}
@@ -0,0 +1,256 @@
//! Browser-domain capabilities (registry form, feature-gated). Lets a
//! remote/master agent drive the desktop's in-process CDP browser, scoped +
//! serialized per companion via [`crate::browser_registry::BrowserRegistry`].
//!
//! The GW2 out-of-band approval state machine (irreversible actions are held
//! for explicit user approval via `nomi_browser_confirm`, never auto-run even
//! under a yolo session) is preserved verbatim from the legacy tool — it is
//! more specific than the registry's generic confirm-gate, so these tools are
//! plain `Write`/`Read` and let GW2 do the gating. Browser tools are NOT denied
//! on the Channel surface: remote browser driving is the entire point.
//!
//! Only compiled when the `browser-use` feature is on.
use std::sync::Arc;
use nomi_browser::{ApprovalTier, OUT_OF_BAND_CONFIRMED_KEY};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Map, Value, json};
use crate::browser_registry::{BrowserRegistry, tool_result_to_value};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier};
// ── params ────────────────────────────────────────────────────────────────
#[derive(Deserialize, JsonSchema)]
struct NavigateParams {
/// The URL to load in the caller's browser.
url: String,
/// Open in a new tab instead of the current one (default false).
#[serde(default)]
new_tab: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
struct ObserveParams {
/// Optional cap on the aria-snapshot depth (for huge pages).
#[serde(default)]
max_depth: Option<u64>,
}
#[derive(Deserialize, JsonSchema)]
struct ActParams {
/// The facade action name (click / type / scroll / screenshot /
/// get_page_text / back / press_key / …). Re-observe after any action that
/// changes the page (refs go stale).
action: String,
/// Action-specific params (ref / text / url / keys / …), passed through
/// verbatim to the browser facade.
#[serde(flatten)]
rest: Map<String, Value>,
}
#[derive(Deserialize, JsonSchema)]
struct ConfirmParams {
/// The call_id from an `approval_required` envelope.
call_id: String,
/// "proceed_once" to approve the held irreversible action, "cancel" to deny.
#[serde(default)]
option: Option<String>,
}
// ── per-caller registry + GW2 helpers (ported verbatim) ───────────────────
fn registry_and_key<'a>(deps: &'a GatewayDeps, ctx: &CallerCtx) -> Result<(&'a BrowserRegistry, String), Value> {
let registry = deps
.browser_registry
.as_ref()
.ok_or_else(|| json!({"error": "browser tools are not available on this desktop"}))?;
let key = BrowserRegistry::key_for(ctx.companion_id.as_deref(), &ctx.conversation_id);
Ok((registry, key))
}
/// Strip any caller-supplied out-of-band sentinel before classify/forward (trust boundary).
fn sanitize_out_of_band(mut input: Value) -> Value {
if let Some(obj) = input.as_object_mut() {
obj.remove(OUT_OF_BAND_CONFIRMED_KEY);
}
input
}
fn approval_required_value(call_id: &str, action: &str, args: &Value) -> Value {
json!({
"approval_required": {
"call_id": call_id,
"title": format!("Approve irreversible browser action: {action}"),
"description": describe_pending(action, args),
"how_to": "This action is irreversible (submit / payment / delete / send) and your \
session auto-approves, so it is held for out-of-band approval. Relay this \
to the user; once they approve, call nomi_browser_confirm with this call_id \
and option \"proceed_once\" (or \"cancel\" to deny).",
"options": [
{"label": "Approve once", "value": "proceed_once"},
{"label": "Deny", "value": "cancel"},
],
}
})
}
fn describe_pending(action: &str, args: &Value) -> String {
let detail = match action {
"navigate" => args.get("url").and_then(Value::as_str).map(|u| format!("navigate to {u}")),
"click" => args.get("ref").and_then(Value::as_str).map(|r| format!("click [ref={r}]")),
"press_key" => args.get("keys").and_then(Value::as_str).map(|k| format!("press {k}")),
"reload" => Some("reload the page".to_string()),
_ => None,
};
match detail {
Some(d) => format!("Will {d} — irreversible (may submit / pay / delete / send)."),
None => format!("Will run irreversible action `{action}` (may submit / pay / delete / send)."),
}
}
/// Gate an outbound action through out-of-band approval. `input` MUST already be
/// sanitized. Returns `Some(json)` to short-circuit, `None` to proceed.
fn gw2_gate(registry: &BrowserRegistry, key: &str, action: &str, input: &Value) -> Option<Value> {
if registry.classify(key, action, input) != ApprovalTier::Irreversible {
return None;
}
match registry.stash_pending(key, input.clone()) {
Some(call_id) => Some(approval_required_value(&call_id, action, input)),
None => Some(json!({
"error": "too many browser actions are awaiting approval; resolve or cancel some via \
nomi_browser_confirm before issuing more irreversible actions"
})),
}
}
// ── handlers ────────────────────────────────────────────────────────────────
async fn navigate(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: NavigateParams) -> Value {
let (registry, key) = match registry_and_key(&deps, &ctx) {
Ok(v) => v,
Err(e) => return e,
};
let input = json!({"action": "navigate", "url": p.url, "new_tab": p.new_tab.unwrap_or(false)});
if let Some(short_circuit) = gw2_gate(registry, &key, "navigate", &input) {
return short_circuit;
}
tool_result_to_value(registry.execute(&key, input).await)
}
async fn observe(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ObserveParams) -> Value {
let (registry, key) = match registry_and_key(&deps, &ctx) {
Ok(v) => v,
Err(e) => return e,
};
let mut input = json!({"action": "observe"});
if let Some(d) = p.max_depth {
input["max_depth"] = json!(d);
}
if let Some(short_circuit) = gw2_gate(registry, &key, "observe", &input) {
return short_circuit;
}
tool_result_to_value(registry.execute(&key, input).await)
}
async fn act(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ActParams) -> Value {
let (registry, key) = match registry_and_key(&deps, &ctx) {
Ok(v) => v,
Err(e) => return e,
};
// Reconstruct the facade input from the passthrough params, strip any
// caller-supplied sentinel (trust boundary), then set the validated action.
let mut input = sanitize_out_of_band(Value::Object(p.rest));
input["action"] = json!(p.action);
if let Some(short_circuit) = gw2_gate(registry, &key, &p.action, &input) {
return short_circuit;
}
tool_result_to_value(registry.execute(&key, input).await)
}
async fn confirm(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ConfirmParams) -> Value {
let (registry, key) = match registry_and_key(&deps, &ctx) {
Ok(v) => v,
Err(e) => return e,
};
let option = p.option.as_deref().map(str::trim).unwrap_or("cancel");
let approve = matches!(option, "proceed_once" | "proceed_always" | "approve" | "yes");
let Some(pending) = registry.take_pending(&p.call_id) else {
return json!({"error": format!("no pending browser approval with call_id {} (already resolved, expired, or never existed)", p.call_id)});
};
if pending.key != key {
return json!({"error": "this pending browser approval belongs to a different session and cannot be resolved here"});
}
if !approve {
return json!({"resolved": p.call_id, "approved": false, "result": {"text": "Denied. The irreversible browser action was not run."}});
}
let mut envelope = tool_result_to_value(registry.execute_confirmed(&key, pending.input).await);
if let Some(obj) = envelope.as_object_mut() {
obj.insert("resolved".to_string(), json!(p.call_id));
obj.insert("approved".to_string(), json!(true));
}
envelope
}
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<NavigateParams, _, _>(
CapabilityMeta::new("nomi_browser_navigate", "browser", "Load a URL in the caller's browser (optionally a new tab).", DangerTier::Write),
navigate,
));
out.push(Capability::new::<ObserveParams, _, _>(
CapabilityMeta::new("nomi_browser_observe", "browser", "Read the page's accessibility tree (aria snapshot + ref table) to target later. Read-only.", DangerTier::Read),
observe,
));
out.push(Capability::new::<ActParams, _, _>(
CapabilityMeta::new("nomi_browser_act", "browser", "Run any browser action (click/type/scroll/screenshot/...); irreversible actions are held for out-of-band approval.", DangerTier::Write),
act,
));
out.push(Capability::new::<ConfirmParams, _, _>(
CapabilityMeta::new("nomi_browser_confirm", "browser", "Resolve a pending out-of-band browser approval (proceed_once / cancel).", DangerTier::Write),
confirm,
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_caller_supplied_out_of_band_sentinel() {
let dirty = json!({"action": "click", "ref": "f0e1", OUT_OF_BAND_CONFIRMED_KEY: true});
let clean = sanitize_out_of_band(dirty);
assert!(clean.get(OUT_OF_BAND_CONFIRMED_KEY).is_none());
assert_eq!(clean.get("action").and_then(Value::as_str), Some("click"));
}
#[test]
fn describe_pending_surfaces_action_detail_without_secrets() {
assert!(describe_pending("navigate", &json!({"url": "https://shop.test/pay"})).contains("shop.test/pay"));
assert!(describe_pending("click", &json!({"ref": "f0e9"})).contains("f0e9"));
let d = describe_pending("type", &json!({"text": "secret:CARD"}));
assert!(!d.contains("secret:CARD"), "preview must not echo a secret reference: {d}");
}
#[test]
fn approval_required_value_mirrors_confirmation_shape() {
let v = approval_required_value("browser_oob_123", "click", &json!({"ref": "f0e3"}));
let ar = v.get("approval_required").expect("approval_required envelope");
assert_eq!(ar.get("call_id").and_then(Value::as_str), Some("browser_oob_123"));
let opts = ar.get("options").and_then(Value::as_array).expect("options");
let values: Vec<&str> = opts.iter().filter_map(|o| o.get("value").and_then(Value::as_str)).collect();
assert!(values.contains(&"proceed_once") && values.contains(&"cancel"));
}
#[test]
fn act_flatten_captures_passthrough_params() {
let p: ActParams = serde_json::from_value(json!({"action": "click", "ref": "f0e1", "text": "hi"})).unwrap();
assert_eq!(p.action, "click");
assert_eq!(p.rest.get("ref").and_then(Value::as_str), Some("f0e1"));
assert_eq!(p.rest.get("text").and_then(Value::as_str), Some("hi"));
assert!(!p.rest.contains_key("action"), "flatten must exclude the named action field");
}
}
@@ -0,0 +1,605 @@
//! Channel-domain capabilities (registry form): IM bot lifecycle,
//! pairing/authorization management, and companion binding.
//!
//! These tools let the LLM agent configure remote IM channels on behalf of the
//! user — the headline use case is "set up a Telegram bot and bind it to my
//! work companion" spoken via conversation (no manual UI required).
//!
//! ## Assumed GatewayDeps field
//!
//! ```ignore
//! pub channel_state: nomifun_channel::ChannelRouterState,
//! ```
//!
//! The parent obtains this from `states.channel` (the `ModuleStates.channel`
//! field built by `build_module_states` in `nomifun_app::router::state`).
//! `ChannelRouterState` bundles `Arc<ChannelManager>`, `Arc<PairingService>`,
//! `Arc<SessionManager>`, `Arc<dyn IChannelRepository>`, `Arc<PluginFactory>`,
//! `Arc<ChannelSettingsService>`, `Option<Arc<dyn MasterAgentProfile>>`, and
//! `ExtensionRegistry`.
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
// ── param structs ────────────────────────────────────────────────────────
#[derive(Deserialize, JsonSchema)]
struct ListPluginsParams {}
#[derive(Deserialize, JsonSchema)]
struct EnablePluginParams {
/// Platform type of the bot to create/update. Required when creating a new
/// bot (omit `plugin_id`). Supported builtins: "telegram", "discord",
/// "slack", "lark", "dingtalk", "weixin", "matrix", "mattermost",
/// "twitch", "nostr", "qqbot". Extension plugins use their registered id.
#[serde(default)]
plugin_type: Option<String>,
/// Existing channel row id to reconfigure. If omitted, a new bot is
/// created (requires `plugin_type`). When provided, updates config in
/// place.
#[serde(default)]
plugin_id: Option<String>,
/// Companion id to bind this bot to. Messages arriving on the channel will
/// be routed to this companion. Omit or pass null to use the default
/// companion.
#[serde(default)]
companion_id: Option<String>,
/// Platform-specific credentials and configuration as a JSON object.
///
/// The shape is `{ "credentials": { ... }, "config": { ... } }` where:
///
/// **credentials** (required fields depend on platform):
/// - telegram/discord/twitch: `{ "token": "<bot_token>" }`
/// - lark: `{ "token": "<verification_token>", "app_id": "...", "app_secret": "..." }`
/// - dingtalk: `{ "client_id": "...", "client_secret": "..." }`
/// - slack: `{ "token": "<xoxb-bot-token>", "app_token": "<xapp-token>" }`
/// - weixin: `{ "bot_token": "...", "account_id": "..." }`
/// - matrix: `{ "access_token": "...", "homeserver_url": "...", "user_id": "@bot:server" }`
/// - mattermost: `{ "token": "...", "server_url": "https://..." }`
/// - nostr: `{ "nostr_private_key": "<nsec/hex>", "nostr_relays": "wss://r1,wss://r2" }`
/// - qqbot: `{ "client_id": "<appId>", "client_secret": "..." }`
///
/// **config** (optional):
/// - `mode`: connection mode if applicable
/// - `webhook_url`: for platforms that support webhook mode
/// - `require_mention`: whether bot responds only when mentioned
/// - `rate_limit`: messages per minute cap
///
/// Pass the full object; do NOT flatten credentials to the top level.
config: Value,
}
#[derive(Deserialize, JsonSchema)]
struct DisablePluginParams {
/// The channel row id (plugin_id) of the bot to disable. The bot is
/// stopped but its configuration is retained for re-enabling.
plugin_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct DeletePluginParams {
/// The channel row id (plugin_id) of the bot to permanently delete. This
/// stops the bot, removes all its sessions, and deletes the database row.
/// Conversations created through this bot are NOT deleted.
plugin_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct TestPluginParams {
/// The platform identifier for the bot being tested (e.g. "telegram",
/// "lark", "discord", etc.). For an existing channel, use the plugin_id
/// from nomi_channel_list_plugins.
plugin_id: String,
/// Primary credential token for the platform. Meaning varies:
/// - telegram/discord/twitch: bot token
/// - lark: verification token
/// - dingtalk/qqbot: client_id (appId)
/// - slack: xoxb bot token
/// - weixin: bot_token
/// - matrix: access_token
/// - mattermost: bot token
/// - nostr: private key (nsec/hex)
token: String,
/// Additional platform-specific credentials for testing.
#[serde(default)]
extra_config: Option<TestExtraConfig>,
}
/// Additional credentials needed to test specific platforms beyond the primary
/// token.
#[derive(Deserialize, JsonSchema)]
struct TestExtraConfig {
/// Lark/DingTalk/QQBot: app_id or related secondary credential.
#[serde(default)]
app_id: Option<String>,
/// Lark: app_secret; DingTalk/QQBot: client_secret.
#[serde(default)]
app_secret: Option<String>,
/// Slack: xapp- level app token for Socket Mode.
#[serde(default)]
app_token: Option<String>,
/// Matrix: homeserver URL (e.g. "https://matrix.org").
#[serde(default)]
homeserver_url: Option<String>,
/// Matrix: bot user id (e.g. "@bot:matrix.org").
#[serde(default)]
user_id: Option<String>,
/// Mattermost: server URL.
#[serde(default)]
server_url: Option<String>,
/// Nostr: comma-separated relay URLs.
#[serde(default)]
nostr_relays: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct ListPairingsParams {}
#[derive(Deserialize, JsonSchema)]
struct ApprovePairingParams {
/// The pairing code to approve (from nomi_channel_list_pairings).
code: String,
}
#[derive(Deserialize, JsonSchema)]
struct RejectPairingParams {
/// The pairing code to reject (from nomi_channel_list_pairings).
code: String,
}
#[derive(Deserialize, JsonSchema)]
struct ListUsersParams {}
#[derive(Deserialize, JsonSchema)]
struct RevokeUserParams {
/// The internal user id to revoke (from nomi_channel_list_users). This
/// removes authorization and clears all sessions for this user.
user_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct SetCompanionParams {
/// Target a specific bot/channel by its row id. Takes priority over
/// `platform`. The companion binding is scoped to this single bot.
#[serde(default)]
plugin_id: Option<String>,
/// Target all bots of a given platform type (legacy path). Used only when
/// `plugin_id` is not provided. Supported: "telegram", "lark",
/// "dingtalk", "slack", "discord", "weixin", "matrix", "mattermost",
/// "twitch", "nostr", "qqbot".
#[serde(default)]
platform: Option<String>,
/// Companion id to bind. Pass null or omit to clear the binding (reverts
/// to the default companion).
#[serde(default)]
companion_id: Option<String>,
}
// ── handlers ─────────────────────────────────────────────────────────────
async fn list_plugins(deps: Arc<GatewayDeps>, _p: ListPluginsParams) -> Value {
match deps.channel_state.manager.get_plugin_status().await {
Ok(statuses) => ok(statuses),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn enable_plugin(deps: Arc<GatewayDeps>, p: EnablePluginParams) -> Value {
use nomifun_channel::manager::EnableChannelSpec;
// Validate companion binding if provided.
if let Some(companion_id) = p.companion_id.as_deref().filter(|s| !s.is_empty()) {
if let Some(profile) = &deps.channel_state.master_profile {
if !profile.companion_exists(companion_id).await {
return json!({ "error": format!("companion '{}' not found", companion_id) });
}
}
}
let spec = EnableChannelSpec {
plugin_id: p.plugin_id.clone().filter(|s| !s.is_empty()),
plugin_type: p.plugin_type.clone(),
companion_id: p.companion_id.clone(),
};
match deps
.channel_state
.manager
.enable_plugin(&spec, &p.config, deps.channel_state.plugin_factory.as_ref())
.await
{
Ok(channel_id) => ok(json!({
"channel_id": channel_id,
"note": "bot enabled; use nomi_channel_test_plugin to verify credentials connect successfully"
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn disable_plugin(deps: Arc<GatewayDeps>, p: DisablePluginParams) -> Value {
match deps.channel_state.manager.disable_plugin(&p.plugin_id).await {
Ok(()) => ok(json!({ "disabled": true, "plugin_id": p.plugin_id })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn delete_plugin(deps: Arc<GatewayDeps>, p: DeletePluginParams) -> Value {
match deps.channel_state.manager.delete_channel(&p.plugin_id).await {
Ok(()) => json!({ "result": format!("channel {} permanently deleted", p.plugin_id) }),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn test_plugin(deps: Arc<GatewayDeps>, p: TestPluginParams) -> Value {
use nomifun_channel::types::{PluginConfig, PluginCredentials};
let mut credentials = PluginCredentials::default();
let extra = p.extra_config.as_ref();
match p.plugin_id.as_str() {
"lark" => {
credentials.token = Some(p.token.clone());
if let Some(e) = extra {
credentials.app_id = e.app_id.clone();
credentials.app_secret = e.app_secret.clone();
}
}
"dingtalk" => {
credentials.client_id = Some(p.token.clone());
if let Some(e) = extra {
credentials.client_secret = e.app_secret.clone();
}
}
"weixin" => {
credentials.bot_token = Some(p.token.clone());
if let Some(e) = extra {
credentials.account_id = e.app_id.clone();
}
}
"slack" => {
credentials.token = Some(p.token.clone());
if let Some(e) = extra {
credentials.app_token = e.app_token.clone();
}
}
"matrix" => {
credentials.access_token = Some(p.token.clone());
if let Some(e) = extra {
credentials.homeserver_url = e.homeserver_url.clone();
credentials.user_id = e.user_id.clone();
}
}
"mattermost" => {
credentials.token = Some(p.token.clone());
if let Some(e) = extra {
credentials.server_url = e.server_url.clone();
}
}
"twitch" => {
credentials.token = Some(p.token.clone());
}
"nostr" => {
credentials.nostr_private_key = Some(p.token.clone());
if let Some(e) = extra {
credentials.nostr_relays = e.nostr_relays.clone();
}
}
"qqbot" => {
credentials.client_id = Some(p.token.clone());
if let Some(e) = extra {
credentials.client_secret = e.app_secret.clone();
}
}
_ => {
// Default: telegram, discord, and others use generic token.
credentials.token = Some(p.token.clone());
}
}
let config = PluginConfig {
credentials,
config: None,
};
match deps
.channel_state
.manager
.test_plugin(&p.plugin_id, config, deps.channel_state.plugin_factory.as_ref())
.await
{
Ok(bot_username) => ok(json!({
"success": true,
"bot_username": bot_username,
})),
Err(e) => ok(json!({
"success": false,
"error": e.to_string(),
})),
}
}
async fn list_pairings(deps: Arc<GatewayDeps>, _p: ListPairingsParams) -> Value {
match deps.channel_state.pairing_service.get_pending_pairings().await {
Ok(rows) => {
let pairings: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"code": r.code,
"platform_user_id": r.platform_user_id,
"platform_type": r.platform_type,
"channel_id": r.channel_id,
"display_name": r.display_name,
"requested_at": r.requested_at,
"expires_at": r.expires_at,
})
})
.collect();
ok(pairings)
}
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn approve_pairing(deps: Arc<GatewayDeps>, p: ApprovePairingParams) -> Value {
match deps.channel_state.pairing_service.approve_pairing(&p.code).await {
Ok(()) => ok(json!({ "approved": true, "code": p.code })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn reject_pairing(deps: Arc<GatewayDeps>, p: RejectPairingParams) -> Value {
match deps.channel_state.pairing_service.reject_pairing(&p.code).await {
Ok(()) => ok(json!({ "rejected": true, "code": p.code })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn list_users(deps: Arc<GatewayDeps>, _p: ListUsersParams) -> Value {
match deps.channel_state.repo.get_all_users().await {
Ok(rows) => {
let users: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"platform_user_id": r.platform_user_id,
"platform_type": r.platform_type,
"channel_id": r.channel_id,
"display_name": r.display_name,
"authorized_at": r.authorized_at,
"last_active": r.last_active,
})
})
.collect();
ok(users)
}
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn revoke_user(deps: Arc<GatewayDeps>, p: RevokeUserParams) -> Value {
// Clean up sessions first, then delete the user record.
if let Err(e) = deps
.channel_state
.session_manager
.cleanup_user_sessions(&p.user_id)
.await
{
return json!({ "error": format!("failed to clean sessions: {}", e) });
}
match deps.channel_state.repo.delete_user(&p.user_id).await {
Ok(()) => json!({ "result": format!("user {} revoked", p.user_id) }),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn set_companion(deps: Arc<GatewayDeps>, p: SetCompanionParams) -> Value {
let companion_id = p.companion_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
// Validate companion existence if binding (not clearing).
if let Some(cid) = companion_id {
if let Some(profile) = &deps.channel_state.master_profile {
if !profile.companion_exists(cid).await {
return json!({ "error": format!("companion '{}' not found", cid) });
}
}
}
// Per-channel binding (preferred).
if let Some(plugin_id) = p.plugin_id.as_deref().filter(|s| !s.is_empty()) {
return match deps
.channel_state
.manager
.rebind_channel_companion(plugin_id, companion_id)
.await
{
Ok(()) => ok(json!({
"bound": true,
"plugin_id": plugin_id,
"companion_id": companion_id,
"note": "channel sessions cleared; next message starts fresh under new companion"
})),
Err(e) => json!({ "error": e.to_string() }),
};
}
// Legacy platform-wide binding.
let platform_str = match p.platform.as_deref().filter(|s| !s.is_empty()) {
Some(s) => s,
None => {
return json!({ "error": "either plugin_id or platform is required" });
}
};
use nomifun_channel::types::PluginType;
let platform = match PluginType::from_str_opt(platform_str) {
Some(pt) => pt,
None => {
return json!({ "error": format!("invalid platform: {}", platform_str) });
}
};
if let Err(e) = deps
.channel_state
.settings_service
.set_master_agent_companion_id(platform, companion_id)
.await
{
return json!({ "error": e.to_string() });
}
// Clear all sessions so next message starts under the new companion.
if let Err(e) = deps.channel_state.session_manager.clear_all_sessions().await {
return json!({ "error": format!("binding updated but session reset failed: {}", e) });
}
ok(json!({
"bound": true,
"platform": platform_str,
"companion_id": companion_id,
"note": "platform-wide companion binding updated; all channel sessions cleared"
}))
}
// ── registration ─────────────────────────────────────────────────────────
/// Register the channel-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
// 1. List configured channel bots + status (read-only).
out.push(Capability::new::<ListPluginsParams, _, _>(
CapabilityMeta::new(
"nomi_channel_list_plugins",
"channel",
"List all configured IM channel bots (telegram, discord, slack, lark, etc.) with their connection status, companion binding, and authorized user count.",
DangerTier::Read,
),
|deps, _ctx, p| list_plugins(deps, p),
));
// 2. Enable/configure a bot channel (sensitive — writes credentials).
out.push(Capability::new::<EnablePluginParams, _, _>(
CapabilityMeta::new(
"nomi_channel_enable_plugin",
"channel",
"Enable or reconfigure an IM bot channel with platform-specific credentials. Creates a new bot if plugin_id is omitted, updates existing if provided. Optionally binds to a companion.",
DangerTier::Sensitive,
)
.deny_on(&[Surface::Channel, Surface::Remote]),
|deps, _ctx, p| enable_plugin(deps, p),
));
// 3. Disable a bot channel (write — config retained).
out.push(Capability::new::<DisablePluginParams, _, _>(
CapabilityMeta::new(
"nomi_channel_disable_plugin",
"channel",
"Disable an IM bot channel. The bot is stopped but configuration is retained for re-enabling later.",
DangerTier::Write,
),
|deps, _ctx, p| disable_plugin(deps, p),
));
// 4. Delete a bot channel permanently (destructive).
out.push(Capability::new::<DeletePluginParams, _, _>(
CapabilityMeta::new(
"nomi_channel_delete_plugin",
"channel",
"Permanently delete a bot channel: stops the bot, removes all its sessions, and deletes the database row. Conversations created through this bot survive.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, _ctx, p| delete_plugin(deps, p),
));
// 5. Test bot credentials (sensitive — sends a network probe).
out.push(Capability::new::<TestPluginParams, _, _>(
CapabilityMeta::new(
"nomi_channel_test_plugin",
"channel",
"Test IM bot credentials by probing the remote platform API. Returns the resolved bot_username on success. Does NOT persist any config changes.",
DangerTier::Sensitive,
),
|deps, _ctx, p| test_plugin(deps, p),
));
// 6. List pending pairing/authorization requests (read-only).
out.push(Capability::new::<ListPairingsParams, _, _>(
CapabilityMeta::new(
"nomi_channel_list_pairings",
"channel",
"List pending pairing requests from IM users waiting to be authorized to interact with the bot.",
DangerTier::Read,
),
|deps, _ctx, p| list_pairings(deps, p),
));
// 7. Approve a pairing request (write).
out.push(Capability::new::<ApprovePairingParams, _, _>(
CapabilityMeta::new(
"nomi_channel_approve_pairing",
"channel",
"Approve a pending pairing request, granting the IM user authorization to interact with the bot.",
DangerTier::Write,
),
|deps, _ctx, p| approve_pairing(deps, p),
));
// 8. Reject a pairing request (write).
out.push(Capability::new::<RejectPairingParams, _, _>(
CapabilityMeta::new(
"nomi_channel_reject_pairing",
"channel",
"Reject a pending pairing request, denying the IM user access to the bot.",
DangerTier::Write,
),
|deps, _ctx, p| reject_pairing(deps, p),
));
// 9. List authorized users (read-only).
out.push(Capability::new::<ListUsersParams, _, _>(
CapabilityMeta::new(
"nomi_channel_list_users",
"channel",
"List all authorized IM users across all channel bots, including their platform info and last activity.",
DangerTier::Read,
),
|deps, _ctx, p| list_users(deps, p),
));
// 10. Revoke an authorized user (destructive — deletes access + sessions).
out.push(Capability::new::<RevokeUserParams, _, _>(
CapabilityMeta::new(
"nomi_channel_revoke_user",
"channel",
"Revoke an authorized user's access: cleans up all their sessions and deletes the authorization record.",
DangerTier::Destructive,
),
|deps, _ctx, p| revoke_user(deps, p),
));
// 11. Bind a channel bot to a companion (write).
out.push(Capability::new::<SetCompanionParams, _, _>(
CapabilityMeta::new(
"nomi_channel_set_companion",
"channel",
"Bind (or clear) the companion that handles a channel bot's conversations. Per-channel binding (plugin_id) is preferred; platform-wide binding is the legacy fallback. Clears sessions so the next message uses the new companion.",
DangerTier::Write,
),
|deps, _ctx, p| set_companion(deps, p),
));
}
@@ -0,0 +1,314 @@
//! Companion-domain capabilities (registry form): digital companion CRUD,
//! runtime status, shared config, and self-improvement suggestions.
//!
//! These tools let the LLM agent manage the desktop's digital companions on
//! behalf of the user — create/configure/delete companions, inspect their
//! status, and act on self-improvement suggestions generated by the learning
//! subsystem.
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
const DEFAULT_SUGGESTION_LIMIT: i64 = 20;
// ── param structs (single source: schema + runtime) ──────────────────────
#[derive(Deserialize, JsonSchema)]
struct CompanionListParams {}
#[derive(Deserialize, JsonSchema)]
struct CompanionGetParams {
/// The companion id to retrieve (from nomi_companion_list).
id: String,
}
#[derive(Deserialize, JsonSchema)]
struct CompanionCreateParams {
/// Display name for the new companion.
name: String,
/// Character archetype / seed description. This seeds the companion's
/// personality and initial system prompt generation.
character: String,
}
#[derive(Deserialize, JsonSchema)]
struct CompanionUpdateParams {
/// The companion id to update (from nomi_companion_list).
id: String,
/// RFC 7396 merge-patch object. Valid top-level keys: "name" (string),
/// "character" (string), "persona" (object with "preset"/"custom"),
/// "model" (object with "provider_id"/"model"),
/// "appearance" (object — companion window config).
/// Only provided keys are changed; omitted keys are untouched.
patch: Value,
}
#[derive(Deserialize, JsonSchema)]
struct CompanionDeleteParams {
/// The companion id to permanently delete. This removes all associated
/// data (thread, memories attributed to this companion, figure binding).
id: String,
}
#[derive(Deserialize, JsonSchema)]
struct CompanionStatusParams {
/// The companion id. If omitted, returns the default companion's status.
#[serde(default)]
id: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct CompanionGetConfigParams {}
#[derive(Deserialize, JsonSchema)]
struct CompanionUpdateConfigParams {
/// RFC 7396 merge-patch object for the shared companion config.
/// Valid top-level keys: "collect" (object — per-source booleans),
/// "learn" (object — learning schedule), "default_companion_id" (string),
/// "bridge_to_memory_dir" (string | null).
patch: Value,
}
#[derive(Deserialize, JsonSchema)]
struct CompanionListSuggestionsParams {
/// Filter by status: "new", "accepted", or "dismissed". Omit for all.
#[serde(default)]
status: Option<String>,
/// Maximum number of suggestions to return (default 20, clamped to 1..=100).
#[serde(default)]
limit: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct CompanionDecideSuggestionParams {
/// The suggestion id to act on (from nomi_companion_list_suggestions).
id: String,
/// true = accept the suggestion (applies it and awards XP);
/// false = dismiss it. The decision is idempotent — calling again with
/// the same id is a no-op.
accept: bool,
}
// ── handlers ──────────────────────────────────────────────────────────────
async fn list(deps: Arc<GatewayDeps>, _p: CompanionListParams) -> Value {
let companions = deps.companion_service.list_companions().await;
ok(companions)
}
async fn get(deps: Arc<GatewayDeps>, p: CompanionGetParams) -> Value {
match deps.companion_service.get_companion(&p.id).await {
Ok(companion) => ok(companion),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn create(deps: Arc<GatewayDeps>, p: CompanionCreateParams) -> Value {
let name = p.name.trim();
if name.is_empty() {
return json!({ "error": "missing required field: name" });
}
let character = p.character.trim();
if character.is_empty() {
return json!({ "error": "missing required field: character" });
}
match deps.companion_service.create_companion(name, character).await {
Ok(companion) => ok(companion),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn update(deps: Arc<GatewayDeps>, p: CompanionUpdateParams) -> Value {
if !p.patch.is_object() {
return json!({ "error": "patch must be a JSON object" });
}
if p.patch.as_object().map_or(true, |m| m.is_empty()) {
return json!({ "error": "patch must contain at least one field to update" });
}
match deps.companion_service.patch_companion(&p.id, p.patch).await {
Ok(companion) => ok(companion),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn delete(deps: Arc<GatewayDeps>, p: CompanionDeleteParams) -> Value {
match deps.companion_service.delete_companion(&p.id).await {
Ok(()) => json!({ "result": format!("companion {} deleted", p.id) }),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn status(deps: Arc<GatewayDeps>, p: CompanionStatusParams) -> Value {
let result = match p.id {
Some(ref id) => deps.companion_service.companion_status(id).await,
None => deps.companion_service.status().await,
};
match result {
Ok(s) => ok(s),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn get_config(deps: Arc<GatewayDeps>, _p: CompanionGetConfigParams) -> Value {
let config = deps.companion_service.get_config().await;
ok(config)
}
async fn update_config(deps: Arc<GatewayDeps>, p: CompanionUpdateConfigParams) -> Value {
if !p.patch.is_object() {
return json!({ "error": "patch must be a JSON object" });
}
if p.patch.as_object().map_or(true, |m| m.is_empty()) {
return json!({ "error": "patch must contain at least one field to update" });
}
match deps.companion_service.patch_config(p.patch).await {
Ok(config) => ok(config),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn list_suggestions(deps: Arc<GatewayDeps>, p: CompanionListSuggestionsParams) -> Value {
let limit = p.limit.unwrap_or(DEFAULT_SUGGESTION_LIMIT).clamp(1, 100);
match deps
.companion_service
.list_suggestions(p.status.as_deref(), limit)
.await
{
Ok(suggestions) => ok(suggestions),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn decide_suggestion(deps: Arc<GatewayDeps>, p: CompanionDecideSuggestionParams) -> Value {
match deps
.companion_service
.decide_suggestion(&p.id, p.accept)
.await
{
Ok(suggestion) => ok(suggestion),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ── registration ─────────────────────────────────────────────────────────
/// Register the companion-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
// 1. List companions (read)
out.push(Capability::new::<CompanionListParams, _, _>(
CapabilityMeta::new(
"nomi_companion_list",
"companion",
"List all digital companions (id, name, character, model, appearance).",
DangerTier::Read,
),
|deps, _ctx, p| list(deps, p),
));
// 2. Get companion (read)
out.push(Capability::new::<CompanionGetParams, _, _>(
CapabilityMeta::new(
"nomi_companion_get",
"companion",
"Get a single companion's full profile and configuration by id.",
DangerTier::Read,
),
|deps, _ctx, p| get(deps, p),
));
// 3. Create companion (write)
out.push(Capability::new::<CompanionCreateParams, _, _>(
CapabilityMeta::new(
"nomi_companion_create",
"companion",
"Create a new digital companion with a name and character description. The model must be configured separately via nomi_companion_update.",
DangerTier::Write,
),
|deps, _ctx, p| create(deps, p),
));
// 4. Update companion (write)
out.push(Capability::new::<CompanionUpdateParams, _, _>(
CapabilityMeta::new(
"nomi_companion_update",
"companion",
"Partially update a companion's profile (name, character, persona, model, appearance). Pass an RFC 7396 merge-patch object.",
DangerTier::Write,
),
|deps, _ctx, p| update(deps, p),
));
// 5. Delete companion (destructive, deny on channel)
out.push(Capability::new::<CompanionDeleteParams, _, _>(
CapabilityMeta::new(
"nomi_companion_delete",
"companion",
"Permanently delete a companion and all its associated data (thread, figure binding). Irreversible.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, _ctx, p| delete(deps, p),
));
// 6. Companion status (read)
out.push(Capability::new::<CompanionStatusParams, _, _>(
CapabilityMeta::new(
"nomi_companion_status",
"companion",
"Get a companion's runtime status (XP, level, mood, memory counts, suggestion counts, model readiness). Omit id for the default companion.",
DangerTier::Read,
),
|deps, _ctx, p| status(deps, p),
));
// 7. Get shared config (read)
out.push(Capability::new::<CompanionGetConfigParams, _, _>(
CapabilityMeta::new(
"nomi_companion_get_config",
"companion",
"Read the shared companion configuration (collection toggles, learning schedule, default companion id).",
DangerTier::Read,
),
|deps, _ctx, p| get_config(deps, p),
));
// 8. Update shared config (write)
out.push(Capability::new::<CompanionUpdateConfigParams, _, _>(
CapabilityMeta::new(
"nomi_companion_update_config",
"companion",
"Partially update the shared companion configuration (collection toggles, learning schedule, default companion, memory bridge). RFC 7396 merge-patch.",
DangerTier::Write,
),
|deps, _ctx, p| update_config(deps, p),
));
// 9. List suggestions (read)
out.push(Capability::new::<CompanionListSuggestionsParams, _, _>(
CapabilityMeta::new(
"nomi_companion_list_suggestions",
"companion",
"List self-improvement suggestions generated by the companion's learning subsystem. Filter by status (new/accepted/dismissed).",
DangerTier::Read,
),
|deps, _ctx, p| list_suggestions(deps, p),
));
// 10. Decide suggestion (write)
out.push(Capability::new::<CompanionDecideSuggestionParams, _, _>(
CapabilityMeta::new(
"nomi_companion_decide_suggestion",
"companion",
"Accept or dismiss a self-improvement suggestion. Accepting awards XP to all companions. The decision is idempotent.",
DangerTier::Write,
),
|deps, _ctx, p| decide_suggestion(deps, p),
));
}
@@ -0,0 +1,288 @@
//! Computer-use domain capabilities (registry form, feature-gated). Lets a
//! Remote/companion agent drive the local desktop — a thin facade over the
//! in-tree `nomi_computer::ComputerTool`, mirroring the inward
//! `mcp-computer-stdio` bridge (same 14 discrete tools, same action mapping,
//! zero duplicated logic). Only compiled with the `computer-use` feature.
//!
//! DangerTier: observe/screenshot/cursor_position/list_windows/wait are `Read`;
//! input-synthesis actions (click/type/key/scroll/launch/…) are `Write` — which
//! is Allowed on every surface incl. Remote, so an external "外部伙伴" can drive
//! the desktop (same posture as the browser `act` tools).
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::computer_registry::{ComputerRegistry, tool_result_to_value};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier};
fn registry(deps: &GatewayDeps) -> Result<&ComputerRegistry, Value> {
deps.computer_registry
.as_ref()
.ok_or_else(|| json!({ "error": "computer-use is not available on this host" }))
}
async fn run(deps: &GatewayDeps, input: Value) -> Value {
match registry(deps) {
Ok(reg) => tool_result_to_value(reg.execute(input).await),
Err(e) => e,
}
}
// ---- parameter structs (lifted from the inward computer_stdio bridge) -------
#[derive(Deserialize, JsonSchema)]
struct NoParams {}
#[derive(Deserialize, JsonSchema)]
struct RefParams {
/// Element number `[ref]` from the most recent `nomi_computer_snapshot`.
r#ref: u32,
}
#[derive(Deserialize, JsonSchema)]
struct SetValueParams {
/// Element number `[ref]` from the most recent snapshot.
r#ref: u32,
/// The text to set into the element.
text: String,
}
#[derive(Deserialize, JsonSchema)]
struct XyParams {
/// X coordinate in pixels of the most recent screenshot.
x: i64,
/// Y coordinate in pixels of the most recent screenshot.
y: i64,
}
#[derive(Deserialize, JsonSchema)]
struct TypeParams {
/// The text to type into the focused control.
text: String,
}
#[derive(Deserialize, JsonSchema)]
struct KeyParams {
/// Key or combo to press, e.g. "enter" or "ctrl+a".
key: String,
}
#[derive(Deserialize, JsonSchema)]
struct ScrollParams {
/// Scroll direction: up, down, left, or right.
direction: String,
/// Wheel clicks (default 3).
#[serde(default)]
amount: Option<i64>,
/// Optional X to scroll at (screenshot pixels).
#[serde(default)]
x: Option<i64>,
/// Optional Y to scroll at (screenshot pixels).
#[serde(default)]
y: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct LaunchParams {
/// What to open: a URL (https://…), a file/folder path, or an app name.
target: String,
/// Optional application to open the target WITH (e.g. app="msedge").
#[serde(default)]
app: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct ScreenshotParams {
/// Optional display index to capture (default: primary).
#[serde(default)]
display: Option<u64>,
}
#[derive(Deserialize, JsonSchema)]
struct WaitParams {
/// Seconds to wait (max 5).
#[serde(default)]
seconds: Option<f64>,
}
// ---- handlers (forward to the shared tool's action dispatcher) --------------
async fn snapshot(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: NoParams) -> Value {
run(&deps, json!({ "action": "observe" })).await
}
async fn screenshot(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ScreenshotParams) -> Value {
run(&deps, json!({ "action": "screenshot", "display": p.display })).await
}
async fn click(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RefParams) -> Value {
run(&deps, json!({ "action": "click_element", "ref": p.r#ref })).await
}
async fn right_click(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RefParams) -> Value {
run(&deps, json!({ "action": "right_click_element", "ref": p.r#ref })).await
}
async fn double_click(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RefParams) -> Value {
run(&deps, json!({ "action": "double_click_element", "ref": p.r#ref })).await
}
async fn set_value(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: SetValueParams) -> Value {
run(&deps, json!({ "action": "set_element_value", "ref": p.r#ref, "text": p.text })).await
}
async fn click_xy(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: XyParams) -> Value {
run(&deps, json!({ "action": "left_click", "x": p.x, "y": p.y })).await
}
async fn type_text(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: TypeParams) -> Value {
run(&deps, json!({ "action": "type", "text": p.text })).await
}
async fn key(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: KeyParams) -> Value {
run(&deps, json!({ "action": "key", "key": p.key })).await
}
async fn scroll(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ScrollParams) -> Value {
run(
&deps,
json!({ "action": "scroll", "direction": p.direction, "amount": p.amount, "x": p.x, "y": p.y }),
)
.await
}
async fn launch(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: LaunchParams) -> Value {
run(&deps, json!({ "action": "launch", "target": p.target, "app": p.app })).await
}
async fn list_windows(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: NoParams) -> Value {
run(&deps, json!({ "action": "list_windows" })).await
}
async fn cursor_position(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: NoParams) -> Value {
run(&deps, json!({ "action": "cursor_position" })).await
}
async fn wait(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: WaitParams) -> Value {
run(&deps, json!({ "action": "wait", "seconds": p.seconds })).await
}
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<NoParams, _, _>(
CapabilityMeta::new(
"nomi_computer_snapshot",
"computer",
"Read the desktop accessibility tree (windows → controls, numbered [ref] + Set-of-Marks overlay). Do this first, then act on a [ref]. Re-run after any UI change. Read-only.",
DangerTier::Read,
),
snapshot,
));
out.push(Capability::new::<ScreenshotParams, _, _>(
CapabilityMeta::new(
"nomi_computer_screenshot",
"computer",
"Capture the screen as a PNG (optional `display` index).",
DangerTier::Read,
),
screenshot,
));
out.push(Capability::new::<RefParams, _, _>(
CapabilityMeta::new(
"nomi_computer_click",
"computer",
"Activate the element with the given `ref` from the latest snapshot.",
DangerTier::Write,
),
click,
));
out.push(Capability::new::<RefParams, _, _>(
CapabilityMeta::new(
"nomi_computer_right_click",
"computer",
"Right-click the element with the given `ref` (opens its context menu).",
DangerTier::Write,
),
right_click,
));
out.push(Capability::new::<RefParams, _, _>(
CapabilityMeta::new(
"nomi_computer_double_click",
"computer",
"Double-click the element with the given `ref`.",
DangerTier::Write,
),
double_click,
));
out.push(Capability::new::<SetValueParams, _, _>(
CapabilityMeta::new(
"nomi_computer_set_value",
"computer",
"Set the `text` value of the element with the given `ref` (good for text fields).",
DangerTier::Write,
),
set_value,
));
out.push(Capability::new::<XyParams, _, _>(
CapabilityMeta::new(
"nomi_computer_click_xy",
"computer",
"Left-click at pixel coordinates (`x`, `y`) of the most recent screenshot. Prefer click-by-ref when possible.",
DangerTier::Write,
),
click_xy,
));
out.push(Capability::new::<TypeParams, _, _>(
CapabilityMeta::new(
"nomi_computer_type",
"computer",
"Type the `text` string into the focused control.",
DangerTier::Write,
),
type_text,
));
out.push(Capability::new::<KeyParams, _, _>(
CapabilityMeta::new(
"nomi_computer_key",
"computer",
"Press a key or combo, e.g. \"enter\" or \"ctrl+a\".",
DangerTier::Write,
),
key,
));
out.push(Capability::new::<ScrollParams, _, _>(
CapabilityMeta::new(
"nomi_computer_scroll",
"computer",
"Scroll in `direction` (up/down/left/right) by `amount` wheel clicks, optionally at (`x`, `y`).",
DangerTier::Write,
),
scroll,
));
out.push(Capability::new::<LaunchParams, _, _>(
CapabilityMeta::new(
"nomi_computer_launch",
"computer",
"Open an application, URL, file, or folder via the OS shell. Always use this instead of shell `start`/`Start-Process`.",
DangerTier::Write,
),
launch,
));
out.push(Capability::new::<NoParams, _, _>(
CapabilityMeta::new(
"nomi_computer_list_windows",
"computer",
"List open windows with ids, titles, positions and sizes.",
DangerTier::Read,
),
list_windows,
));
out.push(Capability::new::<NoParams, _, _>(
CapabilityMeta::new(
"nomi_computer_cursor_position",
"computer",
"Report the mouse cursor position in screenshot coordinates.",
DangerTier::Read,
),
cursor_position,
));
out.push(Capability::new::<WaitParams, _, _>(
CapabilityMeta::new(
"nomi_computer_wait",
"computer",
"Pause for `seconds` (max 5) to let the UI settle.",
DangerTier::Read,
),
wait,
));
}
@@ -0,0 +1,199 @@
//! Confirmation-domain capabilities (registry form): list pending decisions of
//! a driven conversation and resolve one by picking an option.
//!
//! These let a channel "master" agent relay a blocking decision in a worker
//! conversation to the channel user as numbered text and submit the user's
//! pick — the gateway otherwise only exposes a `pending_confirmations` count
//! (`nomi_conversation_status`) with no way to read the options or answer.
use std::sync::Arc;
use nomifun_api_types::ConfirmRequest;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
#[derive(Deserialize, JsonSchema)]
struct ListConfirmationsParams {
/// The id of the conversation whose pending decisions to read.
conversation_id: i64,
}
#[derive(Deserialize, JsonSchema)]
struct ResolveConfirmationParams {
/// The id of the conversation containing the pending decision.
conversation_id: i64,
/// The call_id of the specific pending decision to resolve (from nomi_list_confirmations).
call_id: String,
/// The chosen option's value (a bare option-id string for ACP).
option: String,
}
/// Build the `ConfirmRequest.data` for a resolved option, writing the chosen
/// option under BOTH keys so either backend resolves it: the nomi agent reads
/// `data.get("value")` (and defaults to "cancel" when the key is absent — a
/// bare `Value::String` was therefore silently DENIED), while ACP's
/// `confirm_option_id` reads `option_id` (falling back to `value`). Mirrors the
/// double-key payload IDMM already uses in `nomifun-idmm` probe `inject`.
fn confirm_data(option: &str) -> Value {
json!({ "option_id": option, "value": option })
}
async fn list(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ListConfirmationsParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity"});
}
let id = p.conversation_id.to_string();
let confs = match deps
.conversation_service
.list_confirmations(&ctx.user_id, &id, &deps.task_manager)
.await
{
Ok(c) => c,
Err(e) => return json!({"error": e.to_string()}),
};
ok(json!({
"confirmations": confs
.iter()
.map(|c| json!({
"call_id": c.call_id,
"title": c.title,
"description": c.description,
"options": c
.options
.iter()
.map(|o| json!({"label": o.label, "value": o.value}))
.collect::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
}))
}
async fn resolve(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ResolveConfirmationParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity"});
}
let id = p.conversation_id.to_string();
// Self-confirmation guard: an agent may not resolve a decision in its own
// conversation (that would bypass the human-in-the-loop contract).
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
return json!({
"error": "self_confirmation_forbidden: you cannot resolve a confirmation in your own conversation"
});
}
let req = ConfirmRequest {
msg_id: String::new(),
data: confirm_data(&p.option),
always_allow: false,
};
match deps
.conversation_service
.confirm(&ctx.user_id, &id, &p.call_id, req, &deps.task_manager)
.await
{
Ok(()) => ok(json!({"resolved": p.call_id})),
Err(e) => json!({"error": e.to_string()}),
}
}
/// Register the confirmation-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<ListConfirmationsParams, _, _>(
CapabilityMeta::new(
"nomi_list_confirmations",
"confirmation",
"List the pending decisions (permission / choice dialogs) of a conversation, each with its options. Returns an empty list when no active agent or no pending decisions.",
DangerTier::Read,
),
|deps, ctx, p| list(deps, ctx, p),
));
out.push(Capability::new::<ResolveConfirmationParams, _, _>(
CapabilityMeta::new(
"nomi_resolve_confirmation",
"confirmation",
"Submit the user's pick for a pending decision in a driven conversation. Refused for the caller's own conversation (self-confirmation-forbidden).",
DangerTier::Write,
),
|deps, ctx, p| resolve(deps, ctx, p),
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confirm_data_carries_option_under_both_keys_so_nomi_does_not_read_cancel() {
// REGRESSION: the gateway previously sent ConfirmRequest.data as a bare
// Value::String(option). The nomi agent's confirm reads data.get("value")
// and defaults to "cancel" when absent → every relayed approval on a Nomi
// worker was silently DENIED. The payload must carry the option under
// BOTH keys (nomi reads `value`; ACP's confirm_option_id reads
// `option_id`, falling back to `value`).
let d = confirm_data("proceed_once");
assert_eq!(d.get("value").and_then(|v| v.as_str()), Some("proceed_once"));
assert_eq!(d.get("option_id").and_then(|v| v.as_str()), Some("proceed_once"));
// The nomi consumer's exact read must NOT collapse to cancel.
assert_ne!(
d.get("value").and_then(|v| v.as_str()).unwrap_or("cancel"),
"cancel"
);
}
#[test]
fn bare_string_payload_is_read_as_cancel_by_nomi_consumer() {
// Characterizes WHY the old shape was broken: a bare Value::String is
// invisible to the nomi consumer's `data.get("value")`.
let bare = Value::String("proceed_once".into());
assert_eq!(bare.get("value").and_then(|v| v.as_str()).unwrap_or("cancel"), "cancel");
}
#[test]
fn self_confirmation_guard_forbids_own_conversation() {
let ctx = CallerCtx {
conversation_id: "42".into(),
user_id: "u1".into(),
..Default::default()
};
let id = 42i64.to_string();
let forbidden = !ctx.conversation_id.is_empty() && id == ctx.conversation_id;
assert!(forbidden, "resolving own conversation must be forbidden");
}
#[test]
fn self_confirmation_guard_allows_different_conversation() {
let ctx = CallerCtx {
conversation_id: "42".into(),
user_id: "u1".into(),
..Default::default()
};
let id = 99i64.to_string();
let allowed = ctx.conversation_id.is_empty() || id != ctx.conversation_id;
assert!(allowed, "resolving a different conversation must be allowed");
}
#[test]
fn self_confirmation_guard_allows_when_caller_has_no_conversation() {
let ctx = CallerCtx {
conversation_id: String::new(),
user_id: "u1".into(),
..Default::default()
};
let id = 42i64.to_string();
let allowed = ctx.conversation_id.is_empty() || id != ctx.conversation_id;
assert!(allowed, "empty caller conversation_id must bypass the guard");
}
#[test]
fn missing_user_identity_produces_error() {
// The handlers check ctx.user_id.is_empty() before any deps access.
let ctx = CallerCtx::default();
assert!(ctx.user_id.is_empty());
}
}
@@ -0,0 +1,730 @@
//! Conversation-domain capabilities (registry form): list / status / send /
//! create / update / delete. All self-protection guards from the legacy tool
//! are preserved (no self-injection, no self-model-change, no self-deletion),
//! and nomi sessions still get a model at creation via the shared resolution
//! chain so downstream consumers never see a model-less nomi conversation.
use std::sync::Arc;
use std::time::{Duration, Instant};
use nomifun_api_types::{
CreateConversationRequest, ListConversationsQuery, ListMessagesQuery, SendMessageRequest,
UpdateConversationRequest,
};
use nomifun_common::{AgentType, AppError};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier, ProgressSink, Surface};
use crate::server::ok;
use crate::tools_provider;
const DEFAULT_LIST_LIMIT: u32 = 50;
const DEFAULT_MESSAGE_LIMIT: u32 = 5;
/// Per-message content budget in status output — keeps a busy transcript from
/// blowing up the calling agent's context.
const MESSAGE_SNIPPET_CHARS: usize = 500;
#[derive(Deserialize, JsonSchema)]
struct ListConversationsParams {
/// Maximum number of conversations to return (default 50).
#[serde(default)]
limit: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
struct ConversationStatusParams {
/// The id of the conversation to inspect.
conversation_id: i64,
/// How many recent messages to include (default 5, max 50).
#[serde(default)]
message_limit: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
struct SendToConversationParams {
/// The id of the TARGET conversation (not your own).
conversation_id: i64,
/// The message or task prompt to inject.
content: String,
/// When true the message is hidden from the visible history (use for
/// background task prompts, like AutoWork does).
#[serde(default)]
hidden: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
struct CreateConversationParams {
/// Optional display name for the new conversation.
#[serde(default)]
name: Option<String>,
/// Agent type: "nomi" (default) or "acp". NOT for terminals — any
/// terminal/shell intent must go through nomi_create_terminal instead.
#[serde(default)]
agent_type: Option<String>,
/// ACP backend vendor when agent_type is "acp" (e.g. "claude", "codex", "gemini").
#[serde(default)]
backend: Option<String>,
/// Provider id for nomi sessions (from nomi_list_providers). Omit to
/// auto-resolve: your own companion model → first configured provider.
#[serde(default)]
provider_id: Option<String>,
/// Model id for nomi sessions. Omit to auto-resolve (see provider_id).
#[serde(default)]
model: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct UpdateConversationParams {
/// The id of the conversation to update (from nomi_list_conversations).
conversation_id: i64,
/// New display name (omit to keep).
#[serde(default)]
name: Option<String>,
/// Pin (true) or unpin (false) the conversation in the sidebar.
#[serde(default)]
pinned: Option<bool>,
/// New provider id (nomi conversations only; from nomi_list_providers).
#[serde(default)]
provider_id: Option<String>,
/// New model id (nomi conversations only).
#[serde(default)]
model: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct DeleteConversationParams {
/// The id of the conversation to delete. Confirm the target with the user
/// before calling — deletion also kills its agent and cron bindings.
conversation_id: i64,
}
#[derive(Deserialize, JsonSchema)]
struct AgentRunParams {
/// The goal / task to delegate. A fresh autonomous NomiFun (nomi) agent is
/// spun up to accomplish it end-to-end.
goal: String,
/// Optional absolute workspace directory for the run. Omit for an
/// auto-provisioned temp workspace.
#[serde(default)]
workspace: Option<String>,
/// Optional model id for the agent (provider auto-resolved). Omit to use the
/// default nomi model.
#[serde(default)]
model: Option<String>,
/// Max seconds to wait for completion before returning a `{status:"running"}`
/// handle (default 300, clamped 5..1800). Poll nomi_agent_result afterwards.
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Deserialize, JsonSchema)]
struct AgentResultParams {
/// The conversation id returned by nomi_agent_run.
conversation_id: i64,
}
#[derive(Deserialize, JsonSchema)]
struct WhoamiParams {}
fn error_value(e: AppError) -> Value {
json!({ "error": e.to_string() })
}
fn require_user(ctx: &CallerCtx) -> Result<&str, Value> {
if ctx.user_id.is_empty() {
Err(json!({ "error": "missing caller user identity" }))
} else {
Ok(&ctx.user_id)
}
}
async fn list(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ListConversationsParams) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u,
Err(e) => return e,
};
let query = ListConversationsQuery {
limit: Some(p.limit.unwrap_or(DEFAULT_LIST_LIMIT)),
..Default::default()
};
// Exclude the companion's own work-partner single sessions from the page + total.
let resp = match deps.conversation_service.list(user_id, query, true).await {
Ok(r) => r,
Err(e) => return error_value(e),
};
let mut items = Vec::with_capacity(resp.items.len());
for conv in resp.items {
let runtime = deps.conversation_service.runtime_summary_for(&conv.id.to_string()).await;
items.push(json!({
"id": conv.id,
"name": conv.name,
"agent_type": conv.r#type,
"status": conv.status,
"runtime_state": runtime.state,
"pending_confirmations": runtime.pending_confirmations,
"source": conv.source,
"pinned": conv.pinned,
"is_companion_companion": conv.extra.get("companionSession").and_then(Value::as_bool).unwrap_or(false),
"companion_id": conv.extra.get("companionId").and_then(Value::as_str),
"is_self": conv.id.to_string() == ctx.conversation_id,
"modified_at": conv.modified_at,
}));
}
ok(json!({ "total": resp.total, "conversations": items }))
}
async fn status(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ConversationStatusParams) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u,
Err(e) => return e,
};
let id = p.conversation_id.to_string();
let id = id.as_str();
let conv = match deps.conversation_service.get(user_id, id).await {
Ok(c) => c,
Err(e) => return error_value(e),
};
let runtime = deps.conversation_service.runtime_summary_for(id).await;
let message_limit = p.message_limit.unwrap_or(DEFAULT_MESSAGE_LIMIT).clamp(1, 50);
let messages = match deps
.conversation_service
.list_messages(
user_id,
id,
ListMessagesQuery {
page: Some(1),
page_size: Some(message_limit),
order: Some("desc".to_owned()),
content_mode: None,
cursor: None,
},
)
.await
{
Ok(m) => m,
Err(e) => return error_value(e),
};
let messages_json = match serde_json::to_value(&messages) {
Ok(v) => truncate_message_contents(v),
Err(e) => return json!({ "error": format!("failed to serialize messages: {e}") }),
};
ok(json!({
"id": conv.id,
"name": conv.name,
"agent_type": conv.r#type,
"status": conv.status,
"runtime": runtime,
"recent_messages": messages_json,
}))
}
async fn send(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: SendToConversationParams) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u.to_owned(),
Err(e) => return e,
};
let id = p.conversation_id.to_string();
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
return json!({ "error": "self_injection_forbidden: you cannot send a message into your own conversation" });
}
let req = SendMessageRequest {
content: p.content,
files: vec![],
inject_skills: vec![],
hidden: p.hidden.unwrap_or(false),
origin: Some("companion".into()),
channel_platform: None,
};
match deps.conversation_service.send_message(&user_id, &id, req, &deps.task_manager).await {
Ok(msg_id) => ok(json!({
"msg_id": msg_id,
"note": "message accepted; the target session processes it asynchronously — use nomi_conversation_status to follow progress"
})),
Err(AppError::Conflict(m)) => json!({
"error": format!("busy: the target conversation is already running a turn ({m}); check nomi_conversation_status and retry later")
}),
Err(e) => error_value(e),
}
}
async fn create(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CreateConversationParams) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u.to_owned(),
Err(e) => return e,
};
let agent_type_str = p.agent_type.unwrap_or_else(|| "nomi".to_owned());
if agent_type_str == "terminal" {
return json!({
"error": "terminal sessions are not conversations: use nomi_create_terminal (preset shell | claude | codex | gemini) for any terminal/shell intent"
});
}
let agent_type: AgentType = match serde_json::from_value(json!(agent_type_str)) {
Ok(t) => t,
Err(_) => return json!({ "error": format!("invalid agent_type '{agent_type_str}'") }),
};
let mut extra = json!({});
if let Some(backend) = p.backend {
extra["backend"] = json!(backend);
}
let mut model = None;
let mut model_source = None;
if agent_type == AgentType::Nomi {
match tools_provider::resolve_nomi_model(&deps, &ctx, p.provider_id.as_deref(), p.model.as_deref()).await {
Ok((m, source)) => {
model = Some(m);
model_source = Some(source);
}
Err(e) => return e,
}
}
let req = CreateConversationRequest {
r#type: agent_type,
name: p.name,
model,
source: None,
channel_chat_id: None,
extra,
};
match deps.conversation_service.create(&user_id, req).await {
Ok(resp) => ok(json!({
"id": resp.id,
"name": resp.name,
"agent_type": resp.r#type,
"model": resp.model,
"model_source": model_source,
})),
Err(e) => error_value(e),
}
}
/// Await an agent turn to completion (or until `timeout`), polling every `poll`.
/// The turn is claimed synchronously inside `send_message` before it returns, so
/// `is_processing` is reliably true on the first poll. Returns true if the turn
/// finished, false on timeout (the run keeps going; poll nomi_agent_result). An
/// already-finished turn returns immediately (the first check happens before any
/// sleep). Used both to await an unsubscribable turn (coarse poll) and to let a
/// just-finished turn settle before reading its final message (fine poll).
async fn await_turn(deps: &GatewayDeps, conv_id: &str, timeout: Duration, poll: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
let summary = deps.conversation_service.runtime_summary_for(conv_id).await;
if !summary.is_processing {
return true;
}
if Instant::now() >= deadline {
return false;
}
tokio::time::sleep(poll).await;
}
}
/// Walk a serialized message list (desc-ordered) and return the newest assistant
/// reply text — the first object with `position == "left"` and `type == "text"`,
/// whose `content` is shaped `{"content": "<text>"}`.
fn latest_assistant_text(v: &Value) -> Option<String> {
match v {
Value::Array(arr) => arr.iter().find_map(latest_assistant_text),
Value::Object(map) => {
let is_assistant_text = map.get("position").and_then(Value::as_str) == Some("left")
&& map.get("type").and_then(Value::as_str) == Some("text");
if is_assistant_text
&& let Some(text) = map.get("content").and_then(|c| c.get("content")).and_then(Value::as_str)
{
return Some(text.to_owned());
}
map.values().find_map(latest_assistant_text)
}
_ => None,
}
}
/// Read the final assistant text of a (finished) conversation, if any.
async fn read_final_text(deps: &GatewayDeps, user_id: &str, conv_id: &str) -> Option<String> {
let messages = deps
.conversation_service
.list_messages(
user_id,
conv_id,
ListMessagesQuery {
page: Some(1),
page_size: Some(10),
order: Some("desc".to_owned()),
content_mode: None,
cursor: None,
},
)
.await
.ok()?;
let v = serde_json::to_value(&messages).ok()?;
latest_assistant_text(&v)
}
/// Subscribe to a turn's event stream. The agent instance is built inside
/// `send_message`'s spawned task, so poll briefly for it. `None` if it never
/// appears (caller falls back to polling completion).
async fn subscribe_turn(
deps: &GatewayDeps,
conv_id: &str,
wait: Duration,
) -> Option<tokio::sync::broadcast::Receiver<nomifun_ai_agent::AgentStreamEvent>> {
let deadline = Instant::now() + wait;
loop {
if let Some(agent) = deps.task_manager.get_task(conv_id) {
return Some(agent.subscribe());
}
if Instant::now() >= deadline {
return None;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
/// Forward the turn's events to `progress` until a terminal event
/// (finish/turn_completed/error) or `deadline`. Events are forwarded as their
/// serialized JSON (tagged `{"type": .., "data": ..}`); terminal detection uses
/// the `type` tag so it never couples to the event struct internals. Returns
/// true if the turn finished, false on timeout.
async fn drain_stream(
rx: &mut tokio::sync::broadcast::Receiver<nomifun_ai_agent::AgentStreamEvent>,
progress: &ProgressSink,
deadline: Instant,
) -> bool {
use tokio::sync::broadcast::error::RecvError;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return false;
}
match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Ok(ev)) => {
let v = serde_json::to_value(&ev).unwrap_or_else(|_| json!({}));
let terminal = v
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| matches!(t, "finish" | "turn_completed" | "error"));
let _ = progress.send(v).await;
if terminal {
return true;
}
}
Ok(Err(RecvError::Lagged(_))) => continue,
// Channel closed = the agent instance was dropped at turn end.
Ok(Err(RecvError::Closed)) => return true,
Err(_timeout) => return false,
}
}
}
/// Delegate a goal to a fresh autonomous nomi agent. Streams the agent's
/// events (text / tool-call deltas) through `progress` as they arrive (the
/// streaming `/tool/stream` path); the buffered path returns only the final
/// result. Either way: on completion returns the final assistant text; on
/// timeout returns a `{status:"running"}` handle (poll `nomi_agent_result`).
async fn agent_run(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: AgentRunParams, progress: ProgressSink) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u.to_owned(),
Err(e) => return e,
};
if p.goal.trim().is_empty() {
return json!({ "error": "goal must not be empty" });
}
// A nomi conversation must get a model at creation.
let model = match tools_provider::resolve_nomi_model(&deps, &ctx, None, p.model.as_deref()).await {
Ok((m, _source)) => Some(m),
Err(e) => return e,
};
// yolo: unattended Remote runs have no approval UI — without yolo a tool call
// would park forever. desktopGateway: entitle the delegated agent to the full
// platform tool set (the "外部伙伴" experience). We call create() directly
// (not via the HTTP route), so these extra keys are honored, not stripped.
let mut extra = json!({ "session_mode": "yolo", "desktopGateway": true });
if let Some(ws) = p.workspace.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
extra["workspace"] = json!(ws);
}
let create_req = CreateConversationRequest {
r#type: AgentType::Nomi,
name: Some("Remote agent run".to_owned()),
model,
source: None,
channel_chat_id: None,
extra,
};
let conv = match deps.conversation_service.create(&user_id, create_req).await {
Ok(c) => c,
Err(e) => return error_value(e),
};
let id = conv.id.to_string();
let send_req = SendMessageRequest {
content: p.goal,
files: vec![],
inject_skills: vec![],
hidden: false,
origin: Some("remote".into()),
channel_platform: None,
};
if let Err(e) = deps
.conversation_service
.send_message(&user_id, &id, send_req, &deps.task_manager)
.await
{
return json!({ "error": format!("failed to start agent run: {e}"), "conversation_id": conv.id });
}
let timeout = Duration::from_secs(p.timeout_secs.unwrap_or(300).clamp(5, 1800));
let deadline = Instant::now() + timeout;
// Prefer streaming the live event broadcast; fall back to polling completion
// if the instance can't be subscribed in time. Final text is read from the
// DB either way, so missed early deltas never corrupt the result.
let finished = match subscribe_turn(&deps, &id, Duration::from_secs(5)).await {
Some(mut rx) => drain_stream(&mut rx, &progress, deadline).await,
None => await_turn(&deps, &id, timeout, Duration::from_millis(500)).await,
};
if finished {
// The terminal broadcast event ("finish"/Closed) can fire a few ms before
// the final assistant `text` message is committed: a reasoning model
// persists its visible answer LAST (after the `thinking` message), right
// at turn end, so reading immediately can miss it and return null.
// `nomi_agent_result` never hits this because it gates on the runtime turn
// having fully released (`is_processing == false`), by which point the
// message is listable. Mirror that here — settle (bounded, fine-grained)
// before reading. An already-settled turn returns at once (no added latency).
let _ = await_turn(&deps, &id, Duration::from_secs(5), Duration::from_millis(25)).await;
let text = read_final_text(&deps, &user_id, &id).await;
ok(json!({ "conversation_id": conv.id, "status": "completed", "text": text }))
} else {
ok(json!({
"conversation_id": conv.id,
"status": "running",
"note": "agent run still in progress after timeout; poll nomi_agent_result with this conversation_id"
}))
}
}
/// Fetch the result (or running status) of a delegated agent run.
async fn agent_result(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: AgentResultParams) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u.to_owned(),
Err(e) => return e,
};
let id = p.conversation_id.to_string();
let summary = deps.conversation_service.runtime_summary_for(&id).await;
if summary.is_processing {
return ok(json!({ "conversation_id": p.conversation_id, "status": "running" }));
}
let text = read_final_text(&deps, &user_id, &id).await;
ok(json!({ "conversation_id": p.conversation_id, "status": "completed", "text": text }))
}
/// Reflect the calling session's identity: which companion it is bound to, the
/// surface it arrived on, and the user scope. For an external partner this
/// answers "who am I acting as?"; for tests it proves companion binding reached
/// dispatch.
async fn whoami(_deps: Arc<GatewayDeps>, ctx: CallerCtx, _p: WhoamiParams) -> Value {
ok(json!({
"user_id": ctx.user_id,
"companion_id": ctx.companion_id,
"surface": format!("{:?}", ctx.surface()),
"remote": ctx.remote,
"channel_platform": ctx.channel_platform,
}))
}
async fn update(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: UpdateConversationParams) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u.to_owned(),
Err(e) => return e,
};
let id = p.conversation_id.to_string();
if p.name.is_none() && p.pinned.is_none() && p.provider_id.is_none() && p.model.is_none() {
return json!({ "error": "nothing to update: provide at least one of name / pinned / provider_id+model" });
}
let mut model = None;
if p.provider_id.is_some() || p.model.is_some() {
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
return json!({
"error": "self_model_change_forbidden: changing your own conversation's model would terminate your current turn; the owner can change it from the desktop UI"
});
}
match tools_provider::resolve_explicit_model(&deps, p.provider_id.as_deref(), p.model.as_deref()).await {
Ok(m) => model = Some(m),
Err(e) => return e,
}
}
let model_changed = model.is_some();
let req = UpdateConversationRequest {
name: p.name,
pinned: p.pinned,
model,
extra: None,
};
match deps.conversation_service.update(&user_id, &id, req, &deps.task_manager).await {
Ok(resp) => ok(json!({
"id": resp.id,
"name": resp.name,
"pinned": resp.pinned,
"model": resp.model,
"note": model_changed.then_some(
"model changed: any running task in that conversation was terminated; it restarts with the new model on the next message"
),
})),
Err(e) => error_value(e),
}
}
async fn delete(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: DeleteConversationParams) -> Value {
let user_id = match require_user(&ctx) {
Ok(u) => u.to_owned(),
Err(e) => return e,
};
let id = p.conversation_id.to_string();
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
return json!({ "error": "self_deletion_forbidden: you cannot delete your own conversation" });
}
match deps.conversation_service.delete(&user_id, &id).await {
Ok(()) => ok(json!({ "deleted": id })),
Err(e) => error_value(e),
}
}
/// Cap every `content` string inside the serialized message list so a long
/// transcript cannot flood the calling agent.
fn truncate_message_contents(mut value: Value) -> Value {
fn walk(v: &mut Value) {
match v {
Value::Object(map) => {
for (k, item) in map.iter_mut() {
if k == "content" {
if let Value::String(s) = item
&& s.chars().count() > MESSAGE_SNIPPET_CHARS
{
let truncated: String = s.chars().take(MESSAGE_SNIPPET_CHARS).collect();
*item = Value::String(format!("{truncated}…[truncated]"));
} else {
walk(item);
}
} else {
walk(item);
}
}
}
Value::Array(arr) => {
for item in arr.iter_mut() {
walk(item);
}
}
_ => {}
}
}
walk(&mut value);
value
}
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<ListConversationsParams, _, _>(
CapabilityMeta::new(
"nomi_list_conversations",
"conversation",
"List the desktop's conversations with their live runtime state.",
DangerTier::Read,
),
list,
));
out.push(Capability::new::<ConversationStatusParams, _, _>(
CapabilityMeta::new(
"nomi_conversation_status",
"conversation",
"Runtime summary + the tail of a conversation's transcript (live progress snapshot).",
DangerTier::Read,
),
status,
));
out.push(Capability::new::<SendToConversationParams, _, _>(
CapabilityMeta::new(
"nomi_send_to_conversation",
"conversation",
"Inject a message (or a hidden task prompt) into another session.",
DangerTier::Write,
),
send,
));
out.push(Capability::new::<CreateConversationParams, _, _>(
CapabilityMeta::new(
"nomi_create_conversation",
"conversation",
"Open a fresh desktop session (nomi or acp). nomi sessions get a model at creation.",
DangerTier::Write,
),
create,
));
out.push(Capability::new::<UpdateConversationParams, _, _>(
CapabilityMeta::new(
"nomi_update_conversation",
"conversation",
"Rename / pin / change model of a conversation (not your own model).",
DangerTier::Write,
),
update,
));
out.push(Capability::new::<DeleteConversationParams, _, _>(
CapabilityMeta::new(
"nomi_delete_conversation",
"conversation",
"Delete a conversation (cascades: agent kill, cron unbind, knowledge unmount). Confirm first.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
delete,
));
out.push(Capability::new_streaming::<AgentRunParams, _, _>(
CapabilityMeta::new(
"nomi_agent_run",
"agent",
"Delegate a goal to a fresh autonomous NomiFun agent: spins up a nomi session (yolo, full platform tools), runs it to completion, and returns the final answer. Streams the agent's progress over /tool/stream (or the SSE REST endpoint); buffered callers get the final result. Long runs return a {status:\"running\"} handle — poll nomi_agent_result.",
DangerTier::Write,
),
agent_run,
));
out.push(Capability::new::<AgentResultParams, _, _>(
CapabilityMeta::new(
"nomi_agent_result",
"agent",
"Fetch the result (or running status) of a goal delegated via nomi_agent_run, by conversation id.",
DangerTier::Read,
),
agent_result,
));
out.push(Capability::new::<WhoamiParams, _, _>(
CapabilityMeta::new(
"nomi_whoami",
"conversation",
"Identity of the calling session: the bound companion id, surface (Desktop/Channel/Remote), and user. Lets an external partner confirm which companion it is acting as.",
DangerTier::Read,
),
whoami,
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_caps_long_content_strings() {
let long = "x".repeat(2000);
let v = json!({"items": [{"content": long, "other": "keep"}]});
let out = truncate_message_contents(v);
let content = out["items"][0]["content"].as_str().unwrap();
assert!(content.chars().count() < 600);
assert!(content.ends_with("…[truncated]"));
assert_eq!(out["items"][0]["other"], "keep");
}
#[test]
fn truncate_keeps_short_content_untouched() {
let v = json!({"content": "short"});
let out = truncate_message_contents(v);
assert_eq!(out["content"], "short");
}
}
@@ -0,0 +1,261 @@
//! Cron-domain capabilities (registry form). Create/update reuse the
//! `ICronService` implementation behind the `[CRON_*]` text protocol, so a
//! gateway session gets the same context derivation (agent type / model from
//! the bound conversation) and bind-back behavior as the in-chat protocol.
use std::sync::Arc;
use nomifun_api_types::{ListCronJobsQuery, UpdateConversationRequest};
use nomifun_common::AgentType;
use nomifun_conversation::response_middleware::{
CronCreateParams as SvcCronCreate, CronUpdateParams as SvcCronUpdate, ICronService,
};
use nomifun_cron::types::cron_job_to_response;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
use crate::tools_provider;
#[derive(Deserialize, JsonSchema)]
struct CronListParams {
/// Restrict to jobs bound to one conversation (default: all jobs).
#[serde(default)]
conversation_id: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct CronCreateParams {
/// Short human-readable job name.
name: String,
/// Standard 5-field cron expression, e.g. "0 9 * * *" for daily 09:00.
cron: String,
/// Human-readable description of the schedule (e.g. "every day at 9am").
#[serde(default)]
description: Option<String>,
/// The prompt message sent to the agent on every trigger.
message: String,
/// Conversation to run the job in (default: the calling conversation).
#[serde(default)]
conversation_id: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct CronUpdateParams {
/// The id of the cron job to update (from nomi_cron_list).
job_id: String,
/// New job name (full replacement; pass the existing value to keep it).
name: String,
/// New cron expression (full replacement).
cron: String,
/// New human-readable schedule description.
#[serde(default)]
description: Option<String>,
/// New trigger message (full replacement).
message: String,
/// Conversation the job is bound to (default: the calling conversation).
#[serde(default)]
conversation_id: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct CronDeleteParams {
/// The id of the cron job to delete. Confirm the target with the user first.
job_id: String,
}
/// Duplicate-create guard: an ACTIVE job in the same conversation with the same
/// (trimmed) name or the exact same (trimmed) message counts as a duplicate.
fn is_duplicate_job(existing_name: &str, existing_message: &str, new_name: &str, new_message: &str) -> bool {
existing_name.trim().eq_ignore_ascii_case(new_name.trim()) || existing_message.trim() == new_message.trim()
}
async fn list(deps: Arc<GatewayDeps>, p: CronListParams) -> Value {
let query = ListCronJobsQuery {
conversation_id: p.conversation_id,
};
match deps.cron_service.list_jobs(&query).await {
Ok(jobs) => ok(jobs.iter().map(cron_job_to_response).collect::<Vec<_>>()),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn create(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CronCreateParams) -> Value {
if ctx.user_id.is_empty() {
return json!({ "error": "missing caller user identity" });
}
let target_conv_id = match p.conversation_id.or_else(|| ctx.conversation_id.parse::<i64>().ok()) {
Some(id) => id,
None => {
return json!({ "error": "missing required field: conversation_id (no calling conversation to bind to)" });
}
};
let target_conversation = target_conv_id.to_string();
// ── duplicate guard ──────────────────────────────────────────────
match deps
.cron_service
.list_jobs(&ListCronJobsQuery {
conversation_id: Some(target_conv_id),
})
.await
{
Ok(jobs) => {
if let Some(existing) = jobs
.iter()
.find(|j| j.enabled && is_duplicate_job(&j.name, &j.message, &p.name, &p.message))
{
return ok(json!({
"duplicate": true,
"existing_job": cron_job_to_response(existing),
"note": "an ACTIVE cron job with the same name or message already exists in this conversation — nothing was created. Use nomi_cron_update to modify it; only create a second job if the owner explicitly asked for a duplicate this turn."
}));
}
}
Err(e) => return json!({ "error": e.to_string() }),
}
// ── model guard (nomi conversations only) ────────────────────────
let mut model_note: Option<String> = None;
match deps.conversation_service.get(&ctx.user_id, &target_conversation).await {
Ok(conv) => {
let model_missing = conv.model.as_ref().is_none_or(|m| m.provider_id.trim().is_empty());
if conv.r#type == AgentType::Nomi && model_missing {
match tools_provider::resolve_nomi_model(&deps, &ctx, None, None).await {
Ok((m, source)) => {
let req = UpdateConversationRequest {
name: None,
pinned: None,
model: Some(m.clone()),
extra: None,
};
if let Err(e) = deps
.conversation_service
.update(&ctx.user_id, &target_conversation, req, &deps.task_manager)
.await
{
return json!({ "error": format!("failed to persist auto-selected model onto the bound conversation: {e}") });
}
model_note = Some(format!(
"the bound conversation had no model configured; auto-selected {}/{} (source: {source}) and saved it onto the conversation — mention this to the owner",
m.provider_id, m.model
));
}
Err(e) => return e,
}
}
}
Err(e) => {
return json!({
"error": format!("cannot create the cron job: the bound conversation '{target_conversation}' is not accessible ({e}); a job bound to a missing conversation would never run")
});
}
}
let params = SvcCronCreate {
name: p.name,
schedule: p.cron,
schedule_description: p.description.unwrap_or_default(),
message: p.message,
};
let result = ICronService::create_job(deps.cron_service.as_ref(), &ctx.user_id, &target_conversation, &params).await;
if result.success {
ok(json!({ "message": result.message, "model_note": model_note }))
} else {
json!({ "error": result.message })
}
}
async fn update(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CronUpdateParams) -> Value {
let target_conversation = p
.conversation_id
.map(|id| id.to_string())
.unwrap_or_else(|| ctx.conversation_id.clone());
let params = SvcCronUpdate {
job_id: p.job_id,
name: p.name,
schedule: p.cron,
schedule_description: p.description.unwrap_or_default(),
message: p.message,
};
command_result(ICronService::update_job(deps.cron_service.as_ref(), &ctx.user_id, &target_conversation, &params).await)
}
async fn delete(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CronDeleteParams) -> Value {
command_result(ICronService::delete_job(deps.cron_service.as_ref(), &ctx.user_id, &p.job_id).await)
}
fn command_result(result: nomifun_conversation::response_middleware::CronCommandResult) -> Value {
if result.success {
json!({ "result": result.message })
} else {
json!({ "error": result.message })
}
}
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<CronListParams, _, _>(
CapabilityMeta::new(
"nomi_cron_list",
"cron",
"List scheduled cron jobs (all jobs by default; pass conversation_id to filter to one session).",
DangerTier::Read,
),
|deps, _ctx, p| list(deps, p),
));
out.push(Capability::new::<CronCreateParams, _, _>(
CapabilityMeta::new(
"nomi_cron_create",
"cron",
"Schedule a recurring prompt (cron). Binds to conversation_id or the calling conversation; guards against duplicates and model-less nomi sessions.",
DangerTier::Write,
),
create,
));
out.push(Capability::new::<CronUpdateParams, _, _>(
CapabilityMeta::new(
"nomi_cron_update",
"cron",
"Update a cron job (full replacement of name/cron/message).",
DangerTier::Write,
),
update,
));
out.push(Capability::new::<CronDeleteParams, _, _>(
CapabilityMeta::new(
"nomi_cron_delete",
"cron",
"Delete a cron job. Confirm the target with the user first.",
DangerTier::Destructive,
),
delete,
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_when_name_matches_ignoring_case_and_whitespace() {
assert!(is_duplicate_job("Daily Report", "msg a", " daily report ", "msg b"));
}
#[test]
fn duplicate_when_message_matches_exactly_after_trim() {
assert!(is_duplicate_job("job a", " summarize inbox ", "job b", "summarize inbox"));
}
#[test]
fn not_duplicate_when_both_differ() {
assert!(!is_duplicate_job("job a", "message a", "job b", "message b"));
}
#[test]
fn message_comparison_is_case_sensitive() {
assert!(!is_duplicate_job("job a", "Do The Thing", "job b", "do the thing"));
}
}
@@ -0,0 +1,354 @@
//! Filesystem + shell-open domain capabilities (registry form): file read/write,
//! directory browsing, metadata retrieval, entry removal/rename, and OS-level
//! open (URLs, files, apps).
//!
//! The file service operates within a sandbox defined by `allowed_roots` — all
//! paths are validated against these roots before any I/O. The gateway receives
//! a `FileServiceRef` (= `Arc<dyn IFileService>`) which already performs this
//! validation internally.
//!
//! The shell service wraps OS-native open commands with URL-scheme and path
//! validation (only http/https/mailto, existing paths).
//!
//! PATH SCOPING: The `IFileService` methods that take `extra_root: Option<&Path>`
//! allow callers to widen the sandbox per-request — we pass `None` here (strict
//! mode: only `allowed_roots` configured at construction time apply). Write
//! operations require a `workspace` parameter for event scoping.
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
// ── Constants ────────────────────────────────────────────────────────────────
/// Cap read_file output to avoid blowing up the LLM context.
const READ_FILE_MAX_BYTES: usize = 64 * 1024;
// ── Param structs (single source: schema + runtime) ─────────────────────────
/// Read a file as UTF-8 text.
#[derive(Deserialize, JsonSchema)]
struct ReadFileParams {
/// Absolute path to the file to read.
path: String,
}
/// Write (create or overwrite) a file with the given content.
#[derive(Deserialize, JsonSchema)]
struct WriteFileParams {
/// Absolute path to the file to write.
path: String,
/// UTF-8 text content to write.
content: String,
/// Workspace root directory (used for event scoping). Must be an
/// allowed root or ancestor of `path`.
workspace: String,
}
/// List immediate children of a directory.
#[derive(Deserialize, JsonSchema)]
struct BrowseParams {
/// Absolute path to the directory to list.
dir: String,
/// Workspace root used to compute relative paths in the response.
root: String,
}
/// Recursively list all files under a workspace root as a flat list.
#[derive(Deserialize, JsonSchema)]
struct ListWorkspaceFilesParams {
/// Absolute path to the workspace root.
root: String,
}
/// Get metadata (name, size, mime, last_modified, is_directory) for a path.
#[derive(Deserialize, JsonSchema)]
struct GetMetadataParams {
/// Absolute path to the file or directory.
path: String,
}
/// Delete a file or directory (recursively).
#[derive(Deserialize, JsonSchema)]
struct RemoveParams {
/// Absolute path to the file or directory to remove.
path: String,
/// Workspace root (used for event scoping).
workspace: String,
}
/// Rename a file or directory (same parent, new name).
#[derive(Deserialize, JsonSchema)]
struct RenameParams {
/// Absolute path to the file or directory to rename.
path: String,
/// New name (just the filename, not a full path).
new_name: String,
}
/// Open a URL in the default browser (http/https/mailto only).
#[derive(Deserialize, JsonSchema)]
struct ShellOpenExternalParams {
/// URL to open (must be http://, https://, or mailto:).
url: String,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
async fn read_file(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ReadFileParams) -> Value {
match deps.file_service.read_file(&p.path, None).await {
Ok(Some(content)) => {
if content.len() > READ_FILE_MAX_BYTES {
let truncated = &content[..content.floor_char_boundary(READ_FILE_MAX_BYTES)];
ok(json!({
"content": truncated,
"truncated": true,
"total_bytes": content.len(),
"note": format!("output capped at ~{}KB; file is {} bytes total", READ_FILE_MAX_BYTES / 1024, content.len()),
}))
} else {
ok(json!({ "content": content, "truncated": false }))
}
}
Ok(None) => json!({ "error": format!("file not found: {}", p.path) }),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn write_file(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: WriteFileParams) -> Value {
match deps
.file_service
.write_file(&p.path, p.content.as_bytes(), &p.workspace)
.await
{
Ok(_) => ok(json!({ "written": true, "path": p.path })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn browse(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: BrowseParams) -> Value {
match deps.file_service.get_files_by_dir(&p.dir, &p.root).await {
Ok(entries) => {
let items: Vec<Value> = entries
.iter()
.map(|e| {
json!({
"name": e.name,
"full_path": e.full_path,
"relative_path": e.relative_path,
"is_dir": e.is_dir,
})
})
.collect();
ok(json!({ "entries": items, "count": items.len() }))
}
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn list_workspace_files(
deps: Arc<GatewayDeps>,
_ctx: CallerCtx,
p: ListWorkspaceFilesParams,
) -> Value {
match deps.file_service.list_workspace_files(&p.root).await {
Ok(files) => {
let items: Vec<Value> = files
.iter()
.map(|f| {
json!({
"name": f.name,
"full_path": f.full_path,
"relative_path": f.relative_path,
})
})
.collect();
ok(json!({ "files": items, "count": items.len() }))
}
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn get_metadata(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: GetMetadataParams) -> Value {
match deps.file_service.get_file_metadata(&p.path, None).await {
Ok(meta) => ok(json!({
"name": meta.name,
"path": meta.path,
"size": meta.size,
"mime_type": meta.mime_type,
"last_modified": meta.last_modified,
"is_directory": meta.is_directory,
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn remove(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RemoveParams) -> Value {
match deps.file_service.remove_entry(&p.path, &p.workspace).await {
Ok(()) => ok(json!({ "removed": true, "path": p.path })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn rename(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RenameParams) -> Value {
match deps.file_service.rename_entry(&p.path, &p.new_name).await {
Ok(new_path) => ok(json!({ "renamed": true, "new_path": new_path })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn shell_open_external(
deps: Arc<GatewayDeps>,
_ctx: CallerCtx,
p: ShellOpenExternalParams,
) -> Value {
match deps.shell_service.open_external(&p.url).await {
Ok(()) => ok(json!({ "opened": true, "url": p.url })),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ── Registration ─────────────────────────────────────────────────────────────
/// Register the filesystem + shell-open domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
// 1. Read file (Read)
out.push(Capability::new::<ReadFileParams, _, _>(
CapabilityMeta::new(
"nomi_fs_read_file",
"files",
"Read a file as UTF-8 text (output capped at ~64KB). Returns the content or an error if the path is outside the sandbox or does not exist.",
DangerTier::Read,
),
|deps, ctx, p| read_file(deps, ctx, p),
));
// 2. Write file (Write, deny_on Channel)
out.push(Capability::new::<WriteFileParams, _, _>(
CapabilityMeta::new(
"nomi_fs_write_file",
"files",
"Write (create or overwrite) a file with the given UTF-8 content. The path must be within an allowed workspace root.",
DangerTier::Write,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| write_file(deps, ctx, p),
));
// 3. Browse directory (Read)
out.push(Capability::new::<BrowseParams, _, _>(
CapabilityMeta::new(
"nomi_fs_browse",
"files",
"List immediate children of a directory (one level). Returns name, full_path, relative_path, and is_dir for each entry.",
DangerTier::Read,
),
|deps, ctx, p| browse(deps, ctx, p),
));
// 4. List workspace files (Read)
out.push(Capability::new::<ListWorkspaceFilesParams, _, _>(
CapabilityMeta::new(
"nomi_fs_list_workspace_files",
"files",
"Recursively list all files under a workspace root as a flat list (up to 20,000 entries). Useful for discovering project structure.",
DangerTier::Read,
),
|deps, ctx, p| list_workspace_files(deps, ctx, p),
));
// 5. Get metadata (Read)
out.push(Capability::new::<GetMetadataParams, _, _>(
CapabilityMeta::new(
"nomi_fs_get_metadata",
"files",
"Get metadata for a file or directory: name, path, size (bytes), MIME type, last_modified (unix timestamp), and whether it is a directory.",
DangerTier::Read,
),
|deps, ctx, p| get_metadata(deps, ctx, p),
));
// 6. Remove entry (Destructive, deny_on Channel)
out.push(Capability::new::<RemoveParams, _, _>(
CapabilityMeta::new(
"nomi_fs_remove",
"files",
"Delete a file or directory (recursively). Irreversible — the snapshot system can restore if initialized, but the raw FS deletion cannot be undone.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| remove(deps, ctx, p),
));
// 7. Rename entry (Write, deny_on Channel)
out.push(Capability::new::<RenameParams, _, _>(
CapabilityMeta::new(
"nomi_fs_rename",
"files",
"Rename a file or directory (same parent, new name). Returns the new absolute path on success.",
DangerTier::Write,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| rename(deps, ctx, p),
));
// 8. Shell open external (Write, deny_on Channel)
out.push(Capability::new::<ShellOpenExternalParams, _, _>(
CapabilityMeta::new(
"nomi_shell_open_external",
"files",
"Open a URL in the default browser or mail client. Only http://, https://, and mailto: schemes are allowed.",
DangerTier::Write,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| shell_open_external(deps, ctx, p),
));
}
// ── SKIPPED tools ────────────────────────────────────────────────────────────
//
// The following candidate tools were evaluated but NOT registered:
//
// - `nomi_fs_copy_files_to_workspace` — `copy_files_to_workspace` requires a
// `source_root` and multiple file paths; more of a bulk UI operation than an
// agent tool. Can be added later if needed.
//
// - `nomi_fs_create_temp_file` / `nomi_fs_create_upload_file` — temp/upload
// helpers used by the UI upload flow and conversation attachments. The agent
// can use `nomi_fs_write_file` for workspace files directly.
//
// - `nomi_fs_get_image_base64` / `nomi_fs_fetch_remote_image` — image
// processing helpers. Could be added as a separate "media" domain if agents
// need inline image data.
//
// - `nomi_fs_create_zip` / `nomi_fs_cancel_zip` — ZIP packaging operations.
// Not a typical agent primitive; can be added if agent workflows need
// archiving.
//
// - `nomi_shell_open_file` — `ShellService::open_file` opens a local file with
// its default application. Could be added but overlaps with `open_external`
// for most use cases and has less clear agent utility.
//
// - `nomi_shell_show_in_folder` — `ShellService::show_item_in_folder` reveals
// an item in Finder/Explorer. Pure UI convenience; low agent utility.
//
// - `nomi_shell_launch` — `ShellService::launch` opens arbitrary targets
// (apps, URLs, files) via the OS. Too broad for unsupervised agent use
// (accepts any target string). If needed, can be added with Destructive tier.
//
// - `nomi_shell_open_folder_with` — `ShellService::open_folder_with` opens a
// folder in VSCode/Terminal/Explorer. Agent-facing utility is limited; prefer
// explicit terminal session creation via the terminal domain.
//
// - `nomi_shell_check_tool_installed` — `ShellService::check_tool_installed`
// checks if VSCode/Terminal/Explorer is available. Informational but narrow.
//
// FileWatchService and SnapshotService methods are NOT included — they are
// session-lifecycle services (start/stop watch, git-style staging) that belong
// to the UI interaction layer, not agent tool primitives.
@@ -0,0 +1,193 @@
//! IDMM (Intelligent Decision-Making Mode) capabilities (registry form):
//! read + set the per-session supervision config for a conversation or terminal
//! target.
//!
//! Clean migration of `tools_idmm.rs` onto the capability registry. The typed
//! params structs are now the single source (schema + runtime deserialization).
//! Handler logic is identical to the legacy: overlay onto the previously
//! persisted config so unexposed knobs (fault watch / strategy / budget) keep
//! their values; the gateway exposes the DECISION watch's core knobs
//! (enabled / tier / freeform policy).
use std::sync::Arc;
use nomifun_api_types::{IdmmTargetKind, WatchTier};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::{ok, require_user};
// ─── Params ──────────────────────────────────────────────────────────────────
#[derive(Deserialize, JsonSchema)]
struct SetIdmmParams {
/// Target kind: "conversation" or "terminal".
kind: String,
/// The conversation id or terminal id to supervise.
target_id: String,
/// Enable (true) or disable (false) IDMM supervision.
enabled: bool,
/// Escalation tier: "rule" (no-LLM rules only, default) or
/// "rule_plus_sidecar" (adds a backup-model sidecar; requires a
/// steering_prompt and a configured backup provider).
#[serde(default)]
tier: Option<String>,
/// Bounds what the sidecar may decide on the user's behalf. Required
/// (non-empty) for the rule_plus_sidecar tier.
#[serde(default)]
steering_prompt: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct GetIdmmParams {
/// Target kind: "conversation" or "terminal".
kind: String,
/// The conversation id or terminal id to inspect.
target_id: String,
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn parse_kind(raw: &str) -> Result<IdmmTargetKind, Value> {
IdmmTargetKind::parse(raw)
.ok_or_else(|| json!({"error": format!("unknown kind '{raw}' (expected conversation | terminal)")}))
}
/// Terminal targets are ownership-checked (conversation ids are scoped by the
/// conversation service itself; mirrors the REST routes' asymmetry).
async fn verify_terminal(deps: &GatewayDeps, ctx: &CallerCtx, kind: IdmmTargetKind, target_id: &str) -> Option<Value> {
if kind != IdmmTargetKind::Terminal {
return None;
}
let user_id = match require_user(ctx) {
Ok(u) => u.to_owned(),
Err(e) => return Some(e),
};
match deps.idmm_service.verify_terminal_owner(target_id, &user_id).await {
Ok(()) => None,
Err(e) => Some(json!({"error": e.to_string()})),
}
}
// ─── Handlers ────────────────────────────────────────────────────────────────
async fn set(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: SetIdmmParams) -> Value {
let kind = match parse_kind(&p.kind) {
Ok(k) => k,
Err(e) => return e,
};
if let Some(err) = verify_terminal(&deps, &ctx, kind, &p.target_id).await {
return err;
}
// Overlay onto the previously persisted config so unexposed knobs
// (fault watch / strategy / budget details) keep their values. The gateway
// exposes the DECISION watch (the agent-facing decision capability).
let mut cfg = match deps.idmm_service.read_config_persisted(kind, &p.target_id).await {
Ok(c) => c.unwrap_or_default(),
Err(e) => return json!({"error": e.to_string()}),
};
cfg.decision_watch.base.enabled = p.enabled;
if let Some(tier) = p.tier.as_deref() {
cfg.decision_watch.base.tier = match tier {
"rule" | "rule_only" => WatchTier::RuleOnly,
"rule_plus_sidecar" | "rule_plus_model" => WatchTier::RulePlusModel,
other => {
return json!({"error": format!("unknown tier '{other}' (expected rule_only | rule_plus_model)")});
}
};
}
if let Some(sp) = p.steering_prompt {
cfg.decision_watch.strategy.freeform_policy = Some(sp);
}
if let Err(e) = deps.idmm_service.save_config(kind, &p.target_id, &cfg).await {
// Typical validation errors: sidecar tier without a steering prompt
// or without a resolvable backup provider — relay them verbatim so
// the agent can fix the call or ask the owner.
return json!({"error": e.to_string()});
}
match deps.idmm_service.build_state(kind, &p.target_id).await {
Ok(state) => ok(state),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn get(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: GetIdmmParams) -> Value {
let kind = match parse_kind(&p.kind) {
Ok(k) => k,
Err(e) => return e,
};
if let Some(err) = verify_terminal(&deps, &ctx, kind, &p.target_id).await {
return err;
}
match deps.idmm_service.build_state(kind, &p.target_id).await {
Ok(state) => ok(state),
Err(e) => json!({"error": e.to_string()}),
}
}
// ─── Registration ────────────────────────────────────────────────────────────
/// Register the IDMM-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<SetIdmmParams, _, _>(
CapabilityMeta::new(
"nomi_set_idmm",
"idmm",
"Update IDMM supervision knobs (enabled / tier / steering prompt) and (re)arm the live supervisor for a conversation or terminal.",
DangerTier::Write,
),
|deps, ctx, p| set(deps, ctx, p),
));
out.push(Capability::new::<GetIdmmParams, _, _>(
CapabilityMeta::new(
"nomi_get_idmm",
"idmm",
"Read the current IDMM config and live supervision state for a conversation or terminal.",
DangerTier::Read,
),
|deps, ctx, p| get(deps, ctx, p),
));
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_kind_accepts_valid_values() {
assert_eq!(parse_kind("conversation").unwrap(), IdmmTargetKind::Conversation);
assert_eq!(parse_kind("terminal").unwrap(), IdmmTargetKind::Terminal);
}
#[test]
fn parse_kind_rejects_unknown() {
let err = parse_kind("unknown").unwrap_err();
let msg = err["error"].as_str().unwrap();
assert!(msg.contains("unknown kind 'unknown'"));
assert!(msg.contains("conversation | terminal"));
}
/// Verify the tier mapping accepts all documented aliases and rejects unknowns.
#[test]
fn tier_mapping_coverage() {
fn map_tier(s: &str) -> Result<WatchTier, String> {
match s {
"rule" | "rule_only" => Ok(WatchTier::RuleOnly),
"rule_plus_sidecar" | "rule_plus_model" => Ok(WatchTier::RulePlusModel),
other => Err(format!("unknown tier '{other}'")),
}
}
assert_eq!(map_tier("rule").unwrap(), WatchTier::RuleOnly);
assert_eq!(map_tier("rule_only").unwrap(), WatchTier::RuleOnly);
assert_eq!(map_tier("rule_plus_sidecar").unwrap(), WatchTier::RulePlusModel);
assert_eq!(map_tier("rule_plus_model").unwrap(), WatchTier::RulePlusModel);
assert!(map_tier("bogus").is_err());
}
}
@@ -0,0 +1,444 @@
//! Knowledge-base capabilities (registry form): catalog (list/create), markdown
//! file writes, AI overview autogen, server-side URL fetch, and the per-target
//! knowledge binding. The intricate per-surface write policy is preserved
//! verbatim from the legacy tool. The `channel_write_enabled` field — previously
//! readable at runtime but ABSENT from the MCP schema (a confirmed drift) — is
//! now a declared param, so the single typed struct fixes the drift.
use std::collections::HashSet;
use std::sync::Arc;
use nomifun_api_types::{KnowledgeSource, KnowledgeSourceEntry, KnowledgeSourceMode};
use nomifun_knowledge::source_url::truncate_to_bytes;
use nomifun_knowledge::{
KnowledgeBinding, UrlFetcher, WriteRequest, WriteSurface, WriteTargetSpec, resolve_write_policy,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
/// Response cap for `nomi_knowledge_fetch_url` markdown bodies.
const FETCH_URL_MAX_BYTES: usize = 64 * 1024;
// ── param structs (single source: schema + runtime) ──────────────────────
#[derive(Deserialize, JsonSchema)]
struct ListBasesParams {
/// Case-insensitive substring filter over base name/description.
#[serde(default)]
query: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct CreateBaseParams {
/// Display name for the new knowledge base.
name: String,
/// Optional description (also auto-generated later if URL sources are given).
#[serde(default)]
description: Option<String>,
/// Optional seed URLs to ingest.
#[serde(default)]
urls: Option<Vec<String>>,
/// Ingestion mode for the seed URLs: "snapshot" (default, fetched once) or "live".
#[serde(default)]
mode: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct WriteFileParams {
/// Target knowledge base id (from nomi_knowledge_list_bases).
kb_id: String,
/// Relative .md path inside the base (no traversal; .md only).
rel_path: String,
/// Markdown document content (written verbatim, not trimmed).
content: String,
}
#[derive(Deserialize, JsonSchema)]
struct AutogenParams {
/// Knowledge base id to (re)generate the AI overview for.
kb_id: String,
/// Overwrite the existing root README too (default false).
#[serde(default)]
overwrite_readme: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
struct FetchUrlParams {
/// The URL to fetch + convert to markdown (SSRF-guarded; no private/loopback targets).
url: String,
}
#[derive(Deserialize, JsonSchema)]
struct GetBindingParams {
/// Target kind: "conversation" | "terminal" | "companion".
kind: String,
/// The target id whose binding to read.
target_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct SetBindingParams {
/// Target kind: "conversation" | "terminal" | "companion".
kind: String,
/// The target id whose binding to set.
target_id: String,
/// Enable (true) or disable (false) the binding.
enabled: bool,
/// Replacement list of bound base ids (omit to keep the current list).
#[serde(default)]
kb_ids: Option<Vec<String>>,
/// Write-back ("回血") switch (omit to keep).
#[serde(default)]
writeback: Option<bool>,
/// Write-back mode: "staged" | "direct" (omit to keep).
#[serde(default)]
writeback_mode: Option<String>,
/// Write-back disposition: "conservative" | "aggressive" (omit to keep).
#[serde(default)]
writeback_eagerness: Option<String>,
/// Allow write-back from external IM channel sessions (omit to keep).
#[serde(default)]
channel_write_enabled: Option<bool>,
}
// ── shared pure helpers (also used by caps_terminal's bind-on-create) ─────
/// Resolve the write surface for a gateway caller.
pub(crate) fn gateway_surface(channel_platform: Option<&str>, companion_id: Option<&str>) -> WriteSurface {
if channel_platform.is_some() {
WriteSurface::ExternalChannel
} else if companion_id.is_some() {
WriteSurface::Companion
} else {
WriteSurface::RegularChat
}
}
fn first_unknown_id<'a>(requested: &'a [String], known: &HashSet<&str>) -> Option<&'a str> {
requested.iter().find(|id| !known.contains(id.as_str())).map(String::as_str)
}
fn unknown_kb_error(id: &str) -> Value {
json!({ "error": format!("unknown knowledge base id '{id}'; call nomi_knowledge_list_bases for valid ids") })
}
/// Reject unknown base ids up front. Shared with `caps_terminal`'s bind-on-create.
pub(crate) async fn ensure_known_kb_ids(deps: &GatewayDeps, ids: &[String]) -> Result<(), Value> {
if ids.is_empty() {
return Ok(());
}
let bases = match deps.knowledge_service.list_bases().await {
Ok(bases) => bases,
Err(e) => return Err(json!({ "error": e.to_string() })),
};
let known: HashSet<&str> = bases.iter().map(|b| b.id.as_str()).collect();
match first_unknown_id(ids, &known) {
Some(bad) => Err(unknown_kb_error(bad)),
None => Ok(()),
}
}
fn create_base_note(snapshot_urls: usize) -> String {
if snapshot_urls == 0 {
return "base created; it only takes effect on a target after nomi_knowledge_set_binding binds it there"
.to_owned();
}
format!(
"库已创建;{snapshot_urls} 条 URL 正在后台抓取快照并自动生成梗概,请勿重复创建。\
稍后可用 nomi_knowledge_list_bases 查看(description 出现即完成);\
若较长时间后 description 仍未出现,说明抓取可能失败——可改用 nomi_knowledge_fetch_url \
自行抓取内容、nomi_knowledge_write_file 落库,再 nomi_knowledge_autogen 生成梗概。\
库仍需 nomi_knowledge_set_binding 绑定到目标后才会生效。"
)
}
/// Parse the optional `urls` + `mode` into a URL [`KnowledgeSource`].
fn parse_url_source(urls: Option<Vec<String>>, mode: Option<&str>) -> Result<Option<KnowledgeSource>, Value> {
let urls: Vec<String> = urls
.unwrap_or_default()
.into_iter()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect();
let mode = match mode {
None | Some("snapshot") => KnowledgeSourceMode::Snapshot,
Some("live") => KnowledgeSourceMode::Live,
Some(other) => {
return Err(json!({ "error": format!("unknown mode '{other}' (expected snapshot | live)") }));
}
};
if urls.is_empty() {
return Ok(None);
}
Ok(Some(KnowledgeSource {
kind: "url".into(),
mode,
entries: urls
.into_iter()
.map(|url| KnowledgeSourceEntry { url, title: None, ..Default::default() })
.collect(),
last_fetched_at: None,
credential_ref: None,
scope: None,
sync: None,
}))
}
async fn fetch_url_with(fetcher: &UrlFetcher, url: &str) -> Value {
match fetcher.fetch_page(url).await {
Ok(page) => {
let capped = page.markdown.len() > FETCH_URL_MAX_BYTES;
let markdown = truncate_to_bytes(&page.markdown, FETCH_URL_MAX_BYTES);
ok(json!({
"url": url,
"final_url": page.final_url,
"title": page.title,
"markdown": markdown,
"truncated": page.truncated || capped,
}))
}
Err(e) => json!({ "error": e.to_string() }),
}
}
// ── handlers ──────────────────────────────────────────────────────────────
async fn list_bases(deps: Arc<GatewayDeps>, p: ListBasesParams) -> Value {
let query = p.query.map(|q| q.to_lowercase());
match deps.knowledge_service.list_bases().await {
Ok(bases) => {
let items: Vec<Value> = bases
.iter()
.filter(|b| {
query
.as_deref()
.is_none_or(|q| b.name.to_lowercase().contains(q) || b.description.to_lowercase().contains(q))
})
.map(|b| {
json!({
"id": b.id,
"name": b.name,
"description": b.description,
"file_count": b.file_count,
"root_exists": b.root_exists,
})
})
.collect();
ok(json!({ "total": items.len(), "bases": items }))
}
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn create_base(deps: Arc<GatewayDeps>, p: CreateBaseParams) -> Value {
let name = p.name.trim().to_owned();
if name.is_empty() {
return json!({ "error": "missing required field: name" });
}
let description = p.description.unwrap_or_default();
let source = match parse_url_source(p.urls, p.mode.as_deref()) {
Ok(s) => s,
Err(e) => return e,
};
let snapshot_urls = source
.as_ref()
.filter(|s| s.mode == KnowledgeSourceMode::Snapshot)
.map(|s| s.entries.len())
.unwrap_or(0);
// `create_base_with_background_fetch` spawns a background fetch task and so
// consumes an owned `Arc<Self>` — clone the service handle for it.
match deps
.knowledge_service
.clone()
.create_base_with_background_fetch(&name, &description, None, source)
.await
{
Ok(info) => ok(json!({
"id": info.id,
"name": info.name,
"description": info.description,
"file_count": info.file_count,
"note": create_base_note(snapshot_urls),
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn write_file(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: WriteFileParams) -> Value {
let surface = gateway_surface(ctx.channel_platform.as_deref(), ctx.companion_id.as_deref());
let (scope, binding) = match (surface, ctx.companion_id.as_deref()) {
(WriteSurface::Companion, Some(cid)) => (
cid.to_owned(),
deps.knowledge_service.get_binding("companion", cid).await.unwrap_or_default(),
),
_ => (
ctx.conversation_id.clone(),
KnowledgeBinding { enabled: true, writeback: true, ..Default::default() },
),
};
let policy = resolve_write_policy(surface, &binding, &scope);
let bound_kb_ids = deps.knowledge_service.resolve_kb_ids_for_cwd("").await;
let req = WriteRequest {
spec: WriteTargetSpec::Path { kb_id: p.kb_id, rel_path: p.rel_path },
content: p.content,
policy,
bound_kb_ids,
};
match deps.knowledge_service.write_document(req).await {
Ok(out) => ok(json!({
"kb_id": out.kb_id,
"rel_path": out.final_rel_path,
"staged": out.staged,
"updated": matches!(out.op, nomifun_knowledge::WriteOp::Update),
"note": "written via the unified write path (placement enforced by session policy); after substantial additions refresh the overview via nomi_knowledge_autogen",
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn autogen(deps: Arc<GatewayDeps>, p: AutogenParams) -> Value {
match deps
.knowledge_service
.generate_overview(&p.kb_id, p.overwrite_readme.unwrap_or(false), None)
.await
{
Ok(outcome) => ok(outcome),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn fetch_url(_deps: Arc<GatewayDeps>, p: FetchUrlParams) -> Value {
fetch_url_with(&UrlFetcher::default(), &p.url).await
}
async fn get_binding(deps: Arc<GatewayDeps>, p: GetBindingParams) -> Value {
match deps.knowledge_service.get_binding(&p.kind, &p.target_id).await {
Ok(binding) => ok(binding),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn set_binding(deps: Arc<GatewayDeps>, p: SetBindingParams) -> Value {
let mut binding = match deps.knowledge_service.get_binding(&p.kind, &p.target_id).await {
Ok(b) => b,
Err(e) => return json!({ "error": e.to_string() }),
};
binding.enabled = p.enabled;
if let Some(ids) = p.kb_ids {
binding.kb_ids = ids;
}
if let Some(wb) = p.writeback {
binding.writeback = wb;
}
if let Some(mode) = p.writeback_mode {
binding.writeback_mode = mode;
}
if let Some(eagerness) = p.writeback_eagerness {
binding.writeback_eagerness = eagerness;
}
if let Some(channel_write) = p.channel_write_enabled {
binding.channel_write_enabled = channel_write;
}
if p.enabled && binding.kb_ids.is_empty() {
return json!({ "error": "kb_ids must not be empty when enabling a binding; call nomi_knowledge_list_bases for valid ids" });
}
if let Err(e) = ensure_known_kb_ids(&deps, &binding.kb_ids).await {
return e;
}
match deps.knowledge_service.set_binding(&p.kind, &p.target_id, binding).await {
Ok(binding) => ok(json!({
"binding": binding,
"note": "binding saved; bases are mounted into the target's workspace at its NEXT task start"
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<ListBasesParams, _, _>(
CapabilityMeta::new("nomi_knowledge_list_bases", "knowledge", "List knowledge bases (optionally filtered).", DangerTier::Read),
|deps, _ctx, p| list_bases(deps, p),
));
out.push(Capability::new::<CreateBaseParams, _, _>(
CapabilityMeta::new("nomi_knowledge_create_base", "knowledge", "Create a new managed knowledge base, optionally seeded with URL sources (fetched in the background).", DangerTier::Write),
|deps, _ctx, p| create_base(deps, p),
));
out.push(Capability::new::<WriteFileParams, _, _>(
CapabilityMeta::new("nomi_knowledge_write_file", "knowledge", "Create/update one markdown document in a base (placement enforced by per-surface policy).", DangerTier::Write),
write_file,
));
out.push(Capability::new::<AutogenParams, _, _>(
CapabilityMeta::new("nomi_knowledge_autogen", "knowledge", "Generate the AI overview (description + root README) for a base.", DangerTier::Write),
|deps, _ctx, p| autogen(deps, p),
));
out.push(Capability::new::<FetchUrlParams, _, _>(
CapabilityMeta::new("nomi_knowledge_fetch_url", "knowledge", "Server-side fetch + HTML→markdown of a URL (SSRF-guarded).", DangerTier::Read),
|deps, _ctx, p| fetch_url(deps, p),
));
out.push(Capability::new::<GetBindingParams, _, _>(
CapabilityMeta::new("nomi_knowledge_get_binding", "knowledge", "Read the knowledge binding for one target (conversation/terminal/companion).", DangerTier::Read),
|deps, _ctx, p| get_binding(deps, p),
));
out.push(Capability::new::<SetBindingParams, _, _>(
CapabilityMeta::new("nomi_knowledge_set_binding", "knowledge", "Set the bound base list / toggle a target's knowledge binding and write-back knobs.", DangerTier::Write),
|deps, _ctx, p| set_binding(deps, p),
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gateway_surface_from_ctx() {
assert!(matches!(gateway_surface(Some("lark"), Some("c1")), WriteSurface::ExternalChannel));
assert!(matches!(gateway_surface(None, Some("c1")), WriteSurface::Companion));
assert!(matches!(gateway_surface(None, None), WriteSurface::RegularChat));
}
#[test]
fn url_source_defaults_to_snapshot_and_filters_blank_urls() {
let src = parse_url_source(Some(vec!["https://e.com/a".into(), " ".into(), "https://e.com/b ".into()]), None)
.unwrap()
.expect("non-empty urls must yield a source");
assert_eq!(src.kind, "url");
assert_eq!(src.mode, KnowledgeSourceMode::Snapshot);
let urls: Vec<&str> = src.entries.iter().map(|e| e.url.as_str()).collect();
assert_eq!(urls, vec!["https://e.com/a", "https://e.com/b"]);
}
#[test]
fn url_source_live_mode_and_unknown_mode() {
let src = parse_url_source(Some(vec!["https://e.com".into()]), Some("live")).unwrap().unwrap();
assert_eq!(src.mode, KnowledgeSourceMode::Live);
let err = parse_url_source(Some(vec!["https://e.com".into()]), Some("weekly")).unwrap_err();
assert!(err["error"].as_str().unwrap().contains("weekly"));
}
#[test]
fn url_source_absent_or_empty_urls_is_none() {
assert!(parse_url_source(None, None).unwrap().is_none());
assert!(parse_url_source(Some(vec![]), None).unwrap().is_none());
assert!(parse_url_source(Some(vec![" ".into()]), None).unwrap().is_none());
}
#[test]
fn first_unknown_id_finds_in_request_order() {
let known: HashSet<&str> = ["kb_a", "kb_b"].into();
assert_eq!(first_unknown_id(&["kb_a".into(), "kb_b".into()], &known), None);
assert_eq!(first_unknown_id(&["kb_a".into(), "kb_x".into()], &known), Some("kb_x"));
}
#[test]
fn create_base_note_signals_background_fetch() {
let note = create_base_note(3);
assert!(note.contains("3 条 URL") && note.contains("后台") && note.contains("请勿重复创建"));
let plain = create_base_note(0);
assert!(plain.contains("nomi_knowledge_set_binding") && !plain.contains("后台"));
}
}
@@ -0,0 +1,429 @@
//! Extended knowledge-base capabilities (registry form): base detail / update /
//! delete, file listing / read / delete, inbox review (list / merge / discard),
//! full-text search, and user tag CRUD. Supplements `caps_knowledge.rs` which
//! owns the core catalog + binding + write + autogen + fetch-url tools.
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
/// Hard cap on `read_file` content returned to the model (256 KiB). Larger
/// documents are truncated with a trailing note so the agent knows it is
/// incomplete and can refine its query.
const READ_FILE_MAX_BYTES: usize = 256 * 1024;
// ── param structs ────────────────────────────────────────────────────────────
#[derive(Deserialize, JsonSchema)]
struct GetBaseParams {
/// Knowledge base id (from nomi_knowledge_list_bases).
kb_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct UpdateBaseParams {
/// Knowledge base id to update.
kb_id: String,
/// New display name (omit to keep).
#[serde(default)]
name: Option<String>,
/// New description (omit to keep; empty string clears it).
#[serde(default)]
description: Option<String>,
/// Replacement tag key list (omit to keep; empty array clears all tags).
#[serde(default)]
tags: Option<Vec<String>>,
}
#[derive(Deserialize, JsonSchema)]
struct DeleteBaseParams {
/// Knowledge base id to delete.
kb_id: String,
/// Also remove the managed directory on disk (default false; only allowed
/// for managed bases).
#[serde(default)]
purge: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
struct ListFilesParams {
/// Knowledge base id whose files to list.
kb_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct ReadFileParams {
/// Knowledge base id.
kb_id: String,
/// Relative .md path inside the base (forward slashes, no traversal).
rel_path: String,
}
#[derive(Deserialize, JsonSchema)]
struct DeleteFileParams {
/// Knowledge base id.
kb_id: String,
/// Relative .md path of the file to delete (forward slashes, no traversal).
rel_path: String,
}
#[derive(Deserialize, JsonSchema)]
struct ListInboxParams {
/// Knowledge base id whose staged inbox to list.
kb_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct MergeInboxParams {
/// Knowledge base id.
kb_id: String,
/// Scope (session id that staged the proposal).
scope: String,
/// Relative .md path of the staged proposal (mirrors the target base path).
rel_path: String,
}
#[derive(Deserialize, JsonSchema)]
struct DiscardInboxParams {
/// Knowledge base id.
kb_id: String,
/// Scope (session id that staged the proposal).
scope: String,
/// Relative .md path of the staged proposal.
rel_path: String,
}
#[derive(Deserialize, JsonSchema)]
struct SearchParams {
/// Knowledge base ids to search (at least one required).
kb_ids: Vec<String>,
/// Free-text query (matched against file paths, headings, and content).
query: String,
/// Maximum results to return (default 20, clamped to 1..=100).
#[serde(default)]
limit: Option<usize>,
}
#[derive(Deserialize, JsonSchema)]
struct ListTagsParams {
// No parameters — lists all tags.
}
#[derive(Deserialize, JsonSchema)]
struct CreateTagParams {
/// Human-readable label for the tag (the key is auto-derived from it).
label: String,
/// Optional color string (hex or named; omit for no color).
#[serde(default)]
color: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct DeleteTagParams {
/// Tag key to delete (from nomi_knowledge_list_tags).
key: String,
}
// ── handlers ─────────────────────────────────────────────────────────────────
async fn get_base(deps: Arc<GatewayDeps>, p: GetBaseParams) -> Value {
match deps.knowledge_service.get_base_info(&p.kb_id).await {
Ok(info) => ok(info),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn update_base(deps: Arc<GatewayDeps>, p: UpdateBaseParams) -> Value {
if p.name.is_none() && p.description.is_none() && p.tags.is_none() {
return json!({"error": "nothing to update: provide at least one of name / description / tags"});
}
match deps
.knowledge_service
.update_base(&p.kb_id, p.name.as_deref(), p.description.as_deref(), p.tags)
.await
{
Ok(info) => ok(json!({
"id": info.id,
"name": info.name,
"description": info.description,
"tags": info.tags,
"file_count": info.file_count,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn delete_base(deps: Arc<GatewayDeps>, p: DeleteBaseParams) -> Value {
let purge = p.purge.unwrap_or(false);
match deps.knowledge_service.delete_base(&p.kb_id, purge).await {
Ok(()) => ok(json!({
"deleted": p.kb_id,
"purged": purge,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn list_files(deps: Arc<GatewayDeps>, p: ListFilesParams) -> Value {
match deps.knowledge_service.list_files(&p.kb_id).await {
Ok(files) => ok(json!({
"kb_id": p.kb_id,
"total": files.len(),
"files": files,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn read_file(deps: Arc<GatewayDeps>, p: ReadFileParams) -> Value {
match deps.knowledge_service.read_file(&p.kb_id, &p.rel_path).await {
Ok(file) => {
let truncated = file.content.len() > READ_FILE_MAX_BYTES;
let content = if truncated {
let bytes = &file.content.as_bytes()[..READ_FILE_MAX_BYTES];
let boundary = bytes.iter().rposition(|&b| b == b'\n').unwrap_or(READ_FILE_MAX_BYTES);
let slice = &file.content[..boundary];
format!("{slice}\n\n[…truncated — {total} bytes total; narrow your query or read in sections]", total = file.content.len())
} else {
file.content
};
ok(json!({
"kb_id": p.kb_id,
"rel_path": file.rel_path,
"content": content,
"size": file.size,
"truncated": truncated,
}))
}
Err(e) => json!({"error": e.to_string()}),
}
}
async fn delete_file(deps: Arc<GatewayDeps>, p: DeleteFileParams) -> Value {
match deps.knowledge_service.delete_file(&p.kb_id, &p.rel_path).await {
Ok(()) => ok(json!({
"deleted": format!("{}/{}", p.kb_id, p.rel_path),
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn list_inbox(deps: Arc<GatewayDeps>, p: ListInboxParams) -> Value {
match deps.knowledge_service.list_inbox(&p.kb_id).await {
Ok(entries) => ok(json!({
"kb_id": p.kb_id,
"total": entries.len(),
"entries": entries,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn merge_inbox(deps: Arc<GatewayDeps>, p: MergeInboxParams) -> Value {
match deps.knowledge_service.merge_inbox(&p.kb_id, &p.scope, &p.rel_path).await {
Ok(result) => ok(json!({
"merged_path": result.merged_path,
"note": "inbox proposal accepted and merged into the base body",
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn discard_inbox(deps: Arc<GatewayDeps>, p: DiscardInboxParams) -> Value {
match deps.knowledge_service.discard_inbox(&p.kb_id, &p.scope, &p.rel_path).await {
Ok(()) => ok(json!({
"discarded": format!("{}/{}/{}", p.kb_id, p.scope, p.rel_path),
"note": "inbox proposal rejected and removed",
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn search(deps: Arc<GatewayDeps>, p: SearchParams) -> Value {
if p.kb_ids.is_empty() {
return json!({"error": "kb_ids must not be empty"});
}
let query = p.query.trim();
if query.is_empty() {
return json!({"error": "query must not be empty"});
}
let limit = p.limit.unwrap_or(20).clamp(1, 100);
match deps.knowledge_service.search_bases(&p.kb_ids, query, limit).await {
Ok(hits) => ok(json!({
"total": hits.len(),
"hits": hits,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn list_tags(deps: Arc<GatewayDeps>, _p: ListTagsParams) -> Value {
match deps.knowledge_service.list_tags().await {
Ok(tags) => ok(json!({
"total": tags.len(),
"tags": tags,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn create_tag(deps: Arc<GatewayDeps>, p: CreateTagParams) -> Value {
let label = p.label.trim();
if label.is_empty() {
return json!({"error": "label must not be empty"});
}
match deps.knowledge_service.create_tag(label, p.color).await {
Ok(tag) => ok(tag),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn delete_tag(deps: Arc<GatewayDeps>, p: DeleteTagParams) -> Value {
match deps.knowledge_service.delete_tag(&p.key).await {
Ok(()) => ok(json!({
"deleted": p.key,
"note": "tag removed from all bases that referenced it",
})),
Err(e) => json!({"error": e.to_string()}),
}
}
// ── registration ─────────────────────────────────────────────────────────────
pub(crate) fn register(out: &mut Vec<Capability>) {
// ── base detail / mutation ────────────────────────────────────────────
out.push(Capability::new::<GetBaseParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_get_base",
"knowledge",
"Get full detail of one knowledge base (id, name, description, source, tags, file count, etc.).",
DangerTier::Read,
),
|deps, _ctx, p| get_base(deps, p),
));
out.push(Capability::new::<UpdateBaseParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_update_base",
"knowledge",
"Update a knowledge base's name, description, or assigned tags.",
DangerTier::Write,
),
|deps, _ctx, p| update_base(deps, p),
));
out.push(Capability::new::<DeleteBaseParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_delete_base",
"knowledge",
"Delete a knowledge base registration (optionally purge its managed directory).",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, _ctx, p| delete_base(deps, p),
));
// ── file access ──────────────────────────────────────────────────────
out.push(Capability::new::<ListFilesParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_list_files",
"knowledge",
"List all markdown files in a knowledge base (paths, sizes, modification times).",
DangerTier::Read,
),
|deps, _ctx, p| list_files(deps, p),
));
out.push(Capability::new::<ReadFileParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_read_file",
"knowledge",
"Read one markdown document from a knowledge base (truncated at 256 KiB).",
DangerTier::Read,
),
|deps, _ctx, p| read_file(deps, p),
));
out.push(Capability::new::<DeleteFileParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_delete_file",
"knowledge",
"Delete one markdown file from a knowledge base.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, _ctx, p| delete_file(deps, p),
));
// ── inbox (staged write-back review) ─────────────────────────────────
out.push(Capability::new::<ListInboxParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_list_inbox",
"knowledge",
"List pending staged write-back proposals (inbox) for a knowledge base.",
DangerTier::Read,
),
|deps, _ctx, p| list_inbox(deps, p),
));
out.push(Capability::new::<MergeInboxParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_merge_inbox",
"knowledge",
"Accept a staged inbox proposal: merge it into the base body and remove the staged copy.",
DangerTier::Write,
),
|deps, _ctx, p| merge_inbox(deps, p),
));
out.push(Capability::new::<DiscardInboxParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_discard_inbox",
"knowledge",
"Reject and remove a staged inbox proposal without merging.",
DangerTier::Write,
),
|deps, _ctx, p| discard_inbox(deps, p),
));
// ── search ───────────────────────────────────────────────────────────
out.push(Capability::new::<SearchParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_search",
"knowledge",
"Full-text search across one or more knowledge bases (ranked by relevance).",
DangerTier::Read,
),
|deps, _ctx, p| search(deps, p),
));
// ── user tags ────────────────────────────────────────────────────────
out.push(Capability::new::<ListTagsParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_list_tags",
"knowledge",
"List all user-defined knowledge tags (key, label, color, sort order).",
DangerTier::Read,
),
|deps, _ctx, p| list_tags(deps, p),
));
out.push(Capability::new::<CreateTagParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_create_tag",
"knowledge",
"Create a new user-defined tag for categorizing knowledge bases.",
DangerTier::Write,
),
|deps, _ctx, p| create_tag(deps, p),
));
out.push(Capability::new::<DeleteTagParams, _, _>(
CapabilityMeta::new(
"nomi_knowledge_delete_tag",
"knowledge",
"Delete a user tag (also strips it from all bases that reference it).",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, _ctx, p| delete_tag(deps, p),
));
}
@@ -0,0 +1,573 @@
//! MCP-servers, Extensions, Skills, and Hub management capabilities.
//!
//! Lets the LLM agent manage the desktop's MCP server registry, enable/disable
//! extensions, import/delete skills, and install extensions from the Hub.
//!
//! ## Assumed GatewayDeps fields (parent must wire):
//!
//! - `mcp_config_service: McpConfigService`
//! Clone of `states.mcp.config_service` (from `McpRouterState`).
//! Crate: `nomifun-mcp`, type: `nomifun_mcp::McpConfigService`.
//!
//! - `extension_registry: ExtensionRegistry`
//! Clone of `states.extension.registry` (from `ExtensionRouterState`).
//! Crate: `nomifun-extension`, type: `nomifun_extension::ExtensionRegistry`.
//!
//! - `hub_installer: HubInstaller`
//! Clone of `states.hub.installer` (from `HubRouterState`).
//! Crate: `nomifun-extension`, type: `nomifun_extension::hub::installer::HubInstaller`.
//!
//! - `hub_index_manager: HubIndexManager`
//! Clone of `states.hub.index_manager` (from `HubRouterState`).
//! Crate: `nomifun-extension`, type: `nomifun_extension::hub::index_manager::HubIndexManager`.
//!
//! - `skill_paths: SkillPaths`
//! Clone of `states.skill.skill_paths` (from `SkillRouterState`).
//! Crate: `nomifun-extension`, type: `nomifun_extension::skill_service::SkillPaths`.
//!
//! ## SKIPPED tools (listed at the bottom of this file):
//!
//! - `nomi_mcp_test_connection` — requires building a `McpServerTransport`
//! from the API `McpTransport` enum (tagged union with three variants), which
//! is awkward to expose in a flat JSON schema for an LLM. The route handler
//! also persists test results back to the config service by server id. Skipped
//! until a clear agent use case emerges.
//!
//! - `nomi_skill_set_tags` — needs `skill_tag_repo` + `builtin_skill_tags`;
//! low agent utility (user-facing tagging).
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
// ══════════════════════════════════════════════════════════════════════════════
// MCP Server param structs
// ══════════════════════════════════════════════════════════════════════════════
#[derive(Deserialize, JsonSchema)]
struct McpListServersParams {}
#[derive(Deserialize, JsonSchema)]
struct McpAddServerParams {
/// Human-readable name for the MCP server (must be unique; existing name = upsert).
name: String,
/// Optional description of the server's purpose.
#[serde(default)]
description: Option<String>,
/// Transport type: "stdio", "sse", or "http".
transport_type: String,
/// For stdio: the command to launch (e.g. "npx").
#[serde(default)]
command: Option<String>,
/// For stdio: arguments to the command.
#[serde(default)]
args: Option<Vec<String>>,
/// For stdio: environment variables as key-value pairs.
#[serde(default)]
env: Option<std::collections::HashMap<String, String>>,
/// For sse/http: the endpoint URL.
#[serde(default)]
url: Option<String>,
/// For sse/http: extra headers as key-value pairs.
#[serde(default)]
headers: Option<std::collections::HashMap<String, String>>,
}
#[derive(Deserialize, JsonSchema)]
struct McpEditServerParams {
/// The MCP server id (from nomi_mcp_list_servers).
id: String,
/// New description (pass null to clear, omit to keep).
#[serde(default)]
description: Option<Option<String>>,
/// New transport type (omit to keep). Must provide matching fields.
#[serde(default)]
transport_type: Option<String>,
/// For stdio: the command (omit to keep).
#[serde(default)]
command: Option<String>,
/// For stdio: arguments (omit to keep).
#[serde(default)]
args: Option<Vec<String>>,
/// For stdio: environment variables (omit to keep).
#[serde(default)]
env: Option<std::collections::HashMap<String, String>>,
/// For sse/http: the endpoint URL (omit to keep).
#[serde(default)]
url: Option<String>,
/// For sse/http: extra headers (omit to keep).
#[serde(default)]
headers: Option<std::collections::HashMap<String, String>>,
}
#[derive(Deserialize, JsonSchema)]
struct McpDeleteServerParams {
/// The MCP server id to permanently delete.
id: String,
}
#[derive(Deserialize, JsonSchema)]
struct McpToggleServerParams {
/// The MCP server id to toggle enabled/disabled.
id: String,
}
// ══════════════════════════════════════════════════════════════════════════════
// Extension param structs
// ══════════════════════════════════════════════════════════════════════════════
#[derive(Deserialize, JsonSchema)]
struct ExtensionListParams {}
#[derive(Deserialize, JsonSchema)]
struct ExtensionEnableParams {
/// Extension name (from nomi_extension_list).
name: String,
}
#[derive(Deserialize, JsonSchema)]
struct ExtensionDisableParams {
/// Extension name (from nomi_extension_list).
name: String,
/// Optional reason for disabling.
#[serde(default)]
reason: Option<String>,
}
// ══════════════════════════════════════════════════════════════════════════════
// Skill param structs
// ══════════════════════════════════════════════════════════════════════════════
#[derive(Deserialize, JsonSchema)]
struct SkillListParams {}
#[derive(Deserialize, JsonSchema)]
struct SkillImportParams {
/// Absolute path to the skill directory to import (by copy).
skill_path: String,
}
#[derive(Deserialize, JsonSchema)]
struct SkillDeleteParams {
/// Skill name to permanently delete (user-custom only).
name: String,
}
// ══════════════════════════════════════════════════════════════════════════════
// Hub param structs
// ══════════════════════════════════════════════════════════════════════════════
#[derive(Deserialize, JsonSchema)]
struct HubListExtensionsParams {}
#[derive(Deserialize, JsonSchema)]
struct HubInstallExtensionParams {
/// Extension name from the Hub index (from nomi_hub_list_extensions).
name: String,
}
// ══════════════════════════════════════════════════════════════════════════════
// MCP Server handlers
// ══════════════════════════════════════════════════════════════════════════════
async fn mcp_list_servers(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: McpListServersParams) -> Value {
match deps.mcp_config_service.list_servers().await {
Ok(servers) => ok(servers),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn mcp_add_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpAddServerParams) -> Value {
use nomifun_api_types::McpTransport;
let transport = match p.transport_type.as_str() {
"stdio" => {
let Some(command) = p.command else {
return json!({ "error": "field 'command' is required for stdio transport" });
};
McpTransport::Stdio {
command,
args: p.args.unwrap_or_default(),
env: p.env.unwrap_or_default(),
}
}
"sse" => {
let Some(url) = p.url else {
return json!({ "error": "field 'url' is required for sse transport" });
};
McpTransport::Sse {
url,
headers: p.headers.unwrap_or_default(),
}
}
"http" => {
let Some(url) = p.url else {
return json!({ "error": "field 'url' is required for http transport" });
};
McpTransport::Http {
url,
headers: p.headers.unwrap_or_default(),
}
}
other => {
return json!({ "error": format!("unsupported transport_type: {other:?}; use \"stdio\", \"sse\", or \"http\"") });
}
};
let req = nomifun_api_types::CreateMcpServerRequest {
name: p.name,
description: p.description,
transport,
original_json: None,
builtin: false,
};
match deps.mcp_config_service.add_server(req).await {
Ok(server) => ok(server),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn mcp_edit_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpEditServerParams) -> Value {
use nomifun_api_types::McpTransport;
let transport = match p.transport_type.as_deref() {
Some("stdio") => {
let Some(command) = p.command else {
return json!({ "error": "field 'command' is required for stdio transport" });
};
Some(McpTransport::Stdio {
command,
args: p.args.unwrap_or_default(),
env: p.env.unwrap_or_default(),
})
}
Some("sse") => {
let Some(url) = p.url else {
return json!({ "error": "field 'url' is required for sse transport" });
};
Some(McpTransport::Sse {
url,
headers: p.headers.unwrap_or_default(),
})
}
Some("http") => {
let Some(url) = p.url else {
return json!({ "error": "field 'url' is required for http transport" });
};
Some(McpTransport::Http {
url,
headers: p.headers.unwrap_or_default(),
})
}
Some(other) => {
return json!({ "error": format!("unsupported transport_type: {other:?}; use \"stdio\", \"sse\", or \"http\"") });
}
None => None,
};
let req = nomifun_api_types::UpdateMcpServerRequest {
name: None,
description: p.description,
transport,
original_json: None,
builtin: None,
};
match deps.mcp_config_service.edit_server(&p.id, req).await {
Ok(server) => ok(server),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn mcp_delete_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpDeleteServerParams) -> Value {
match deps.mcp_config_service.delete_server(&p.id).await {
Ok(was_enabled) => ok(json!({
"deleted": true,
"was_enabled": was_enabled,
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn mcp_toggle_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpToggleServerParams) -> Value {
match deps.mcp_config_service.toggle_server(&p.id).await {
Ok(server) => ok(server),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Extension handlers
// ══════════════════════════════════════════════════════════════════════════════
async fn extension_list(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: ExtensionListParams) -> Value {
let summaries = deps.extension_registry.get_loaded_extensions().await;
let items: Vec<Value> = summaries
.into_iter()
.map(|s| {
json!({
"name": s.name,
"version": s.version,
"display_name": s.display_name,
"description": s.description,
"enabled": s.enabled,
})
})
.collect();
ok(items)
}
async fn extension_enable(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ExtensionEnableParams) -> Value {
match deps.extension_registry.enable_extension(&p.name).await {
Ok(()) => ok(json!({ "enabled": true, "name": p.name })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn extension_disable(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ExtensionDisableParams) -> Value {
match deps.extension_registry.disable_extension(&p.name, p.reason.as_deref()).await {
Ok(()) => ok(json!({ "disabled": true, "name": p.name })),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Skill handlers
// ══════════════════════════════════════════════════════════════════════════════
async fn skill_list(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: SkillListParams) -> Value {
match nomifun_extension::skill_service::list_available_skills(&deps.skill_paths).await {
Ok(items) => {
let resp: Vec<Value> = items
.into_iter()
.map(|s| {
json!({
"name": s.name,
"description": s.description,
"is_custom": s.is_custom,
})
})
.collect();
ok(resp)
}
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn skill_import(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: SkillImportParams) -> Value {
let path = std::path::Path::new(&p.skill_path);
match nomifun_extension::skill_service::import_skill(&deps.skill_paths, path).await {
Ok(name) => ok(json!({ "imported": true, "skill_name": name })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn skill_delete(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: SkillDeleteParams) -> Value {
match nomifun_extension::skill_service::delete_skill(&deps.skill_paths, &p.name).await {
Ok(()) => ok(json!({ "deleted": true, "name": p.name })),
Err(e) => json!({ "error": e.to_string() }),
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Hub handlers
// ══════════════════════════════════════════════════════════════════════════════
async fn hub_list_extensions(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: HubListExtensionsParams) -> Value {
let entries = deps.hub_index_manager.load_index().await;
let items: Vec<Value> = entries
.into_iter()
.map(|e| {
let status_str = serde_json::to_value(&e.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "notInstalled".to_string());
json!({
"name": e.name,
"version": e.version,
"display_name": e.display_name,
"description": e.description,
"author": e.author,
"status": status_str,
})
})
.collect();
ok(items)
}
async fn hub_install_extension(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: HubInstallExtensionParams) -> Value {
let result = deps.hub_installer.install(&p.name).await;
ok(json!({
"success": result.success,
"msg": result.msg,
}))
}
// ══════════════════════════════════════════════════════════════════════════════
// Registration
// ══════════════════════════════════════════════════════════════════════════════
/// Register the MCP/Extension/Skill/Hub domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
// ── MCP Servers ──────────────────────────────────────────────────────
out.push(Capability::new::<McpListServersParams, _, _>(
CapabilityMeta::new(
"nomi_mcp_list_servers",
"mcp",
"List all configured MCP servers (name, transport, enabled state, connection status).",
DangerTier::Read,
),
|deps, ctx, p| mcp_list_servers(deps, ctx, p),
));
out.push(Capability::new::<McpAddServerParams, _, _>(
CapabilityMeta::new(
"nomi_mcp_add_server",
"mcp",
"Add a new MCP server (stdio/sse/http). Upserts by name if one already exists. Headers may contain auth tokens.",
DangerTier::Sensitive,
),
|deps, ctx, p| mcp_add_server(deps, ctx, p),
));
out.push(Capability::new::<McpEditServerParams, _, _>(
CapabilityMeta::new(
"nomi_mcp_edit_server",
"mcp",
"Edit an existing MCP server's transport or description (by id).",
DangerTier::Write,
),
|deps, ctx, p| mcp_edit_server(deps, ctx, p),
));
out.push(Capability::new::<McpDeleteServerParams, _, _>(
CapabilityMeta::new(
"nomi_mcp_delete_server",
"mcp",
"Permanently delete an MCP server configuration (by id).",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| mcp_delete_server(deps, ctx, p),
));
out.push(Capability::new::<McpToggleServerParams, _, _>(
CapabilityMeta::new(
"nomi_mcp_toggle_server",
"mcp",
"Toggle the enabled/disabled state of an MCP server (by id).",
DangerTier::Write,
),
|deps, ctx, p| mcp_toggle_server(deps, ctx, p),
));
// ── Extensions ───────────────────────────────────────────────────────
out.push(Capability::new::<ExtensionListParams, _, _>(
CapabilityMeta::new(
"nomi_extension_list",
"extension",
"List all loaded extensions (name, version, enabled state).",
DangerTier::Read,
),
|deps, ctx, p| extension_list(deps, ctx, p),
));
out.push(Capability::new::<ExtensionEnableParams, _, _>(
CapabilityMeta::new(
"nomi_extension_enable",
"extension",
"Enable a disabled extension by name.",
DangerTier::Write,
),
|deps, ctx, p| extension_enable(deps, ctx, p),
));
out.push(Capability::new::<ExtensionDisableParams, _, _>(
CapabilityMeta::new(
"nomi_extension_disable",
"extension",
"Disable an enabled extension by name (with optional reason).",
DangerTier::Write,
),
|deps, ctx, p| extension_disable(deps, ctx, p),
));
// ── Skills ───────────────────────────────────────────────────────────
out.push(Capability::new::<SkillListParams, _, _>(
CapabilityMeta::new(
"nomi_skill_list",
"skill",
"List all available skills (built-in and user-custom).",
DangerTier::Read,
),
|deps, ctx, p| skill_list(deps, ctx, p),
));
out.push(Capability::new::<SkillImportParams, _, _>(
CapabilityMeta::new(
"nomi_skill_import",
"skill",
"Import a skill from a local directory (by absolute path). Copies the skill into the user skills folder.",
DangerTier::Write,
),
|deps, ctx, p| skill_import(deps, ctx, p),
));
out.push(Capability::new::<SkillDeleteParams, _, _>(
CapabilityMeta::new(
"nomi_skill_delete",
"skill",
"Permanently delete a user-custom skill by name.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| skill_delete(deps, ctx, p),
));
// ── Hub ──────────────────────────────────────────────────────────────
out.push(Capability::new::<HubListExtensionsParams, _, _>(
CapabilityMeta::new(
"nomi_hub_list_extensions",
"hub",
"List extensions available in the Hub marketplace (name, version, install status).",
DangerTier::Read,
),
|deps, ctx, p| hub_list_extensions(deps, ctx, p),
));
out.push(Capability::new::<HubInstallExtensionParams, _, _>(
CapabilityMeta::new(
"nomi_hub_install_extension",
"hub",
"Install an extension from the Hub by name. Downloads and registers it locally.",
DangerTier::Write,
),
|deps, ctx, p| hub_install_extension(deps, ctx, p),
));
}
// ══════════════════════════════════════════════════════════════════════════════
// SKIPPED tools
// ══════════════════════════════════════════════════════════════════════════════
//
// 1. `nomi_mcp_test_connection` (Read/Write)
// Service: `McpConnectionTestService::test_connection(&self, name: &str, transport: &McpServerTransport)`
// Issue: The `McpServerTransport` is a domain enum built from the tagged
// `McpTransport` API type. Exposing a tagged-union transport in the flat
// JSON schema would be confusing for an LLM (requires `type` + variant-
// specific fields). The route handler also persists test results back.
// Agent use case unclear — the user can trigger a test from the UI.
//
// 2. `nomi_skill_set_tags` (Write)
// Service: `ISkillTagRepository::upsert(...)` + `builtin_skill_tags` map.
// Issue: Tags are audience/scenario classifications for UI filtering, not
// something an agent typically needs to set. Low priority.
@@ -0,0 +1,167 @@
//! Memory-domain capabilities (registry form), backed by the companion memory
//! store — the desktop's long-term memory, same data as `/api/companion/memories`.
//!
//! Reference migration of `tools_memory.rs` onto the capability registry: the
//! `*Params` structs are now the SINGLE source (schema + runtime deserialization),
//! so the historical drift — `offset` readable at runtime but absent from the MCP
//! schema — is fixed by construction (it is a declared field here).
use std::sync::Arc;
use nomifun_companion::store::{MemoryFilter, MemoryScope};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
const DEFAULT_LIST_LIMIT: i64 = 50;
#[derive(Deserialize, JsonSchema)]
struct MemoryListParams {
/// Filter by kind: profile / preference / knowledge / episode / task / affective.
#[serde(default)]
kind: Option<String>,
/// Substring search over memory content.
#[serde(default)]
query: Option<String>,
/// Include archived memories too (default false: active only).
#[serde(default)]
include_archived: Option<bool>,
/// Maximum rows to return (default 50, clamped to 1..=200).
#[serde(default)]
limit: Option<i64>,
/// Row offset for pagination (default 0).
#[serde(default)]
offset: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct MemorySaveParams {
/// The memory content — one self-contained fact.
content: String,
/// Kind: profile / preference / knowledge / episode / task / affective (default knowledge).
#[serde(default)]
kind: Option<String>,
/// Optional tags.
#[serde(default)]
tags: Option<Vec<String>>,
}
#[derive(Deserialize, JsonSchema)]
struct MemoryUpdateParams {
/// The id of the memory to update (from nomi_memory_list).
id: String,
/// New content (omit to keep).
#[serde(default)]
content: Option<String>,
/// Pin (true) or unpin (false) the memory; pinned memories are always injected.
#[serde(default)]
pinned: Option<bool>,
/// "active" or "archived".
#[serde(default)]
status: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct MemoryDeleteParams {
/// The id of the memory to permanently delete. Prefer archiving via
/// nomi_memory_update unless the user explicitly asked to delete.
id: String,
}
async fn list(deps: Arc<GatewayDeps>, p: MemoryListParams) -> Value {
let filter = MemoryFilter {
kind: p.kind,
q: p.query,
status: if p.include_archived.unwrap_or(false) {
None
} else {
Some("active".to_owned())
},
// Master-agent view spans every companion's memories.
scope_companion_id: None,
limit: p.limit.unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, 200),
offset: p.offset.unwrap_or(0).max(0),
};
match deps.companion_service.list_memories(&filter).await {
Ok(memories) => ok(memories),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn save(deps: Arc<GatewayDeps>, p: MemorySaveParams) -> Value {
let content = p.content.trim();
if content.is_empty() {
return json!({ "error": "missing required field: content" });
}
let kind = p.kind.unwrap_or_else(|| "knowledge".to_owned());
let tags = p.tags.unwrap_or_default();
match deps.companion_service.add_memory(&kind, content, &tags, MemoryScope::Shared).await {
Ok(memory) => ok(memory),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn update(deps: Arc<GatewayDeps>, p: MemoryUpdateParams) -> Value {
if p.content.is_none() && p.pinned.is_none() && p.status.is_none() {
return json!({ "error": "nothing to update: provide at least one of content / pinned / status" });
}
match deps
.companion_service
.update_memory(&p.id, p.content.as_deref(), p.pinned, p.status.as_deref(), None)
.await
{
Ok(()) => json!({ "result": format!("memory {} updated", p.id) }),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn delete(deps: Arc<GatewayDeps>, p: MemoryDeleteParams) -> Value {
match deps.companion_service.delete_memory(&p.id).await {
Ok(()) => json!({ "result": format!("memory {} deleted", p.id) }),
Err(e) => json!({ "error": e.to_string() }),
}
}
/// Register the memory-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<MemoryListParams, _, _>(
CapabilityMeta::new(
"nomi_memory_list",
"memory",
"List the desktop's long-term memories (active by default; filter by kind/query; include_archived to see archived).",
DangerTier::Read,
),
|deps, _ctx, p| list(deps, p),
));
out.push(Capability::new::<MemorySaveParams, _, _>(
CapabilityMeta::new(
"nomi_memory_save",
"memory",
"Persist a new long-term memory (one self-contained fact).",
DangerTier::Write,
),
|deps, _ctx, p| save(deps, p),
));
out.push(Capability::new::<MemoryUpdateParams, _, _>(
CapabilityMeta::new(
"nomi_memory_update",
"memory",
"Edit a memory's content, pin/unpin it, or archive/reactivate it.",
DangerTier::Write,
),
|deps, _ctx, p| update(deps, p),
));
out.push(Capability::new::<MemoryDeleteParams, _, _>(
CapabilityMeta::new(
"nomi_memory_delete",
"memory",
"Permanently delete a memory. Prefer archiving via nomi_memory_update unless the user asked to delete.",
DangerTier::Destructive,
),
|deps, _ctx, p| delete(deps, p),
));
}
@@ -0,0 +1,67 @@
//! Provider-domain capability (registry form): the read-only model-provider
//! catalog. The shared nomi model-resolution chain stays in `tools_provider`
//! (used by the cron + conversation capabilities), this only exposes listing.
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
use crate::tools_provider::load_provider_summaries;
/// Cap on models listed per provider (keeps the tool result inside the calling
/// agent's context budget; the full list lives in desktop Settings).
const MAX_MODELS_PER_PROVIDER: usize = 20;
#[derive(Deserialize, JsonSchema)]
struct ListProvidersParams {
/// Include disabled providers too (default false: enabled only).
#[serde(default)]
include_disabled: Option<bool>,
}
async fn list(deps: Arc<GatewayDeps>, p: ListProvidersParams) -> Value {
let include_disabled = p.include_disabled.unwrap_or(false);
let summaries = match load_provider_summaries(&deps).await {
Ok(s) => s,
Err(e) => return e,
};
let items: Vec<Value> = summaries
.iter()
.filter(|p| include_disabled || p.enabled)
.map(|p| {
json!({
"id": p.id,
"name": p.name,
"platform": p.platform,
"enabled": p.enabled,
"models": p.models.iter().take(MAX_MODELS_PER_PROVIDER).collect::<Vec<_>>(),
"model_count": p.models.len(),
"models_truncated": p.models.len() > MAX_MODELS_PER_PROVIDER,
})
})
.collect();
if items.is_empty() {
return ok(json!({
"providers": [],
"note": "no model provider is configured/enabled on this desktop yet — add one in Settings → Providers (or via nomi_create_provider) before creating nomi conversations or cron jobs"
}));
}
ok(json!({ "providers": items }))
}
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<ListProvidersParams, _, _>(
CapabilityMeta::new(
"nomi_list_providers",
"provider",
"Read-only catalog of configured model providers and their enabled models (no API keys), for guiding a model choice before creating sessions / cron jobs.",
DangerTier::Read,
),
|deps, _ctx, p| list(deps, p),
));
}
@@ -0,0 +1,299 @@
//! Requirement-domain capabilities (registry form), backed by the requirement
//! service — the desktop's task/requirement board driving AutoWork scheduling.
//!
//! Migrated from `tools_requirement.rs` onto the capability registry: the
//! `*Params` structs are now the single source (schema + runtime deserialization),
//! eliminating hand-parsing via `require_str`/`opt_str`/`require_i64`.
use std::sync::Arc;
use nomifun_api_types::{
CreateRequirementRequest, ListRequirementsQuery, RequirementStatus,
UpdateRequirementRequest,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
/// How many requirements the duplicate guard inspects (same tag, newest pages
/// first would be ideal; the repo orders deterministically and tags rarely
/// exceed this).
const DEDUP_SCAN_PAGE_SIZE: u32 = 200;
// ─── Params structs ─────────────────────────────────────────────────────────
#[derive(Deserialize, JsonSchema)]
struct RequirementListParams {
/// Filter by tag (the AutoWork grouping/scheduling dimension).
#[serde(default)]
tag: Option<String>,
/// Filter by status: pending / in_progress / done / failed / cancelled / needs_review.
#[serde(default)]
status: Option<String>,
/// Full-text filter over title/content.
#[serde(default)]
query: Option<String>,
/// Page number (default 1).
#[serde(default)]
page: Option<u32>,
/// Page size (default 20, max 200).
#[serde(default)]
page_size: Option<u32>,
}
#[derive(Deserialize, JsonSchema)]
struct RequirementCreateParams {
/// Requirement title.
title: String,
/// Detailed requirement content (markdown).
#[serde(default)]
content: Option<String>,
/// Tag — the grouping AutoWork schedules by.
tag: String,
}
#[derive(Deserialize, JsonSchema)]
struct RequirementUpdateParams {
/// The id of the requirement to update (from nomi_requirement_list).
id: i64,
/// New title (omit to keep).
#[serde(default)]
title: Option<String>,
/// New content (omit to keep).
#[serde(default)]
content: Option<String>,
/// New tag (omit to keep).
#[serde(default)]
tag: Option<String>,
/// New status: pending / in_progress / done / failed / cancelled.
#[serde(default)]
status: Option<String>,
/// Note recorded with a status change (recommended for done/failed).
#[serde(default)]
completion_note: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct RequirementDeleteParams {
/// The id of the requirement to delete. Confirm the target with the user first.
id: i64,
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/// Parse an optional status string into the typed enum, returning a structured
/// error the LLM can self-correct from.
fn parse_status(raw: Option<&str>) -> Result<Option<RequirementStatus>, Value> {
match raw {
None => Ok(None),
Some(s) => serde_json::from_value::<RequirementStatus>(json!(s))
.map(Some)
.map_err(|_| json!({"error": format!("invalid status '{s}'")})),
}
}
/// Duplicate-create guard: an OPEN requirement (pending / in_progress /
/// needs_review) under the same tag with the same (trimmed,
/// ASCII-case-insensitive) title counts as a duplicate. Done / failed /
/// cancelled requirements never block a re-create.
pub(crate) fn is_open_duplicate(
status: &RequirementStatus,
existing_title: &str,
new_title: &str,
) -> bool {
matches!(
status,
RequirementStatus::Pending | RequirementStatus::InProgress | RequirementStatus::NeedsReview
) && existing_title.trim().eq_ignore_ascii_case(new_title.trim())
}
// ─── Handlers ───────────────────────────────────────────────────────────────
async fn list(deps: Arc<GatewayDeps>, p: RequirementListParams) -> Value {
let status = match parse_status(p.status.as_deref()) {
Ok(s) => s,
Err(e) => return e,
};
let query = ListRequirementsQuery {
tag: p.tag,
status,
conversation_id: None,
q: p.query,
order_by: None,
order: None,
page: p.page,
page_size: p.page_size,
};
match deps.requirement_service.list(&query).await {
Ok(result) => ok(result),
Err(e) => json!({"error": e.to_string()}),
}
}
/// Creates a requirement after checking the duplicate guard: an open
/// requirement with the same tag + title (trimmed, case-insensitive) returns
/// the existing one instead of creating a twin.
async fn create(deps: Arc<GatewayDeps>, p: RequirementCreateParams) -> Value {
let title = p.title;
let tag = p.tag;
// ── T4 duplicate guard ───────────────────────────────────────────
let dedup_query = ListRequirementsQuery {
tag: Some(tag.clone()),
status: None,
conversation_id: None,
q: None,
order_by: None,
order: None,
page: Some(1),
page_size: Some(DEDUP_SCAN_PAGE_SIZE),
};
match deps.requirement_service.list(&dedup_query).await {
Ok(page) => {
if let Some(existing) = page
.items
.iter()
.find(|r| is_open_duplicate(&r.status, &r.title, &title))
{
return ok(json!({
"duplicate": true,
"existing_requirement": existing,
"note": "an open requirement with this tag + title already exists — nothing was created. Use nomi_requirement_update to refine it; only create a second one if the owner explicitly asked for a duplicate this turn."
}));
}
}
Err(e) => return json!({"error": e.to_string()}),
}
let req = CreateRequirementRequest {
title,
content: p.content.unwrap_or_default(),
tag,
order_key: None,
status: None,
created_by: Some("agent".to_owned()),
attachments: vec![],
};
match deps.requirement_service.create(req).await {
Ok(requirement) => ok(requirement),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn update(deps: Arc<GatewayDeps>, p: RequirementUpdateParams) -> Value {
let status = match parse_status(p.status.as_deref()) {
Ok(s) => s,
Err(e) => return e,
};
let req = UpdateRequirementRequest {
title: p.title,
content: p.content,
tag: p.tag,
order_key: None,
status,
completion_note: p.completion_note,
add_attachments: vec![],
remove_attachment_ids: vec![],
};
match deps.requirement_service.update(p.id, req).await {
Ok(requirement) => ok(requirement),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn delete(deps: Arc<GatewayDeps>, p: RequirementDeleteParams) -> Value {
match deps.requirement_service.delete(p.id).await {
Ok(()) => json!({"result": format!("requirement {} deleted", p.id)}),
Err(e) => json!({"error": e.to_string()}),
}
}
// ─── Registration ───────────────────────────────────────────────────────────
/// Register the requirement-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<RequirementListParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_list",
"requirement",
"List requirements (the desktop task board). Paginated; filter by tag / status / full-text query.",
DangerTier::Read,
),
|deps, _ctx, p| list(deps, p),
));
out.push(Capability::new::<RequirementCreateParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_create",
"requirement",
"Create a new requirement. Refuses to create a duplicate of an open requirement with the same tag + title (returns the existing one instead).",
DangerTier::Write,
),
|deps, _ctx, p| create(deps, p),
));
out.push(Capability::new::<RequirementUpdateParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_update",
"requirement",
"Update a requirement's title, content, tag, status, or completion note.",
DangerTier::Write,
),
|deps, _ctx, p| update(deps, p),
));
out.push(Capability::new::<RequirementDeleteParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_delete",
"requirement",
"Permanently delete a requirement. Confirm the target with the user first.",
DangerTier::Destructive,
),
|deps, _ctx, p| delete(deps, p),
));
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_statuses_with_same_title_are_duplicates() {
for status in [
RequirementStatus::Pending,
RequirementStatus::InProgress,
RequirementStatus::NeedsReview,
] {
assert!(
is_open_duplicate(&status, "Fix login bug", " fix login BUG "),
"{status:?} should block a same-title re-create"
);
}
}
#[test]
fn closed_statuses_never_block_recreate() {
for status in [
RequirementStatus::Done,
RequirementStatus::Failed,
RequirementStatus::Cancelled,
] {
assert!(
!is_open_duplicate(&status, "Fix login bug", "Fix login bug"),
"{status:?} must not block a re-create"
);
}
}
#[test]
fn different_titles_are_not_duplicates() {
assert!(!is_open_duplicate(
&RequirementStatus::Pending,
"Fix login bug",
"Fix logout bug"
));
}
}
@@ -0,0 +1,429 @@
//! Extended SCHEDULING / AUTONOMY gateway tools — surface service methods that
//! the base `caps_cron`, `caps_requirement`, `caps_autowork`, and `caps_idmm`
//! files do NOT already register.
//!
//! Each handler follows the established pattern: typed `*Params` (single source
//! of schema + deserialization), `(Arc<GatewayDeps>, CallerCtx, P) -> Value` or
//! `(Arc<GatewayDeps>, P) -> Value`, `crate::server::ok` for success, structured
//! `json!({"error":…})` on failure.
use std::sync::Arc;
use nomifun_api_types::IdmmTargetKind;
use nomifun_cron::types::cron_job_to_response;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
// ═══════════════════════════════════════════════════════════════════════════════
// CRON DOMAIN (extensions)
// ═══════════════════════════════════════════════════════════════════════════════
#[derive(Deserialize, JsonSchema)]
struct CronGetJobParams {
/// The id of the cron job to retrieve (from nomi_cron_list).
job_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct CronRunNowParams {
/// The id of the cron job to trigger immediately.
job_id: String,
}
async fn cron_get_job(deps: Arc<GatewayDeps>, p: CronGetJobParams) -> Value {
match deps.cron_service.get_job(&p.job_id).await {
Ok(job) => ok(cron_job_to_response(&job)),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn cron_run_now(deps: Arc<GatewayDeps>, p: CronRunNowParams) -> Value {
match deps.cron_service.run_now(&p.job_id).await {
Ok(resp) => ok(json!({
"triggered": true,
"conversation_id": resp.conversation_id,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// REQUIREMENT DOMAIN (extensions)
// ═══════════════════════════════════════════════════════════════════════════════
#[derive(Deserialize, JsonSchema)]
struct RequirementGetParams {
/// The id of the requirement to fetch.
id: i64,
}
#[derive(Deserialize, JsonSchema)]
struct RequirementListTagsParams {
// Intentionally empty — tags() takes no arguments.
// A unit struct would also work, but an empty object is friendlier to
// schema consumers.
}
#[derive(Deserialize, JsonSchema)]
struct RequirementGetBoardParams {
/// The tag whose kanban board to retrieve.
tag: String,
}
#[derive(Deserialize, JsonSchema)]
struct RequirementResumeTagParams {
/// The tag to resume (un-pause).
tag: String,
/// Re-queue ALL failed requirements in the tag back to pending (default false).
#[serde(default)]
requeue_failed: bool,
/// Re-queue these specific failed requirement ids back to pending.
#[serde(default)]
requeue_ids: Vec<i64>,
}
async fn requirement_get(deps: Arc<GatewayDeps>, p: RequirementGetParams) -> Value {
match deps.requirement_service.get(p.id).await {
Ok(req) => ok(req),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn requirement_list_tags(deps: Arc<GatewayDeps>, _p: RequirementListTagsParams) -> Value {
match deps.requirement_service.tags().await {
Ok(tags) => ok(tags),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn requirement_get_board(deps: Arc<GatewayDeps>, p: RequirementGetBoardParams) -> Value {
match deps.requirement_service.board(&p.tag).await {
Ok(board) => ok(board),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn requirement_resume_tag(deps: Arc<GatewayDeps>, p: RequirementResumeTagParams) -> Value {
// Mirror the REST route: if `requeue_failed`, collect all failed ids from
// the board and merge with explicit ids.
let mut requeue_ids = p.requeue_ids;
if p.requeue_failed {
match deps.requirement_service.board(&p.tag).await {
Ok(board) => {
requeue_ids.extend(board.failed.into_iter().map(|r| r.id));
}
Err(e) => return json!({"error": e.to_string()}),
}
}
if let Err(e) = deps.requirement_service.resume_tag(&p.tag, &requeue_ids).await {
return json!({"error": e.to_string()});
}
// Return the updated tag summary (same as the REST route).
match deps.requirement_service.tags().await {
Ok(tags) => {
let summary = tags.into_iter().find(|t| t.tag == p.tag);
ok(json!({
"resumed": true,
"requeued_count": requeue_ids.len(),
"tag_summary": summary,
}))
}
Err(e) => json!({"error": e.to_string()}),
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// IDMM DOMAIN (extensions)
// ═══════════════════════════════════════════════════════════════════════════════
#[derive(Deserialize, JsonSchema)]
struct IdmmGetLogParams {
/// Target kind: "conversation" or "terminal".
kind: String,
/// The conversation id or terminal id to inspect.
target_id: String,
/// Maximum rows to return (default 50, clamped to 1..=500).
#[serde(default)]
limit: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct IdmmGetActivityParams {
/// Maximum rows to return (default 50, clamped to 1..=500).
#[serde(default)]
limit: Option<i64>,
}
#[derive(Deserialize, JsonSchema)]
struct IdmmInterveneParams {
/// Target kind: "conversation" or "terminal".
kind: String,
/// The conversation id or terminal id to intervene on.
target_id: String,
}
#[derive(Deserialize, JsonSchema)]
struct IdmmGetSettingsParams {
// No parameters — global settings.
}
#[derive(Deserialize, JsonSchema)]
struct IdmmSetSettingsParams {
/// Backup provider id for sidecar model fallback (omit to clear).
#[serde(default)]
backup_provider_id: Option<String>,
/// Backup model id for sidecar fallback (omit to clear).
#[serde(default)]
backup_model: Option<String>,
/// Default steering prompt injected into new IDMM supervision configs.
#[serde(default)]
default_steering_prompt: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct IdmmClearLogParams {
/// Target kind: "conversation" or "terminal".
kind: String,
/// The conversation id or terminal id whose log to clear.
target_id: String,
}
// ─── Helpers ────────────────────────────────────────────────────────────────
fn parse_idmm_kind(raw: &str) -> Result<IdmmTargetKind, Value> {
IdmmTargetKind::parse(raw)
.ok_or_else(|| json!({"error": format!("unknown kind '{raw}' (expected conversation | terminal)")}))
}
/// Terminal targets are ownership-checked (same gate as caps_idmm).
async fn verify_idmm_terminal(deps: &GatewayDeps, ctx: &CallerCtx, kind: IdmmTargetKind, target_id: &str) -> Option<Value> {
if kind != IdmmTargetKind::Terminal {
return None;
}
if ctx.user_id.is_empty() {
return Some(json!({"error": "missing caller user identity"}));
}
match deps.idmm_service.verify_terminal_owner(target_id, &ctx.user_id).await {
Ok(()) => None,
Err(e) => Some(json!({"error": e.to_string()})),
}
}
// ─── Handlers ───────────────────────────────────────────────────────────────
async fn idmm_get_log(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: IdmmGetLogParams) -> Value {
let kind = match parse_idmm_kind(&p.kind) {
Ok(k) => k,
Err(e) => return e,
};
if let Some(err) = verify_idmm_terminal(&deps, &ctx, kind, &p.target_id).await {
return err;
}
let limit = p.limit.unwrap_or(50).clamp(1, 500);
match deps.idmm_service.log(kind, &p.target_id, limit).await {
Ok(records) => ok(records),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn idmm_get_activity(deps: Arc<GatewayDeps>, p: IdmmGetActivityParams) -> Value {
let limit = p.limit.unwrap_or(50).clamp(1, 500);
match deps.idmm_service.recent_activity(limit).await {
Ok(records) => ok(records),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn idmm_intervene(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: IdmmInterveneParams) -> Value {
let kind = match parse_idmm_kind(&p.kind) {
Ok(k) => k,
Err(e) => return e,
};
if let Some(err) = verify_idmm_terminal(&deps, &ctx, kind, &p.target_id).await {
return err;
}
match deps.idmm_service.intervene_now(kind, &p.target_id).await {
Ok(()) => {
// Return the updated state (same as the REST route).
match deps.idmm_service.build_state(kind, &p.target_id).await {
Ok(state) => ok(json!({
"intervened": true,
"state": state,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
Err(e) => json!({"error": e.to_string()}),
}
}
async fn idmm_get_settings(deps: Arc<GatewayDeps>, _p: IdmmGetSettingsParams) -> Value {
match deps.idmm_service.get_settings().await {
Ok(settings) => ok(settings),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn idmm_set_settings(deps: Arc<GatewayDeps>, p: IdmmSetSettingsParams) -> Value {
// Read current settings and overlay provided fields (same partial-update
// semantics as the REST route).
let mut settings = match deps.idmm_service.get_settings().await {
Ok(s) => s,
Err(e) => return json!({"error": e.to_string()}),
};
if p.backup_provider_id.is_some() {
settings.backup_provider_id = p.backup_provider_id;
}
if p.backup_model.is_some() {
settings.backup_model = p.backup_model;
}
if let Some(prompt) = p.default_steering_prompt {
settings.default_steering_prompt = prompt;
}
match deps.idmm_service.set_settings(&settings).await {
Ok(()) => ok(settings),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn idmm_clear_log(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: IdmmClearLogParams) -> Value {
let kind = match parse_idmm_kind(&p.kind) {
Ok(k) => k,
Err(e) => return e,
};
if let Some(err) = verify_idmm_terminal(&deps, &ctx, kind, &p.target_id).await {
return err;
}
match deps.idmm_service.clear_log(kind, &p.target_id).await {
Ok(count) => json!({"result": format!("cleared {count} intervention records")}),
Err(e) => json!({"error": e.to_string()}),
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// REGISTRATION
// ═══════════════════════════════════════════════════════════════════════════════
/// Register the scheduling/autonomy extension capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
// ── Cron extensions ─────────────────────────────────────────────────────
out.push(Capability::new::<CronGetJobParams, _, _>(
CapabilityMeta::new(
"nomi_cron_get_job",
"cron",
"Get a single cron job by id (full detail including schedule, next/last run, error).",
DangerTier::Read,
),
|deps, _ctx, p| cron_get_job(deps, p),
));
out.push(Capability::new::<CronRunNowParams, _, _>(
CapabilityMeta::new(
"nomi_cron_run_now",
"cron",
"Trigger a cron job to execute immediately (out-of-schedule one-shot run).",
DangerTier::Write,
),
|deps, _ctx, p| cron_run_now(deps, p),
));
// ── Requirement extensions ──────────────────────────────────────────────
out.push(Capability::new::<RequirementGetParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_get",
"requirement",
"Fetch a single requirement by id (full detail including attachments, timestamps, status).",
DangerTier::Read,
),
|deps, _ctx, p| requirement_get(deps, p),
));
out.push(Capability::new::<RequirementListTagsParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_list_tags",
"requirement",
"List all AutoWork tags with per-status counts, paused state, and totals.",
DangerTier::Read,
),
|deps, _ctx, p| requirement_list_tags(deps, p),
));
out.push(Capability::new::<RequirementGetBoardParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_get_board",
"requirement",
"Get the kanban board view for a tag (requirements grouped by status column).",
DangerTier::Read,
),
|deps, _ctx, p| requirement_get_board(deps, p),
));
out.push(Capability::new::<RequirementResumeTagParams, _, _>(
CapabilityMeta::new(
"nomi_requirement_resume_tag",
"requirement",
"Resume a paused AutoWork tag and optionally re-queue failed requirements back to pending.",
DangerTier::Write,
),
|deps, _ctx, p| requirement_resume_tag(deps, p),
));
// ── IDMM extensions ─────────────────────────────────────────────────────
out.push(Capability::new::<IdmmGetLogParams, _, _>(
CapabilityMeta::new(
"nomi_idmm_get_log",
"idmm",
"Read the persisted intervention log for a conversation or terminal (most-recent-first).",
DangerTier::Read,
),
idmm_get_log,
));
out.push(Capability::new::<IdmmGetActivityParams, _, _>(
CapabilityMeta::new(
"nomi_idmm_get_activity",
"idmm",
"Read the cross-session recent intervention feed (all targets, most-recent-first).",
DangerTier::Read,
),
|deps, _ctx, p| idmm_get_activity(deps, p),
));
out.push(Capability::new::<IdmmInterveneParams, _, _>(
CapabilityMeta::new(
"nomi_idmm_intervene",
"idmm",
"Force one IDMM supervision pass now (manual 'act now') and return the resulting state.",
DangerTier::Write,
),
idmm_intervene,
));
out.push(Capability::new::<IdmmGetSettingsParams, _, _>(
CapabilityMeta::new(
"nomi_idmm_get_settings",
"idmm",
"Read global IDMM settings (backup provider/model, default steering prompt).",
DangerTier::Read,
),
|deps, _ctx, p| idmm_get_settings(deps, p),
));
out.push(Capability::new::<IdmmSetSettingsParams, _, _>(
CapabilityMeta::new(
"nomi_idmm_set_settings",
"idmm",
"Update global IDMM settings (backup provider/model, default steering prompt). Partial update: omitted fields keep their current value.",
DangerTier::Sensitive,
),
|deps, _ctx, p| idmm_set_settings(deps, p),
));
out.push(Capability::new::<IdmmClearLogParams, _, _>(
CapabilityMeta::new(
"nomi_idmm_clear_log",
"idmm",
"Clear all persisted intervention records for a conversation or terminal. Irreversible.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
idmm_clear_log,
));
}
@@ -0,0 +1,423 @@
//! System-domain capabilities (registry form): desktop settings, client
//! preferences (theme / zoom / keep-awake / feature toggles), model-provider
//! CRUD, model fetching, and read-only system info.
//!
//! These tools let the LLM agent configure the desktop environment on behalf
//! of the user — the headline use case is "set my theme to dark" / "add a
//! new provider" / "change my zoom level" spoken to the companion.
//!
//! SKIPPED tools (listed at the bottom of this file) need extra GatewayDeps
//! fields the parent has not yet wired:
//! - `nomi_system_check_update` — needs `VersionCheckService`
//! - `nomi_system_factory_reset` — needs `data_dir: PathBuf`
use std::collections::HashMap;
use std::sync::Arc;
use nomifun_api_types::{
CreateProviderRequest, FetchModelsRequest, UpdateProviderRequest, UpdateSettingsRequest,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::GatewayDeps;
use crate::registry::{Capability, CapabilityMeta, DangerTier};
use crate::server::ok;
// ── param structs (single source: schema + runtime) ──────────────────────
#[derive(Deserialize, JsonSchema)]
struct GetSettingsParams {}
#[derive(Deserialize, JsonSchema)]
struct UpdateSettingsParams {
/// System language code. Allowed: "en-US" or "zh-CN".
#[serde(default)]
language: Option<String>,
/// Enable/disable desktop notifications globally.
#[serde(default)]
notification_enabled: Option<bool>,
/// Enable/disable notifications specifically for cron-job results.
#[serde(default)]
cron_notification_enabled: Option<bool>,
/// Enable/disable the command queue (batch-queued execution of LLM requests).
#[serde(default)]
command_queue_enabled: Option<bool>,
/// Whether uploaded files should be saved to the current workspace.
#[serde(default)]
save_upload_to_workspace: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
struct GetPreferencesParams {
/// Optional list of preference keys to fetch (omit to return all).
/// Common keys: "theme", "ui.zoomFactor", "system.closeToTray",
/// "companion.size", "system.keepAwake", "feature.*".
#[serde(default)]
keys: Option<Vec<String>>,
}
#[derive(Deserialize, JsonSchema)]
struct UpdatePreferencesParams {
/// Map of key → JSON value to set. A `null` value deletes the key.
/// Keys must be non-empty and at most 255 characters.
///
/// Common keys (non-exhaustive):
/// "theme" (string: "light" | "dark" | "rhythm-dark" | …),
/// "ui.zoomFactor" (number: 0.52.0),
/// "system.closeToTray" (bool),
/// "system.keepAwake" (bool),
/// "companion.size" (number: px),
/// "feature.<name>" (bool).
preferences: HashMap<String, Value>,
}
#[derive(Deserialize, JsonSchema)]
struct CreateProviderParams {
/// Provider platform identifier (e.g. "openai", "anthropic", "gemini",
/// "new-api", "bedrock", "vertex-ai", "minimax", "dashscope-coding", etc.).
platform: String,
/// Human-readable display name for this provider.
name: String,
/// API base URL (must start with http:// or https://). Empty string allowed
/// only for bedrock platform.
base_url: String,
/// Plain-text API key (supports comma/newline-separated multi-keys for
/// load balancing). Required for non-bedrock platforms.
api_key: String,
/// Initial model list. If omitted, use nomi_system_fetch_models after
/// creation to populate.
#[serde(default)]
models: Option<Vec<String>>,
/// Whether the provider is enabled (default true).
#[serde(default)]
enabled: Option<bool>,
/// Optional context-window limit override (token count).
#[serde(default)]
context_limit: Option<i64>,
/// Optional AWS Bedrock configuration (required when platform = "bedrock").
/// Pass the full BedrockConfig object as JSON.
#[serde(default)]
bedrock_config: Option<Value>,
}
#[derive(Deserialize, JsonSchema)]
struct UpdateProviderParams {
/// Provider id (from nomi_list_providers).
id: String,
/// New platform identifier (omit to keep).
#[serde(default)]
platform: Option<String>,
/// New display name (omit to keep).
#[serde(default)]
name: Option<String>,
/// New API base URL (omit to keep).
#[serde(default)]
base_url: Option<String>,
/// New API key in plain text (omit to keep).
#[serde(default)]
api_key: Option<String>,
/// Replace model list (omit to keep).
#[serde(default)]
models: Option<Vec<String>>,
/// Enable or disable (omit to keep).
#[serde(default)]
enabled: Option<bool>,
/// Override context-window limit (omit to keep).
#[serde(default)]
context_limit: Option<i64>,
/// AWS Bedrock configuration update (omit to keep).
#[serde(default)]
bedrock_config: Option<Value>,
}
#[derive(Deserialize, JsonSchema)]
struct DeleteProviderParams {
/// Provider id to permanently delete.
id: String,
}
#[derive(Deserialize, JsonSchema)]
struct FetchModelsParams {
/// Provider id whose models to fetch from the remote API.
id: String,
/// If true, attempt automatic URL correction on failure for
/// OpenAI-compatible providers (probes common URL suffixes).
#[serde(default)]
try_fix: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
struct GetInfoParams {}
// ── handlers ──────────────────────────────────────────────────────────────
async fn get_settings(deps: Arc<GatewayDeps>, _p: GetSettingsParams) -> Value {
match deps.settings_service.get_settings().await {
Ok(settings) => ok(settings),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn update_settings(deps: Arc<GatewayDeps>, p: UpdateSettingsParams) -> Value {
let req = UpdateSettingsRequest {
language: p.language,
notification_enabled: p.notification_enabled,
cron_notification_enabled: p.cron_notification_enabled,
command_queue_enabled: p.command_queue_enabled,
save_upload_to_workspace: p.save_upload_to_workspace,
};
if req.is_empty() {
return json!({ "error": "nothing to update: provide at least one field" });
}
match deps.settings_service.update_settings(req).await {
Ok(settings) => ok(settings),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn get_preferences(deps: Arc<GatewayDeps>, p: GetPreferencesParams) -> Value {
let keys_owned = p.keys.unwrap_or_default();
let keys_ref: Vec<&str> = keys_owned.iter().map(String::as_str).collect();
let filter = if keys_ref.is_empty() { None } else { Some(keys_ref.as_slice()) };
match deps.client_pref_service.get_preferences(filter).await {
Ok(prefs) => ok(prefs),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn update_preferences(deps: Arc<GatewayDeps>, p: UpdatePreferencesParams) -> Value {
if p.preferences.is_empty() {
return json!({ "error": "preferences map must not be empty" });
}
match deps.client_pref_service.update_preferences(p.preferences).await {
Ok(()) => ok(json!({ "updated": true })),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn create_provider(deps: Arc<GatewayDeps>, p: CreateProviderParams) -> Value {
// Map the bedrock_config Value passthrough into the typed struct.
let bedrock_config = match p.bedrock_config {
Some(val) => match serde_json::from_value(val) {
Ok(cfg) => Some(cfg),
Err(e) => return json!({ "error": format!("invalid bedrock_config: {e}") }),
},
None => None,
};
let req = CreateProviderRequest {
id: None,
platform: p.platform,
name: p.name,
base_url: p.base_url,
api_key: p.api_key,
models: p.models.unwrap_or_default(),
enabled: p.enabled.unwrap_or(true),
capabilities: vec![],
context_limit: p.context_limit,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config,
is_full_url: false,
};
match deps.provider_service.create(req).await {
Ok(resp) => ok(json!({
"id": resp.id,
"platform": resp.platform,
"name": resp.name,
"base_url": resp.base_url,
"models": resp.models,
"enabled": resp.enabled,
"note": "provider created; use nomi_system_fetch_models to populate the model list from the remote API if models were not specified",
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn update_provider(deps: Arc<GatewayDeps>, p: UpdateProviderParams) -> Value {
let bedrock_config = match p.bedrock_config {
Some(val) => match serde_json::from_value(val) {
Ok(cfg) => Some(cfg),
Err(e) => return json!({ "error": format!("invalid bedrock_config: {e}") }),
},
None => None,
};
let req = UpdateProviderRequest {
platform: p.platform,
name: p.name,
base_url: p.base_url,
api_key: p.api_key,
models: p.models,
enabled: p.enabled,
capabilities: None,
context_limit: p.context_limit,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config,
is_full_url: None,
};
match deps.provider_service.update(&p.id, req).await {
Ok(resp) => ok(json!({
"id": resp.id,
"platform": resp.platform,
"name": resp.name,
"base_url": resp.base_url,
"models": resp.models,
"enabled": resp.enabled,
})),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn delete_provider(deps: Arc<GatewayDeps>, p: DeleteProviderParams) -> Value {
match deps.provider_service.delete(&p.id).await {
Ok(()) => json!({ "result": format!("provider {} deleted", p.id) }),
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn fetch_models(deps: Arc<GatewayDeps>, p: FetchModelsParams) -> Value {
let req = FetchModelsRequest {
try_fix: p.try_fix.unwrap_or(false),
};
match deps.model_fetch_service.fetch_models(&p.id, &req).await {
Ok(resp) => {
let mut result = json!({
"models": resp.models,
"count": resp.models.len(),
});
if let Some(fixed_url) = resp.fixed_base_url {
result["fixed_base_url"] = json!(fixed_url);
result["note"] = json!(
"the provider's base URL was auto-corrected; the new URL has been applied"
);
}
ok(result)
}
Err(e) => json!({ "error": e.to_string() }),
}
}
async fn get_info(_deps: Arc<GatewayDeps>, _p: GetInfoParams) -> Value {
let info = nomifun_system::sysinfo::get_system_info();
ok(info)
}
// ── registration ─────────────────────────────────────────────────────────
/// Register the system-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
// 1. Settings (read)
out.push(Capability::new::<GetSettingsParams, _, _>(
CapabilityMeta::new(
"nomi_system_get_settings",
"system",
"Read the desktop's system settings (language, notification toggles, etc.).",
DangerTier::Read,
),
|deps, _ctx, p| get_settings(deps, p),
));
// 2. Settings (write)
out.push(Capability::new::<UpdateSettingsParams, _, _>(
CapabilityMeta::new(
"nomi_system_update_settings",
"system",
"Partially update system settings (language, notification toggles, command queue, workspace upload). Only provided fields are changed.",
DangerTier::Write,
),
|deps, _ctx, p| update_settings(deps, p),
));
// 3. Preferences (read)
out.push(Capability::new::<GetPreferencesParams, _, _>(
CapabilityMeta::new(
"nomi_system_get_preferences",
"system",
"Read client preferences (theme, zoom, keep-awake, companion size, feature toggles, etc.). Omit keys to get all.",
DangerTier::Read,
),
|deps, _ctx, p| get_preferences(deps, p),
));
// 4. Preferences (write) — the headline "set theme / zoom / keep-awake" tool
out.push(Capability::new::<UpdatePreferencesParams, _, _>(
CapabilityMeta::new(
"nomi_system_update_preferences",
"system",
"Batch set/delete client preferences (theme, ui.zoomFactor, system.closeToTray, system.keepAwake, companion.size, feature toggles). Pass null value to delete a key.",
DangerTier::Write,
),
|deps, _ctx, p| update_preferences(deps, p),
));
// 5. Create provider (sensitive — handles API keys)
out.push(Capability::new::<CreateProviderParams, _, _>(
CapabilityMeta::new(
"nomi_system_create_provider",
"system",
"Register a new model provider (platform + base URL + API key). The service validates credentials format and encrypts the key at rest.",
DangerTier::Sensitive,
),
|deps, _ctx, p| create_provider(deps, p),
));
// 6. Update provider (sensitive — may update API key)
out.push(Capability::new::<UpdateProviderParams, _, _>(
CapabilityMeta::new(
"nomi_system_update_provider",
"system",
"Partially update an existing model provider (name, URL, API key, models, enabled). Only provided fields are changed.",
DangerTier::Sensitive,
),
|deps, _ctx, p| update_provider(deps, p),
));
// 7. Delete provider (destructive)
out.push(Capability::new::<DeleteProviderParams, _, _>(
CapabilityMeta::new(
"nomi_system_delete_provider",
"system",
"Permanently delete a model provider and all its stored credentials.",
DangerTier::Destructive,
),
|deps, _ctx, p| delete_provider(deps, p),
));
// 8. Fetch models (write — triggers a network call and may auto-fix the URL)
out.push(Capability::new::<FetchModelsParams, _, _>(
CapabilityMeta::new(
"nomi_system_fetch_models",
"system",
"Fetch the model list from a provider's remote API (by provider id). Use after creating a provider without specifying models.",
DangerTier::Write,
),
|deps, _ctx, p| fetch_models(deps, p),
));
// 9. System info (read — pure, no service dependency beyond sysinfo)
out.push(Capability::new::<GetInfoParams, _, _>(
CapabilityMeta::new(
"nomi_system_get_info",
"system",
"Read system info: data/cache/log directories, OS platform, and CPU architecture.",
DangerTier::Read,
),
|deps, _ctx, p| get_info(deps, p),
));
}
// ── SKIPPED tools ────────────────────────────────────────────────────────
//
// 10. `nomi_system_check_update` (Read)
// Needs: `deps.version_check_service: nomifun_system::VersionCheckService`
// Method: `version_check_service.check_update(&UpdateCheckRequest { .. })`
// Not wired because VersionCheckService is not in the assumed GatewayDeps.
//
// 11. `nomi_system_factory_reset` (Destructive, deny_on Channel+Remote)
// Needs: `deps.data_dir: PathBuf`
// Method: `nomifun_common::factory_reset::write_marker(&data_dir, &ResetMarker::new(ResetScope::Full))`
// Not wired because data_dir is not in the assumed GatewayDeps.
@@ -0,0 +1,289 @@
//! Terminal-session capabilities (registry form): create / list.
//!
//! Terminals are a SEPARATE domain from conversations (PTY-backed processes
//! in the `terminal_sessions` table, not `conversations`) — which is why the
//! conversation tools refuse `agent_type = "terminal"` and point here.
//!
//! Migration of `tools_terminal.rs` onto the capability registry: the typed
//! `*Params` structs are now the single source (schema + runtime
//! deserialization). The `preset_launch` helper lives in `tools_terminal.rs`
//! and is reused directly (pub(crate)).
use std::sync::Arc;
use nomifun_api_types::CreateTerminalRequest;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
use crate::tools_terminal::preset_launch;
/// Default PTY size for gateway-created terminals (no real viewport exists;
/// wide enough that agent CLIs render sanely when the user attaches later).
const DEFAULT_COLS: u16 = 120;
const DEFAULT_ROWS: u16 = 30;
// ─── Params ────────────────────────────────────────────────────────────────
#[derive(Deserialize, JsonSchema)]
struct CreateTerminalParams {
/// Optional display name (defaults to the preset/backend name).
#[serde(default)]
name: Option<String>,
/// Launch preset: "shell" (default, the platform login shell) or an agent
/// CLI "claude" | "codex" | "gemini".
#[serde(default)]
preset: Option<String>,
/// Working directory (defaults to the user's home directory).
#[serde(default)]
cwd: Option<String>,
/// Permission level for agent presets: "default" (interactive approvals)
/// or "full-auto" (passes the CLI's skip-permissions flag — powerful,
/// confirm with the user first). Ignored for the shell preset.
#[serde(default)]
mode: Option<String>,
/// Advanced: explicit program to launch, overriding the preset's command.
#[serde(default)]
command: Option<String>,
/// Advanced: explicit argument list for the program (overrides preset args).
#[serde(default)]
args: Option<Vec<String>>,
/// Optional knowledge base ids to bind to this terminal at creation
/// (bind-on-create); they are mounted into `.nomi/knowledge/` inside the
/// cwd when the terminal starts. Use nomi_knowledge_list_bases for ids.
#[serde(default)]
knowledge_base_ids: Option<Vec<String>>,
}
#[derive(Deserialize, JsonSchema)]
struct ListTerminalsParams {
/// Filter by status: "running" | "exited" (default: all).
#[serde(default)]
status: Option<String>,
}
// ─── Handlers ──────────────────────────────────────────────────────────────
async fn create(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CreateTerminalParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
let user_id = ctx.user_id;
let preset = p.preset.unwrap_or_else(|| "shell".to_owned());
let mode = p.mode.unwrap_or_else(|| "default".to_owned());
if mode != "default" && mode != "full-auto" {
return json!({"error": format!("unknown mode '{mode}' (expected default | full-auto)")});
}
let (mut command, mut cmd_args, backend) = match preset_launch(&preset, mode == "full-auto") {
Ok(v) => v,
Err(e) => return json!({"error": e}),
};
// Advanced overrides: an explicit command replaces the preset launch
// entirely (args reset, then optionally replaced too).
if let Some(custom) = p.command {
command = custom;
cmd_args = vec![];
}
if let Some(arr) = p.args {
cmd_args = arr;
}
let cwd = match p.cwd {
Some(c) => c,
None => match dirs::home_dir() {
Some(h) => h.to_string_lossy().into_owned(),
None => {
return json!({"error": "no cwd given and the user home directory could not be determined"})
}
},
};
// Optional create-time knowledge binding: the bases get bound to this
// terminal's WORKPATH (spec §7) and mounted into `{cwd}/.nomi/knowledge/`
// when the PTY starts. The mount itself is best-effort downstream (never
// blocks the launch), so the ids are validated HERE — a typo'd id would
// otherwise be accepted and silently mount nothing.
if let Some(ids) = &p.knowledge_base_ids {
if let Err(e) = crate::caps_knowledge::ensure_known_kb_ids(&deps, ids).await {
return e;
}
}
let knowledge_bases_bound = p.knowledge_base_ids.as_ref().map_or(0, Vec::len);
let req = CreateTerminalRequest {
name: p.name,
cwd,
command,
args: cmd_args,
env: None,
backend: backend.clone(),
// Permission mode only applies to agent CLI presets.
mode: backend.is_some().then(|| mode.clone()),
cols: DEFAULT_COLS,
rows: DEFAULT_ROWS,
defer_spawn: false,
knowledge_base_ids: p.knowledge_base_ids,
};
match deps.terminal_service.create(&user_id, req).await {
Ok(resp) => ok(json!({
"id": resp.id,
"name": resp.name,
"status": resp.last_status,
"cwd": resp.cwd,
"command": resp.command,
"args": resp.args,
"backend": resp.backend,
"mode": resp.mode,
// Echo the validated bind-on-create request count (0 = none
// requested); the mount itself remains best-effort.
"knowledge_bases_bound": knowledge_bases_bound,
"note": "terminal created, its process is running; use nomi_list_terminals to check status. Agent terminals (claude/codex/gemini) are eligible AutoWork targets via nomi_set_autowork."
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn list(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ListTerminalsParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
let user_id = &ctx.user_id;
match deps.terminal_service.list(user_id).await {
Ok(rows) => {
let items: Vec<Value> = rows
.iter()
.filter(|t| p.status.as_deref().is_none_or(|s| t.last_status == s))
.map(|t| {
json!({
"id": t.id,
"name": t.name,
"status": t.last_status,
"cwd": t.cwd,
"command": t.command,
"backend": t.backend,
"mode": t.mode,
"exit_code": t.exit_code,
"created_at": t.created_at,
})
})
.collect();
ok(json!({"total": items.len(), "terminals": items}))
}
Err(e) => json!({"error": e.to_string()}),
}
}
// ─── Registration ──────────────────────────────────────────────────────────
/// Register the terminal-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<CreateTerminalParams, _, _>(
CapabilityMeta::new(
"nomi_create_terminal",
"terminal",
"Spawn a new PTY terminal session (shell or agent CLI). Use preset to pick the program; mode to enable full-auto permissions for agent CLIs.",
DangerTier::Write,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| create(deps, ctx, p),
));
out.push(Capability::new::<ListTerminalsParams, _, _>(
CapabilityMeta::new(
"nomi_list_terminals",
"terminal",
"List every terminal session of the calling user (filter by status: running | exited).",
DangerTier::Read,
),
|deps, ctx, p| list(deps, ctx, p),
));
}
// ─── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Mirrors `ui/src/renderer/pages/terminal/launchPresets.ts` — the
/// frontend and gateway presets must agree on commands and flags.
#[test]
fn presets_match_frontend_launch_presets() {
assert_eq!(
preset_launch("shell", false).unwrap(),
("$SHELL".to_owned(), vec![], None)
);
// shell ignores full-auto (no permission concept).
assert_eq!(preset_launch("shell", true).unwrap().1, Vec::<String>::new());
assert_eq!(
preset_launch("claude", true).unwrap(),
(
"claude".to_owned(),
vec!["--dangerously-skip-permissions".to_owned()],
Some("claude".to_owned())
)
);
assert_eq!(
preset_launch("codex", true).unwrap(),
(
"codex".to_owned(),
vec!["--dangerously-bypass-approvals-and-sandbox".to_owned()],
Some("codex".to_owned())
)
);
assert_eq!(
preset_launch("gemini", true).unwrap(),
("gemini".to_owned(), vec!["--yolo".to_owned()], Some("gemini".to_owned()))
);
// default mode = no extra flags for agent presets.
assert_eq!(preset_launch("claude", false).unwrap().1, Vec::<String>::new());
}
#[test]
fn unknown_preset_is_rejected() {
let err = preset_launch("bash", false).unwrap_err();
assert!(err.contains("bash"), "{err}");
}
/// Mode validation rejects unknown strings.
#[test]
fn unknown_mode_is_rejected() {
// Simulate the check that would happen inside `create` before
// calling `preset_launch` — test the boundary inline since the
// handler is async and the validation is trivial.
let mode = "yolo";
let valid = mode == "default" || mode == "full-auto";
assert!(!valid);
}
/// Knowledge base ids: serde correctly deserializes typed params.
#[test]
fn knowledge_base_ids_deserialization() {
// Valid: present with string array
let json_val = json!({"knowledge_base_ids": ["kb_a", "kb_b"]});
let p: CreateTerminalParams = serde_json::from_value(json_val).unwrap();
assert_eq!(p.knowledge_base_ids, Some(vec!["kb_a".to_owned(), "kb_b".to_owned()]));
// Valid: absent → None
let json_val = json!({});
let p: CreateTerminalParams = serde_json::from_value(json_val).unwrap();
assert_eq!(p.knowledge_base_ids, None);
// Valid: explicit null → None
let json_val = json!({"knowledge_base_ids": null});
let p: CreateTerminalParams = serde_json::from_value(json_val).unwrap();
assert_eq!(p.knowledge_base_ids, None);
// Invalid: non-string elements are rejected at deserialization
let json_val = json!({"knowledge_base_ids": ["kb_a", 1]});
let result = serde_json::from_value::<CreateTerminalParams>(json_val);
assert!(result.is_err());
}
}
@@ -0,0 +1,261 @@
//! Extended terminal-session capabilities (registry form): get / write_input /
//! kill / delete / resize / relaunch / update.
//!
//! Companion module to `caps_terminal.rs` (which covers create / list). These
//! are the remaining mutation and query endpoints that a gateway-connected agent
//! needs to fully manage PTY sessions.
use std::sync::Arc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
use crate::server::ok;
// ─── Params ────────────────────────────────────────────────────────────────
/// Parameters for reading a single terminal session's detail/status.
#[derive(Deserialize, JsonSchema)]
struct GetTerminalParams {
/// The terminal session id (from nomi_list_terminals).
id: i64,
}
/// Parameters for writing bytes/keystrokes to a terminal's PTY.
#[derive(Deserialize, JsonSchema)]
struct WriteInputParams {
/// The terminal session id.
id: i64,
/// Base64-encoded bytes to write to the PTY stdin. Encode raw keystrokes
/// (including control sequences like \r for Enter, \x03 for Ctrl-C) as
/// base64 before passing here.
data_b64: String,
}
/// Parameters for terminating a terminal's running process (SIGKILL).
#[derive(Deserialize, JsonSchema)]
struct KillTerminalParams {
/// The terminal session id.
id: i64,
}
/// Parameters for permanently deleting a terminal session (kills process + removes row).
#[derive(Deserialize, JsonSchema)]
struct DeleteTerminalParams {
/// The terminal session id.
id: i64,
}
/// Parameters for resizing a terminal's PTY (cols x rows).
#[derive(Deserialize, JsonSchema)]
struct ResizeTerminalParams {
/// The terminal session id.
id: i64,
/// Number of columns (width in characters).
cols: u16,
/// Number of rows (height in characters).
rows: u16,
}
/// Parameters for relaunching a terminal's process in place (same session id,
/// fresh child process).
#[derive(Deserialize, JsonSchema)]
struct RelaunchTerminalParams {
/// The terminal session id.
id: i64,
}
/// Parameters for updating a terminal session's metadata (rename / pin).
#[derive(Deserialize, JsonSchema)]
struct UpdateTerminalParams {
/// The terminal session id.
id: i64,
/// New display name (omit to keep current).
#[serde(default)]
name: Option<String>,
/// Pin (true) or unpin (false) the terminal; pinned terminals persist in
/// the sidebar. Omit to keep current.
#[serde(default)]
pinned: Option<bool>,
}
// ─── Handlers ──────────────────────────────────────────────────────────────
async fn get_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: GetTerminalParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
match deps.terminal_service.get(p.id).await {
Ok(resp) => ok(json!({
"id": resp.id,
"name": resp.name,
"status": resp.last_status,
"cwd": resp.cwd,
"command": resp.command,
"args": resp.args,
"backend": resp.backend,
"mode": resp.mode,
"cols": resp.cols,
"rows": resp.rows,
"exit_code": resp.exit_code,
"pinned": resp.pinned,
"created_at": resp.created_at,
"updated_at": resp.updated_at,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn write_input(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: WriteInputParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
match deps.terminal_service.input(p.id, &p.data_b64).await {
Ok(()) => ok(json!({"written": true})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn kill_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: KillTerminalParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
match deps.terminal_service.kill(p.id).await {
Ok(()) => ok(json!({"killed": true, "id": p.id})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn delete_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: DeleteTerminalParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
match deps.terminal_service.delete(p.id).await {
Ok(()) => ok(json!({"deleted": true, "id": p.id})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn resize_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ResizeTerminalParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
match deps.terminal_service.resize(p.id, p.cols, p.rows).await {
Ok(()) => ok(json!({"resized": true, "id": p.id, "cols": p.cols, "rows": p.rows})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn relaunch_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: RelaunchTerminalParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
match deps.terminal_service.relaunch(p.id).await {
Ok(resp) => ok(json!({
"id": resp.id,
"name": resp.name,
"status": resp.last_status,
"cwd": resp.cwd,
"command": resp.command,
"args": resp.args,
"backend": resp.backend,
"mode": resp.mode,
"note": "process relaunched in place (same session id, fresh child)"
})),
Err(e) => json!({"error": e.to_string()}),
}
}
async fn update_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: UpdateTerminalParams) -> Value {
if ctx.user_id.is_empty() {
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
}
if p.name.is_none() && p.pinned.is_none() {
return json!({"error": "nothing to update: provide at least one of name / pinned"});
}
match deps.terminal_service.update_meta(p.id, p.name, p.pinned).await {
Ok(resp) => ok(json!({
"id": resp.id,
"name": resp.name,
"pinned": resp.pinned,
"status": resp.last_status,
})),
Err(e) => json!({"error": e.to_string()}),
}
}
// ─── Registration ──────────────────────────────────────────────────────────
/// Register the extended terminal-domain capabilities.
pub(crate) fn register(out: &mut Vec<Capability>) {
out.push(Capability::new::<GetTerminalParams, _, _>(
CapabilityMeta::new(
"nomi_terminal_get",
"terminal",
"Get a single terminal session's detail and current status (running/exited, exit code, dimensions, etc.).",
DangerTier::Read,
),
|deps, ctx, p| get_terminal(deps, ctx, p),
));
out.push(Capability::new::<WriteInputParams, _, _>(
CapabilityMeta::new(
"nomi_terminal_write_input",
"terminal",
"Write base64-encoded bytes/keystrokes to a terminal's PTY stdin. Powerful: can execute arbitrary commands in the running shell.",
DangerTier::Write,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| write_input(deps, ctx, p),
));
out.push(Capability::new::<KillTerminalParams, _, _>(
CapabilityMeta::new(
"nomi_terminal_kill",
"terminal",
"Send SIGKILL to terminate the terminal's running process. The session remains (status becomes 'exited'); use relaunch to restart or delete to remove entirely.",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| kill_terminal(deps, ctx, p),
));
out.push(Capability::new::<DeleteTerminalParams, _, _>(
CapabilityMeta::new(
"nomi_terminal_delete",
"terminal",
"Permanently delete a terminal session (kills the process if running, removes the row and all associated data).",
DangerTier::Destructive,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| delete_terminal(deps, ctx, p),
));
out.push(Capability::new::<ResizeTerminalParams, _, _>(
CapabilityMeta::new(
"nomi_terminal_resize",
"terminal",
"Resize a terminal's PTY to the given cols x rows (triggers deferred spawn if the session was created with defer_spawn).",
DangerTier::Write,
),
|deps, ctx, p| resize_terminal(deps, ctx, p),
));
out.push(Capability::new::<RelaunchTerminalParams, _, _>(
CapabilityMeta::new(
"nomi_terminal_relaunch",
"terminal",
"Relaunch a terminal's process in place: kills the old child and spawns a fresh one reusing the same session id, command, and cwd.",
DangerTier::Write,
)
.deny_on(&[Surface::Channel]),
|deps, ctx, p| relaunch_terminal(deps, ctx, p),
));
out.push(Capability::new::<UpdateTerminalParams, _, _>(
CapabilityMeta::new(
"nomi_terminal_update",
"terminal",
"Update a terminal session's metadata: rename it and/or pin/unpin it.",
DangerTier::Write,
),
|deps, ctx, p| update_terminal(deps, ctx, p),
));
}
@@ -0,0 +1,68 @@
//! Single shared [`ComputerTool`] for the gateway's computer-use capabilities.
//!
//! Unlike the browser (one Chrome per companion), the desktop is one physical
//! screen, so a single shared `ComputerTool` is the right model — exactly like
//! the inward `mcp-computer-stdio` bridge. `ComputerTool` is stateful
//! (`is_concurrency_safe == false`: shared observe/screenshot caches and `[ref]`
//! resolution), so all calls are serialized behind one lock. Only compiled with
//! the `computer-use` feature.
use std::sync::Arc;
use nomi_computer::tool::ComputerTool;
use nomi_config::config::ComputerConfig;
use nomi_tools::Tool;
use nomi_types::tool::ToolResult;
use serde_json::{Value, json};
use tokio::sync::Mutex;
/// Owns the shared desktop `ComputerTool` and serializes calls to it.
pub struct ComputerRegistry {
tool: Arc<ComputerTool>,
/// One global lock: the desktop is a single screen and `ComputerTool` keeps
/// mutable observe/screenshot caches that concurrent callers would clobber
/// (a stale `[ref]` resolves against the wrong snapshot).
lock: Mutex<()>,
}
impl ComputerRegistry {
pub fn new() -> Self {
Self {
tool: Arc::new(ComputerTool::new(&ComputerConfig::default())),
lock: Mutex::new(()),
}
}
/// Forward a `{"action": ..}` payload to the shared tool, serialized.
pub async fn execute(&self, input: Value) -> ToolResult {
let _guard = self.lock.lock().await;
self.tool.execute(input).await
}
}
impl Default for ComputerRegistry {
fn default() -> Self {
Self::new()
}
}
/// Map a `ToolResult` onto the gateway result envelope: error → `{"error": ..}`;
/// success → `{"result": {"text": .., "images": [{media_type, data}]}}` (base64
/// screenshots / Set-of-Marks overlays flow straight through). Mirrors the
/// browser registry's helper of the same name (kept separate so a computer-only
/// build needs no browser-use feature).
pub fn tool_result_to_value(result: ToolResult) -> Value {
if result.is_error {
return json!({ "error": result.content });
}
let mut payload = json!({ "text": result.content });
if !result.images.is_empty() {
let imgs: Vec<Value> = result
.images
.iter()
.map(|img| json!({ "media_type": img.media_type, "data": img.data }))
.collect();
payload["images"] = Value::Array(imgs);
}
json!({ "result": payload })
}
@@ -0,0 +1,123 @@
//! Late-bound dependency bundle for the gateway tool implementations.
use std::sync::Arc;
use nomifun_ai_agent::IWorkerTaskManager;
use nomifun_companion::CompanionService;
use nomifun_conversation::ConversationService;
use nomifun_cron::service::CronService;
use nomifun_db::IProviderRepository;
use nomifun_idmm::IdmmService;
use nomifun_knowledge::KnowledgeService;
use nomifun_requirement::{Orchestrator, RequirementService};
use nomifun_system::{ClientPrefService, ModelFetchService, ProviderService, SettingsService};
use nomifun_terminal::TerminalService;
/// Everything the gateway tools need to operate the desktop.
///
/// Constructed by `nomifun-app` AFTER `build_module_states` (the
/// `ConversationService` / `CronService` instances live there) and wired into
/// the already-running [`crate::GatewayMcpServer`] via `set_deps` — the same
/// late-wire choreography as the guide / requirement MCP servers, which is
/// what lets the server start before the agent factory while the factory
/// still receives the server's connection config.
///
/// NEW FIELD? A capability needing a new service adds it here, then wires it in
/// `nomifun-app/src/router/routes.rs::inject_gateway_deps` (clone from the
/// matching `states.*` / `services.*`). The struct is just an Arc bundle —
/// growth is O(1) pointers, negligible.
pub struct GatewayDeps {
pub conversation_service: ConversationService,
pub task_manager: Arc<dyn IWorkerTaskManager>,
pub cron_service: Arc<CronService>,
/// MUST be the router-state instance (the singleton clone that had
/// `with_conversation_service` / `with_terminal_driver` attached in
/// `build_requirement_state`) — the AutoWork config tools need those
/// attachments; the bare singleton would error "not attached".
pub requirement_service: Arc<RequirementService>,
pub companion_service: Arc<CompanionService>,
/// Singleton terminal service (owns the live PTY map shared with the
/// terminal routes + AutoWork orchestrator).
pub terminal_service: Arc<TerminalService>,
/// Main-db provider rows: model listing + the nomi model resolution chain.
pub provider_repo: Arc<dyn IProviderRepository>,
/// IDMM supervision config (same instance as `/api/idmm` so save also
/// arms/stops the live supervisor).
pub idmm_service: Arc<IdmmService>,
/// Knowledge base registry + bindings (same instance the conversation
/// service mounts from at task start).
pub knowledge_service: Arc<KnowledgeService>,
/// AutoWork live-loop control. The REST `POST /api/requirements/autowork`
/// starts/stops this orchestrator alongside persisting the config; the
/// gateway autowork tools must mirror that or an "enabled" toggle would
/// only take effect after the next desktop boot (boot-resume).
pub autowork_orchestrator: Arc<Orchestrator>,
/// System domain services (same instances the `/api/settings`,
/// `/api/settings/client`, `/api/providers` routes use — so a gateway theme /
/// toggle / provider change and a UI change act on identical state).
pub settings_service: SettingsService,
pub client_pref_service: ClientPrefService,
pub provider_service: ProviderService,
pub model_fetch_service: ModelFetchService,
/// Channel domain state (plugin manager + pairing + sessions + settings),
/// the same instances the `/api/channels` routes use. `Clone` (all Arc).
pub channel_state: nomifun_channel::ChannelRouterState,
/// Filesystem service (path-scoped to the configured allowed roots).
pub file_service: nomifun_file::FileServiceRef,
/// Shell-open service (OS ShellExecute / `open`).
pub shell_service: std::sync::Arc<nomifun_shell::ShellService>,
/// MCP server CRUD (same instance as the `/api/mcp` routes).
pub mcp_config_service: nomifun_mcp::McpConfigService,
/// Extension registry + hub + skills (same instances as the extension routes).
pub extension_registry: nomifun_extension::ExtensionRegistry,
pub hub_index_manager: nomifun_extension::HubIndexManager,
pub hub_installer: nomifun_extension::HubInstaller,
pub skill_paths: nomifun_extension::SkillPaths,
/// Agent catalog + remote agents (same instances as the agent routes).
pub agent_service: std::sync::Arc<nomifun_ai_agent::AgentService>,
pub remote_agent_service: std::sync::Arc<nomifun_ai_agent::RemoteAgentService>,
/// Client-preference repo backing the global model-failover config.
pub client_pref_repo: std::sync::Arc<dyn nomifun_db::IClientPreferenceRepository>,
/// **P3-GW1 (route A)**: per-companion browser tool registry, living in the
/// main process. `Some` only when the `browser-use` feature is on and the
/// app wired it; `None` (or the field absent without the feature) → the
/// gateway exposes no `nomi_browser_*` tools. Each companion gets its own
/// lazily-engined `BrowserTool` + a serialization mutex (X5). See
/// [`crate::browser_registry`].
#[cfg(feature = "browser-use")]
pub browser_registry: Option<crate::browser_registry::BrowserRegistry>,
/// Shared desktop `ComputerTool` (one screen → one serialized instance).
/// `Some` only when the `computer-use` feature is on and the app wired it;
/// otherwise the gateway exposes no `nomi_computer_*` tools. See
/// [`crate::computer_registry`].
#[cfg(feature = "computer-use")]
pub computer_registry: Option<crate::computer_registry::ComputerRegistry>,
}
/// Identity of the calling agent session, forwarded by the stdio bridge from
/// the env the factory injected (`NOMI_GW_MCP_CONVERSATION_ID` /
/// `NOMI_GW_MCP_USER_ID` / `NOMI_GW_MCP_COMPANION_ID`).
#[derive(Debug, Clone, Default)]
pub struct CallerCtx {
/// The conversation the calling agent lives in. Used for self-protection
/// (a session may not message or delete itself) and as the default cron
/// binding target.
pub conversation_id: String,
/// The desktop user every tool scopes its data access to.
pub user_id: String,
/// The companion the calling session is bound to (multi-companion upgrade). `None`
/// for sessions without a companion binding — memory/requirement tools are
/// deliberately companion-agnostic (memory is shared), so this is attribution
/// context, not an access scope.
pub companion_id: Option<String>,
/// IM platform when this is a channel master-agent session (e.g. "lark").
/// `None` for plain companion/desktop sessions. Used to resolve the write
/// surface (channel → write-disabled in P1).
pub channel_platform: Option<String>,
/// `true` when the caller is an external network consumer reaching the
/// platform through the Remote front door (the "外部伙伴" surface). Takes
/// precedence over `channel_platform` in [`CallerCtx::surface`]. Defaults
/// `false` so every existing (desktop/channel) construction site is
/// unaffected.
pub remote: bool,
}
@@ -0,0 +1,74 @@
//! `nomifun-gateway` — the Desktop Gateway MCP: an in-process HTTP tool server
//! that exposes the whole Nomi Desktop capability surface (conversations,
//! terminals, cron jobs, global companion memory, requirements, AutoWork, IDMM,
//! knowledge bases, model providers) to agent sessions that carry the
//! backend-set `desktopGateway` extra flag.
//!
//! Governance principle: the companion IS the desktop's universal semantic control
//! surface — every new desktop feature domain ships a companion-operable gateway
//! tool by default (see the gateway design spec appendix).
//!
//! ## Why this exists
//!
//! Remote IM (channel) sessions and companion companion threads act as the user's
//! "master agent": one conversation through which the user can see and drive
//! everything running on the desktop. Agents reach this server through the
//! `nomicore mcp-gateway-stdio` bridge (claude / codex / gemini advertise
//! stdio-only MCP capabilities; the nomi engine consumes the same bridge), and
//! every tool call is forwarded back here as an authenticated `POST /tool`.
//!
//! ## Shape (third instance of the house pattern)
//!
//! Mirrors the requirement MCP server lifecycle: bind `127.0.0.1:0`, mint a
//! per-process random bearer token, late-wire the service dependencies.
pub mod deps;
pub mod registry;
pub mod server;
#[cfg(feature = "browser-use")]
pub mod browser_registry;
#[cfg(feature = "computer-use")]
pub mod computer_registry;
// ── legacy helper modules retained for shared pure logic ─────────────────
// `tools_provider` keeps the nomi model-resolution chain (used by the cron +
// conversation capabilities); `tools_terminal` keeps `preset_launch` (used by
// the terminal capabilities). `tools_browser` is the not-yet-migrated browser
// domain, still dispatched by the legacy match in `server.rs` under coexistence.
mod tools_provider;
mod tools_terminal;
// ── capability domains (registry form) ───────────────────────────────────
// NEW DOMAIN? Adding `mod caps_<x>;` here is step 2 of 3 — also add the
// `crate::caps_<x>::register(&mut caps)` call in `registry/mod.rs::build()`.
// The `all_caps_modules_are_mod_declared_and_registered` test fails CI if a
// file here is missing its register() call (and vice-versa).
mod caps_agent;
mod caps_autowork;
#[cfg(feature = "browser-use")]
mod caps_browser;
mod caps_channel;
mod caps_companion;
#[cfg(feature = "computer-use")]
mod caps_computer;
mod caps_confirmation;
mod caps_conversation;
mod caps_cron;
mod caps_files;
mod caps_idmm;
mod caps_knowledge;
mod caps_knowledge_ext;
mod caps_mcp;
mod caps_memory;
mod caps_provider;
mod caps_requirement;
mod caps_scheduling_ext;
mod caps_system;
mod caps_terminal;
mod caps_terminal_ext;
pub use deps::{CallerCtx, GatewayDeps};
pub use registry::{Registry, Surface, ToolSpec};
pub use server::GatewayMcpServer;
@@ -0,0 +1,424 @@
//! Capability descriptor: the single source of truth for one operable platform
//! capability — its MCP tool name, LLM-facing description, JSON Schema, danger
//! tier, per-surface permission policy, and async handler.
//!
//! The design rule that kills the historical "three definitions per tool" drift
//! (schemars Param struct in the bridge ↔ hand-parser in `tools_*.rs` ↔ service
//! request type): a capability owns ONE typed `Request` struct `P`. Its JSON
//! Schema is generated from `P` (`schemars`), its runtime arguments are
//! deserialized into the SAME `P`, and the handler receives a typed `P`. Schema,
//! validation, and execution can no longer disagree.
//!
//! The registry is **deps-free**: a handler receives `Arc<GatewayDeps>` as an
//! argument at dispatch time, so `Registry::build()` constructs no services. The
//! identical registry therefore serves both processes — the in-process server
//! (which dispatches with real deps) and the `mcp-gateway-stdio` bridge (which
//! only reads `tool_specs()` to answer `tools/list`).
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
/// Boxed handler future. `Value` is the tool result (the `{"result": …}` /
/// `{"error": …}` envelope the existing tools already produce).
pub type BoxFut = Pin<Box<dyn Future<Output = Value> + Send>>;
/// Type-erased capability handler: `(deps, caller, raw_args) -> result`.
pub type Handler = Arc<dyn Fn(Arc<GatewayDeps>, CallerCtx, Value) -> BoxFut + Send + Sync>;
/// A streaming capability emits intermediate progress `Value`s through this sink
/// while it runs (e.g. a delegated agent's text/tool-call deltas), then returns
/// its final `Value`. Adapters that don't stream (MCP `tools/call`, REST
/// `/v1/tools/{name}`, CLI) just run the buffered [`Handler`] instead and get
/// the final value only — so adding a streaming variant never breaks them.
pub type ProgressSink = tokio::sync::mpsc::Sender<Value>;
/// Type-erased streaming handler: like [`Handler`] but also handed a
/// [`ProgressSink`] for incremental output. Returns the final `Value`.
pub type StreamingHandler =
Arc<dyn Fn(Arc<GatewayDeps>, CallerCtx, Value, ProgressSink) -> BoxFut + Send + Sync>;
/// How dangerous an operation is. Drives the default per-surface permission
/// decision (see [`default_decision`]). Promoted from IDMM's regex-on-command
/// heuristic to a first-class, per-capability annotation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DangerTier {
/// No side effects, no secrets. Always allowed.
Read,
/// Creates / modifies state, reversible.
Write,
/// Irreversible deletion / reset.
Destructive,
/// Reads or writes secrets / credentials.
Sensitive,
}
/// Which kind of session is calling. Derived from [`CallerCtx`]: a channel
/// platform marks an external IM session; otherwise it is a local desktop
/// session. `Remote` is reserved for future LAN/web/device sessions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
/// Local desktop session (companion thread or a plain local conversation).
Desktop,
/// External IM channel master-agent session (telegram / lark / …).
Channel,
/// Future: remote LAN / web / external-device session.
Remote,
}
impl CallerCtx {
/// The permission surface this caller acts on.
pub fn surface(&self) -> Surface {
if self.remote {
Surface::Remote
} else if self.channel_platform.is_some() {
Surface::Channel
} else {
Surface::Desktop
}
}
}
/// The pre-dispatch gate outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// Execute the handler.
Allow,
/// Refuse until the agent restates the action and re-calls with `confirm=true`.
Confirm,
/// Hard-refuse on this surface regardless of confirmation.
Deny,
}
/// The default decision for a `(surface, danger)` pair — the policy matrix from
/// the design spec §4. Capability-level `deny_on` / `confirm_on` overrides
/// refine this in [`decide`].
///
/// | Surface | Read | Write | Destructive | Sensitive |
/// |---------|------|-------|-------------|-----------|
/// | Desktop | Allow | Allow | Confirm | Confirm |
/// | Channel | Allow | Allow | Deny | Deny |
/// | Remote | Allow | Allow | Confirm | Deny |
pub fn default_decision(surface: Surface, danger: DangerTier) -> Decision {
use DangerTier::*;
use Surface::*;
match (surface, danger) {
(_, Read) | (_, Write) => Decision::Allow,
(Desktop, Destructive) | (Desktop, Sensitive) => Decision::Confirm,
(Channel, Destructive) | (Channel, Sensitive) => Decision::Deny,
(Remote, Destructive) => Decision::Confirm,
(Remote, Sensitive) => Decision::Deny,
}
}
/// Resolve the final gate decision for a capability on a surface, honoring the
/// capability's explicit `deny_on` / `confirm_on` overrides and whether the
/// caller already passed `confirm=true`.
pub fn decide(meta: &CapabilityMeta, surface: Surface, confirmed: bool) -> Decision {
if meta.deny_on.contains(&surface) {
return Decision::Deny;
}
let base = default_decision(surface, meta.danger);
if base == Decision::Deny {
return Decision::Deny;
}
let needs_confirm = base == Decision::Confirm || meta.confirm_on.contains(&surface);
if needs_confirm && !confirmed {
Decision::Confirm
} else {
Decision::Allow
}
}
/// Static metadata for one capability. All `&'static` so the registry is cheap
/// to build and the bridge can list tools with zero allocation beyond the schema.
pub struct CapabilityMeta {
/// MCP tool name. Convention: `nomi_<domain>_<verb_object>`, lower_snake,
/// kept concise. The fully-namespaced wire name is `mcp__nomifun-desktop__<name>`
/// (22-char prefix); Anthropic caps that at 64 chars, so the tool name has a
/// 42-char hard budget. The registry self-test enforces both the hard limit
/// and a tighter style budget so names cannot creep toward the ceiling.
pub name: &'static str,
/// Coarse domain label (for diagnostics / grouping).
pub domain: &'static str,
/// LLM-facing one-line description.
pub summary: &'static str,
/// Danger tier — drives the default permission decision.
pub danger: DangerTier,
/// Surfaces where this capability is hard-denied regardless of confirmation
/// (escape hatch beyond the danger matrix, e.g. a `Write` too risky for IM).
pub deny_on: &'static [Surface],
/// Surfaces where this capability additionally requires confirmation
/// (escape hatch to force confirm on an otherwise-allowed surface).
pub confirm_on: &'static [Surface],
}
impl CapabilityMeta {
/// Construct metadata with no surface overrides (the danger matrix applies as-is).
pub const fn new(name: &'static str, domain: &'static str, summary: &'static str, danger: DangerTier) -> Self {
Self {
name,
domain,
summary,
danger,
deny_on: &[],
confirm_on: &[],
}
}
/// Hard-deny this capability on the given surfaces (beyond the danger matrix).
pub const fn deny_on(mut self, surfaces: &'static [Surface]) -> Self {
self.deny_on = surfaces;
self
}
/// Force confirmation for this capability on the given surfaces.
pub const fn confirm_on(mut self, surfaces: &'static [Surface]) -> Self {
self.confirm_on = surfaces;
self
}
/// Whether this capability can require a `confirm=true` on ANY surface — used
/// to decide whether to inject the `confirm` property into its schema.
fn confirmable(&self) -> bool {
matches!(self.danger, DangerTier::Destructive | DangerTier::Sensitive) || !self.confirm_on.is_empty()
}
}
/// One operable capability: metadata + generated schema + typed handler.
pub struct Capability {
pub meta: CapabilityMeta,
/// JSON Schema object for the tool's arguments (MCP `inputSchema`).
pub input_schema: Map<String, Value>,
pub handler: Handler,
/// Optional streaming handler. `Some` for capabilities that can emit
/// incremental progress (e.g. `nomi_agent_run`); consumed by
/// [`Registry::dispatch_stream`]. The buffered [`handler`](Self::handler) is
/// always present, so non-streaming adapters are unaffected.
pub stream: Option<StreamingHandler>,
}
impl Capability {
/// Build a capability from a typed request `P` and an async handler.
///
/// `P` is the single source: its `JsonSchema` becomes the MCP `inputSchema`,
/// and incoming arguments are deserialized into `P` before the handler runs.
/// A deserialization failure returns a structured `{"error": …}` the agent
/// can self-correct from — it never reaches the handler.
pub fn new<P, F, Fut>(meta: CapabilityMeta, f: F) -> Self
where
P: DeserializeOwned + JsonSchema + Send + 'static,
F: Fn(Arc<GatewayDeps>, CallerCtx, P) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Value> + Send + 'static,
{
let mut input_schema = schema_for_params::<P>();
if meta.confirmable() {
inject_confirm_property(&mut input_schema);
}
let f = Arc::new(f);
let handler: Handler = Arc::new(move |deps, ctx, args: Value| {
let f = f.clone();
Box::pin(async move {
// `confirm` is a cross-cutting gate field injected into the schema,
// not part of `P`; drop it before typed deserialization so an
// `deny_unknown_fields` request type would not choke on it.
let args = strip_confirm(args);
match serde_json::from_value::<P>(args) {
Ok(p) => f(deps, ctx, p).await,
Err(e) => json!({ "error": format!("invalid arguments for this tool: {e}") }),
}
})
});
Capability {
meta,
input_schema,
handler,
stream: None,
}
}
/// Build a STREAMING capability: `f` receives a [`ProgressSink`] for
/// incremental output and returns the final `Value`. A buffered
/// [`Handler`](Self::handler) is synthesized automatically (it runs `f` with
/// a draining sink and returns only the final value), so MCP `tools/call`,
/// REST `/v1/tools/{name}`, and CLI keep working unchanged; streaming
/// adapters use [`Registry::dispatch_stream`] to see the progress events.
pub fn new_streaming<P, F, Fut>(meta: CapabilityMeta, f: F) -> Self
where
P: DeserializeOwned + JsonSchema + Send + 'static,
F: Fn(Arc<GatewayDeps>, CallerCtx, P, ProgressSink) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Value> + Send + 'static,
{
let mut input_schema = schema_for_params::<P>();
if meta.confirmable() {
inject_confirm_property(&mut input_schema);
}
let f = Arc::new(f);
// Streaming path: deserialize P, run f feeding the caller's sink.
let stream_f = f.clone();
let stream: StreamingHandler = Arc::new(move |deps, ctx, args: Value, sink: ProgressSink| {
let f = stream_f.clone();
Box::pin(async move {
let args = strip_confirm(args);
match serde_json::from_value::<P>(args) {
Ok(p) => f(deps, ctx, p, sink).await,
Err(e) => json!({ "error": format!("invalid arguments for this tool: {e}") }),
}
})
});
// Buffered path: run the same handler with a sink whose receiver is
// drained-and-discarded, returning only the final value.
let handler: Handler = Arc::new(move |deps, ctx, args: Value| {
let f = f.clone();
Box::pin(async move {
let args = strip_confirm(args);
let p = match serde_json::from_value::<P>(args) {
Ok(p) => p,
Err(e) => return json!({ "error": format!("invalid arguments for this tool: {e}") }),
};
let (tx, mut rx) = tokio::sync::mpsc::channel::<Value>(64);
let drain = tokio::spawn(async move { while rx.recv().await.is_some() {} });
let result = f(deps, ctx, p, tx).await;
drain.abort();
result
})
});
Capability {
meta,
input_schema,
handler,
stream: Some(stream),
}
}
}
/// Generate the MCP-facing JSON Schema object for a request type `P`, stripped
/// of the meta keys schemars adds (`$schema`, `title`) that MCP clients ignore.
fn schema_for_params<P: JsonSchema>() -> Map<String, Value> {
let schema = schemars::schema_for!(P);
let value = serde_json::to_value(schema).unwrap_or_else(|_| json!({ "type": "object" }));
let mut map = match value {
Value::Object(m) => m,
_ => Map::new(),
};
map.remove("$schema");
map.remove("title");
map.entry("type").or_insert_with(|| json!("object"));
// Tools with no fields still need a `properties` object so clients render an
// empty-args form rather than rejecting the schema.
map.entry("properties").or_insert_with(|| json!({}));
map
}
/// Add the cross-cutting `confirm` argument to a confirm-gated tool's schema so
/// the LLM can discover it.
fn inject_confirm_property(schema: &mut Map<String, Value>) {
let props = schema.entry("properties").or_insert_with(|| json!({}));
if let Some(obj) = props.as_object_mut() {
obj.insert(
"confirm".into(),
json!({
"type": "boolean",
"description": "Set true ONLY after restating the exact destructive/sensitive action and its target to the user and getting explicit agreement. Required to execute confirm-gated actions."
}),
);
}
}
/// Remove the gate-only `confirm` key before typed deserialization.
fn strip_confirm(mut args: Value) -> Value {
if let Value::Object(ref mut m) = args {
m.remove("confirm");
}
args
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matrix_allows_reads_and_writes_everywhere() {
for s in [Surface::Desktop, Surface::Channel, Surface::Remote] {
assert_eq!(default_decision(s, DangerTier::Read), Decision::Allow);
assert_eq!(default_decision(s, DangerTier::Write), Decision::Allow);
}
}
#[test]
fn matrix_gates_destructive_and_sensitive() {
assert_eq!(default_decision(Surface::Desktop, DangerTier::Destructive), Decision::Confirm);
assert_eq!(default_decision(Surface::Desktop, DangerTier::Sensitive), Decision::Confirm);
assert_eq!(default_decision(Surface::Channel, DangerTier::Destructive), Decision::Deny);
assert_eq!(default_decision(Surface::Channel, DangerTier::Sensitive), Decision::Deny);
assert_eq!(default_decision(Surface::Remote, DangerTier::Destructive), Decision::Confirm);
assert_eq!(default_decision(Surface::Remote, DangerTier::Sensitive), Decision::Deny);
}
#[test]
fn remote_caller_resolves_remote_surface() {
// The Remote front door sets `remote: true`; surface() must yield Remote.
let ctx = CallerCtx { remote: true, ..Default::default() };
assert_eq!(ctx.surface(), Surface::Remote);
// `remote` takes precedence over a stray channel_platform value.
let ctx2 = CallerCtx {
remote: true,
channel_platform: Some("lark".into()),
..Default::default()
};
assert_eq!(ctx2.surface(), Surface::Remote);
// Without the marker, behaviour is unchanged (desktop / channel).
assert_eq!(CallerCtx::default().surface(), Surface::Desktop);
assert_eq!(
CallerCtx { channel_platform: Some("lark".into()), ..Default::default() }.surface(),
Surface::Channel
);
}
const META_DESTRUCTIVE: CapabilityMeta = CapabilityMeta {
name: "t_del",
domain: "test",
summary: "delete a thing",
danger: DangerTier::Destructive,
deny_on: &[],
confirm_on: &[],
};
#[test]
fn destructive_needs_confirm_on_desktop_until_confirmed() {
assert_eq!(decide(&META_DESTRUCTIVE, Surface::Desktop, false), Decision::Confirm);
assert_eq!(decide(&META_DESTRUCTIVE, Surface::Desktop, true), Decision::Allow);
// External channels hard-deny destructive ops even with confirm=true.
assert_eq!(decide(&META_DESTRUCTIVE, Surface::Channel, true), Decision::Deny);
}
const META_WRITE_DENY_CHANNEL: CapabilityMeta = CapabilityMeta {
name: "t_write",
domain: "test",
summary: "write a thing",
danger: DangerTier::Write,
deny_on: &[Surface::Channel],
confirm_on: &[],
};
#[test]
fn deny_on_override_hard_denies_even_writes() {
assert_eq!(decide(&META_WRITE_DENY_CHANNEL, Surface::Channel, true), Decision::Deny);
assert_eq!(decide(&META_WRITE_DENY_CHANNEL, Surface::Desktop, false), Decision::Allow);
}
#[test]
fn confirmable_drives_schema_injection() {
assert!(META_DESTRUCTIVE.confirmable());
assert!(!META_WRITE_DENY_CHANNEL.confirmable());
}
}
@@ -0,0 +1,415 @@
//! The capability registry: a deps-free, compile-time-known collection of every
//! operable platform capability, keyed by MCP tool name.
//!
//! - The in-process [`crate::server`] dispatches tool calls through
//! [`Registry::dispatch_opt`] (with real `GatewayDeps`).
//! - The `mcp-gateway-stdio` bridge answers `tools/list` from
//! [`Registry::tool_specs`] (schema only, no deps).
//!
//! During migration the registry coexists with the legacy `tools_*.rs` dispatch
//! match: `dispatch_opt` returns `None` for any tool not yet registered, letting
//! the legacy match handle it. Once every tool is migrated the legacy match is
//! deleted and the bridge flips to listing `tool_specs()` dynamically.
mod capability;
pub use capability::{
Capability, CapabilityMeta, DangerTier, Decision, ProgressSink, StreamingHandler, Surface,
decide, default_decision,
};
use std::collections::BTreeMap;
use std::sync::{Arc, OnceLock};
use serde_json::{Map, Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
/// A tool advertised to MCP clients via `tools/list`.
pub struct ToolSpec {
pub name: &'static str,
pub domain: &'static str,
pub description: &'static str,
pub input_schema: Map<String, Value>,
}
/// The global capability set.
pub struct Registry {
by_name: BTreeMap<&'static str, Capability>,
}
impl Registry {
/// The process-wide registry, built once. Construction allocates only the
/// capability closures + their generated schemas — no services — so this is
/// safe to call from the bridge process too.
pub fn global() -> &'static Registry {
static REG: OnceLock<Registry> = OnceLock::new();
REG.get_or_init(Registry::build)
}
fn build() -> Registry {
let mut caps: Vec<Capability> = Vec::new();
// ── capability domains ───────────────────────────────────────────
// NEW DOMAIN? Three steps (the `all_caps_modules_are_mod_declared_and_registered`
// test fails CI if you miss 12; the compiler fails if you miss 4):
// 1. create `caps_<domain>.rs` with `pub(crate) fn register(out: &mut Vec<Capability>)`
// 2. add `mod caps_<domain>;` to lib.rs
// 3. add `crate::caps_<domain>::register(&mut caps);` HERE
// 4. if it needs a NEW service: add a field to deps.rs::GatewayDeps and
// wire it in nomifun-app/src/router/routes.rs::inject_gateway_deps.
// Adding a tool to an EXISTING domain is just one more `out.push(...)` — no wiring.
crate::caps_memory::register(&mut caps);
crate::caps_confirmation::register(&mut caps);
crate::caps_conversation::register(&mut caps);
crate::caps_provider::register(&mut caps);
crate::caps_cron::register(&mut caps);
crate::caps_requirement::register(&mut caps);
crate::caps_autowork::register(&mut caps);
crate::caps_idmm::register(&mut caps);
crate::caps_terminal::register(&mut caps);
crate::caps_knowledge::register(&mut caps);
crate::caps_knowledge_ext::register(&mut caps);
crate::caps_system::register(&mut caps);
crate::caps_companion::register(&mut caps);
crate::caps_channel::register(&mut caps);
crate::caps_scheduling_ext::register(&mut caps);
crate::caps_terminal_ext::register(&mut caps);
crate::caps_files::register(&mut caps);
crate::caps_mcp::register(&mut caps);
crate::caps_agent::register(&mut caps);
#[cfg(feature = "browser-use")]
crate::caps_browser::register(&mut caps);
#[cfg(feature = "computer-use")]
crate::caps_computer::register(&mut caps);
// De-duplicate by name; a collision is a programmer error worth failing
// fast on at first use (boot), not a silent last-writer-wins.
let mut by_name = BTreeMap::new();
for c in caps {
let name = c.meta.name;
if by_name.insert(name, c).is_some() {
panic!("duplicate gateway capability name: {name}");
}
}
Registry { by_name }
}
/// Whether a tool name is handled by the registry (migration check).
pub fn contains(&self, name: &str) -> bool {
self.by_name.contains_key(name)
}
/// Total registered capabilities.
pub fn len(&self) -> usize {
self.by_name.len()
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
/// The tools visible on a surface: everything except the hard-denied set.
/// Confirm-gated tools ARE listed (they are usable with `confirm=true`);
/// passing `confirmed = true` to [`decide`] collapses `Confirm → Allow`, so
/// only `Deny` outcomes are filtered out.
pub fn tool_specs(&self, surface: Surface) -> Vec<ToolSpec> {
self.by_name
.values()
.filter(|c| decide(&c.meta, surface, true) != Decision::Deny)
.map(|c| ToolSpec {
name: c.meta.name,
domain: c.meta.domain,
description: c.meta.summary,
input_schema: c.input_schema.clone(),
})
.collect()
}
/// Like [`tool_specs`](Self::tool_specs) but restricted to the given
/// capability domains (`CapabilityMeta::domain`). Powers curated external
/// "profiles" (e.g. an `agent` profile = do-work domains only), so a remote
/// MCP client gets a tight, intent-focused tool list instead of all ~150.
/// An empty `domains` slice yields an empty result (callers pass the full
/// set or use [`tool_specs`](Self::tool_specs) for "everything").
pub fn tool_specs_for(&self, surface: Surface, domains: &[&str]) -> Vec<ToolSpec> {
self.by_name
.values()
.filter(|c| domains.contains(&c.meta.domain))
.filter(|c| decide(&c.meta, surface, true) != Decision::Deny)
.map(|c| ToolSpec {
name: c.meta.name,
domain: c.meta.domain,
description: c.meta.summary,
input_schema: c.input_schema.clone(),
})
.collect()
}
pub fn tool_visible(&self, surface: Surface, name: &str) -> bool {
self.by_name
.get(name)
.is_some_and(|c| decide(&c.meta, surface, true) != Decision::Deny)
}
pub fn tool_visible_for(&self, surface: Surface, domains: &[&str], name: &str) -> bool {
self.by_name.get(name).is_some_and(|c| {
domains.contains(&c.meta.domain) && decide(&c.meta, surface, true) != Decision::Deny
})
}
/// Dispatch a tool call if the registry owns the tool; `None` means "not a
/// registry tool — let the legacy match handle it".
pub async fn dispatch_opt(
&self,
deps: Arc<GatewayDeps>,
ctx: CallerCtx,
name: &str,
args: &Value,
) -> Option<Value> {
let cap = self.by_name.get(name)?;
let surface = ctx.surface();
let confirmed = args
.get("confirm")
.and_then(Value::as_bool)
.unwrap_or(false);
let result = match decide(&cap.meta, surface, confirmed) {
Decision::Deny => json!({
"error": format!("'{name}' is not permitted on the {surface:?} surface")
}),
Decision::Confirm => json!({
"needs_confirmation": true,
"tool": name,
"danger": format!("{:?}", cap.meta.danger),
"note": "This action is destructive or sensitive. Restate the exact action and its target to the user, get explicit agreement, then call again with confirm=true."
}),
Decision::Allow => (cap.handler)(deps, ctx, args.clone()).await,
};
Some(result)
}
/// Streaming dispatch: like [`dispatch_opt`](Self::dispatch_opt) but a
/// streaming-capable tool emits intermediate progress through `progress`
/// while it runs, and the returned `Value` is the final result. A
/// non-streaming tool emits nothing on `progress` and returns its single
/// value (so the streaming endpoint works uniformly for every tool).
/// `None` means the tool name is unknown.
pub async fn dispatch_stream(
&self,
deps: Arc<GatewayDeps>,
ctx: CallerCtx,
name: &str,
args: &Value,
progress: ProgressSink,
) -> Option<Value> {
let cap = self.by_name.get(name)?;
let surface = ctx.surface();
let confirmed = args
.get("confirm")
.and_then(Value::as_bool)
.unwrap_or(false);
let result = match decide(&cap.meta, surface, confirmed) {
Decision::Deny => json!({
"error": format!("'{name}' is not permitted on the {surface:?} surface")
}),
Decision::Confirm => json!({
"needs_confirmation": true,
"tool": name,
"danger": format!("{:?}", cap.meta.danger),
"note": "This action is destructive or sensitive. Restate the exact action and its target to the user, get explicit agreement, then call again with confirm=true."
}),
Decision::Allow => match &cap.stream {
Some(stream) => stream(deps, ctx, args.clone(), progress).await,
None => (cap.handler)(deps, ctx, args.clone()).await,
},
};
Some(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::GatewayMcpConfig;
/// Boot-time invariants for every registered capability: unique names
/// (panics in `build` otherwise), `nomi_`-prefixed, a non-empty summary, a
/// well-formed object schema, and a fully-namespaced MCP wire name within
/// the Anthropic 64-char limit — with a tighter style budget on the tool
/// name itself so length cannot creep up to the hard ceiling unnoticed.
#[test]
fn registry_builds_and_names_fit_mcp_limit() {
let reg = Registry::global();
// Wire name = `mcp__<server>__<tool>`; derive the prefix from the REAL
// server-name constant so a rename can never silently invalidate this.
let prefix = format!("mcp__{}__", GatewayMcpConfig::SERVER_NAME).len();
// Hard ceiling Anthropic enforces on the wire name.
const HARD_WIRE_LIMIT: usize = 64;
// Style budget for the tool name alone (see CapabilityMeta::name doc):
// keeps a comfortable margin under the ceiling as domains grow.
const TOOL_NAME_BUDGET: usize = 42;
for (name, cap) in reg.by_name.iter() {
assert!(
name.starts_with("nomi_"),
"gateway tool names are nomi_-prefixed: {name}"
);
assert!(
prefix + name.len() <= HARD_WIRE_LIMIT,
"tool name breaks the MCP 64-char wire limit: {name} ({prefix} + {} > {HARD_WIRE_LIMIT})",
name.len()
);
assert!(
name.len() <= TOOL_NAME_BUDGET,
"tool name exceeds the {TOOL_NAME_BUDGET}-char style budget (keep `nomi_<domain>_<verb_object>` concise): {name} ({} chars)",
name.len()
);
assert!(
!cap.meta.summary.trim().is_empty(),
"capability {name} has an empty summary (LLMs need it)"
);
assert!(
cap.input_schema.contains_key("properties"),
"capability {name} schema missing `properties` (MCP/OpenAI clients reject such schemas)"
);
}
}
/// Floor on the registered-capability count. A drop below this almost always
/// means a `caps_*` module's `register()` call was accidentally removed from
/// `build()` (or a domain module deleted). Bump the floor when capabilities
/// are intentionally removed. Default build (no `browser-use`) sits just
/// below the feature-on count, so the floor allows for the gated module.
#[test]
fn registry_capability_count_floor() {
let n = Registry::global().len();
assert!(
n >= 132,
"capability count fell to {n} (floor 132) — a caps_* module may have lost its \
register() call in Registry::build(), or a domain was removed. If intentional, lower the floor."
);
}
#[test]
fn gateway_surfaces_do_not_advertise_team_tools() {
let reg = Registry::global();
for surface in [Surface::Desktop, Surface::Remote, Surface::Channel] {
let team_tools: Vec<&str> = reg
.tool_specs(surface)
.iter()
.map(|s| s.name)
.filter(|name| name.starts_with("nomi_team_"))
.collect();
assert!(
team_tools.is_empty(),
"team tools must not be advertised on {surface:?}: {team_tools:?}"
);
}
}
#[test]
fn tool_specs_for_filters_to_domains() {
let reg = Registry::global();
let agentish = reg.tool_specs_for(Surface::Remote, &["agent", "conversation"]);
assert!(
!agentish.is_empty(),
"agent/conversation domains must expose tools"
);
// strict subset of the full Remote surface
let all: std::collections::BTreeSet<&str> = reg
.tool_specs(Surface::Remote)
.iter()
.map(|s| s.name)
.collect();
assert!(agentish.iter().all(|s| all.contains(s.name)));
assert!(
agentish.len() < all.len(),
"a profile must be narrower than the full surface"
);
// contains the agent-delegation cap, excludes a system-management cap
let names: Vec<&str> = agentish.iter().map(|s| s.name).collect();
assert!(names.contains(&"nomi_agent_run"));
assert!(
!names.contains(&"nomi_system_update_settings"),
"system domain must be excluded"
);
// unknown domain yields nothing
assert!(
reg.tool_specs_for(Surface::Remote, &["does_not_exist"])
.is_empty()
);
}
/// **Anti-drift guard (the structural fix for the historical ~10% coverage gap).**
///
/// Every `caps_*.rs` file on disk MUST be both `mod`-declared in `lib.rs` and
/// have its `register()` called in `Registry::build()`. A new domain file that
/// forgets either step compiles silently and contributes ZERO tools with no
/// other test failure — exactly the silent non-exposure that let coverage rot
/// before. This test makes that mistake a hard CI failure. Pure source-text
/// scanning (no proc-macro / inventory / linkme), so it also covers
/// feature-gated modules whose `cfg` lines keep them out of a default build.
#[test]
fn all_caps_modules_are_mod_declared_and_registered() {
use std::fs;
use std::path::PathBuf;
let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
// 1. Ground truth: caps_*.rs files on disk.
let mut on_disk: Vec<String> = fs::read_dir(&src_dir)
.expect("read gateway src dir")
.filter_map(|e| e.ok())
.filter_map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
(n.starts_with("caps_") && n.ends_with(".rs"))
.then(|| n.trim_end_matches(".rs").to_owned())
})
.collect();
on_disk.sort();
assert!(
!on_disk.is_empty(),
"no caps_*.rs files found — test misconfigured?"
);
// 2. `mod caps_*;` declarations in lib.rs (ignores the cfg line above them).
let lib_rs = fs::read_to_string(src_dir.join("lib.rs")).expect("read lib.rs");
let modded: Vec<String> = lib_rs
.lines()
.filter_map(|l| {
let t = l.trim();
t.strip_prefix("mod ")
.or_else(|| t.strip_prefix("pub mod "))
.and_then(|r| r.strip_suffix(';'))
.filter(|n| n.starts_with("caps_"))
.map(str::to_owned)
})
.collect();
// 3. `crate::caps_*::register(&mut caps);` call sites in build().
let reg_rs =
fs::read_to_string(src_dir.join("registry/mod.rs")).expect("read registry/mod.rs");
let registered: Vec<String> = reg_rs
.lines()
.filter_map(|l| {
l.trim()
.strip_prefix("crate::")
.and_then(|r| r.strip_suffix("::register(&mut caps);"))
.filter(|n| n.starts_with("caps_"))
.map(str::to_owned)
})
.collect();
let not_modded: Vec<&String> = on_disk.iter().filter(|f| !modded.contains(f)).collect();
assert!(
not_modded.is_empty(),
"caps_*.rs on disk but NOT `mod`-declared in lib.rs (dead, never compiled): {not_modded:?} — add `mod <name>;`"
);
let not_registered: Vec<&String> =
on_disk.iter().filter(|f| !registered.contains(f)).collect();
assert!(
not_registered.is_empty(),
"caps_*.rs NOT registered in Registry::build(): {not_registered:?} — add `crate::<name>::register(&mut caps);` (silently contributes ZERO tools otherwise)"
);
}
}
@@ -0,0 +1,309 @@
//! In-process HTTP half of the Desktop Gateway MCP.
//!
//! ACP CLIs and the nomi engine spawn a SEPARATE stdio process
//! (`nomicore mcp-gateway-stdio`) that cannot share this process's services;
//! it forwards each tool call back here as an authenticated `POST /tool`.
use std::net::SocketAddr;
use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use nomifun_common::generate_id;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::deps::{CallerCtx, GatewayDeps};
use crate::registry::Registry;
/// Late-bound handle to the gateway dependencies. Unlike the guide /
/// requirement servers (which hold a `Weak` to a singleton that outlives
/// them elsewhere), this slot OWNS the deps bundle: `GatewayDeps` is
/// assembled specifically for this server during router construction and has
/// no other owner. Nothing inside the bundle references the server back, so
/// there is no Arc cycle.
type DepsSlot = Arc<RwLock<Option<Arc<GatewayDeps>>>>;
#[derive(Clone)]
struct GatewayState {
auth_token: String,
deps: DepsSlot,
}
/// In-process HTTP MCP server for the desktop gateway tools.
pub struct GatewayMcpServer {
http_addr: SocketAddr,
auth_token: String,
shutdown_handle: Option<tokio::task::JoinHandle<()>>,
deps_slot: DepsSlot,
}
impl GatewayMcpServer {
/// Bind a fresh `127.0.0.1:0` listener, mint a random bearer token, and
/// start serving `POST /tool`. Deps must be wired separately via
/// [`set_deps`](Self::set_deps) before the first tool call arrives.
pub async fn start() -> Result<Self, String> {
let auth_token = generate_id();
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind gateway MCP HTTP listener: {e}"))?;
let http_addr = listener
.local_addr()
.map_err(|e| format!("Failed to read gateway MCP local addr: {e}"))?;
let deps_slot: DepsSlot = Arc::new(RwLock::new(None));
let state = GatewayState {
auth_token: auth_token.clone(),
deps: deps_slot.clone(),
};
let app = axum::Router::new()
.route("/tool", axum::routing::post(handle_tool_request))
.with_state(state);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
warn!(error = %e, "Gateway MCP axum server exited with error");
}
});
debug!(
http_port = http_addr.port(),
"Gateway MCP Server started (axum)"
);
Ok(Self {
http_addr,
auth_token,
shutdown_handle: Some(handle),
deps_slot,
})
}
/// Wire the dependency bundle after router construction. Must be called
/// once before the first tool request arrives.
pub async fn set_deps(&self, deps: Arc<GatewayDeps>) {
*self.deps_slot.write().await = Some(deps);
}
pub fn http_port(&self) -> u16 {
self.http_addr.port()
}
pub fn auth_token(&self) -> &str {
&self.auth_token
}
pub fn stop(&mut self) {
if let Some(handle) = self.shutdown_handle.take() {
handle.abort();
debug!(
http_port = self.http_addr.port(),
"Gateway MCP Server stop requested"
);
}
}
}
impl Drop for GatewayMcpServer {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// Axum handler
// ---------------------------------------------------------------------------
async fn handle_tool_request(
State(state): State<GatewayState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> impl IntoResponse {
let provided_token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if provided_token != state.auth_token {
warn!("Gateway MCP: unauthorized request");
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "unauthorized"})),
)
.into_response();
}
let tool = body
.get("tool")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let args = body.get("args").cloned().unwrap_or(Value::Null);
let ctx = CallerCtx {
conversation_id: body
.get("conversation_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
user_id: body
.get("user_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
// Optional: only master-agent / companion sessions with a companion binding
// carry it; empty is normalized to None.
companion_id: body
.get("companion_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned),
// Optional: only channel master-agent sessions carry it.
channel_platform: body
.get("channel_platform")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned),
// This in-process server is the INWARD path (bundled agents on loopback);
// never the external Remote surface.
..Default::default()
};
let deps = match state.deps.read().await.clone() {
Some(d) => d,
None => {
warn!(tool, "Gateway MCP: deps not available");
return finish(json!({"error": "service_unavailable"}));
}
};
info!(tool, caller = %ctx.conversation_id, "Gateway MCP: dispatching tool");
// The capability registry is the single authority: it owns every tool,
// generates its schema, and enforces the danger-tier × surface permission
// gate. An unknown name returns a structured error the agent can recover from.
let response_body = match Registry::global()
.dispatch_opt(deps.clone(), ctx.clone(), &tool, &args)
.await
{
Some(v) => v,
None => {
warn!(tool, "Gateway MCP: unknown tool");
json!({ "error": format!("Unknown tool: {tool}") })
}
};
finish(response_body)
}
/// Wrap a JSON body as a response and ask the client to close the connection
/// (the stdio bridge runs with `pool_max_idle_per_host(0)` and does not reuse).
fn finish(body: Value) -> axum::response::Response {
let mut resp = Json(body).into_response();
resp.headers_mut()
.insert(header::CONNECTION, HeaderValue::from_static("close"));
resp
}
// ---------------------------------------------------------------------------
// Shared helpers for the capability handlers
// ---------------------------------------------------------------------------
/// Every conversation-domain tool needs the calling user's identity to scope
/// data access; refuse to operate without one.
pub(crate) fn require_user(ctx: &CallerCtx) -> Result<&str, Value> {
if ctx.user_id.is_empty() {
Err(json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"}))
} else {
Ok(&ctx.user_id)
}
}
/// Wrap a serializable payload as a successful tool result.
pub(crate) fn ok<T: serde::Serialize>(payload: T) -> Value {
match serde_json::to_value(payload) {
Ok(v) => json!({"result": v}),
Err(e) => json!({"error": format!("failed to serialize result: {e}")}),
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn post_tool(port: u16, token: Option<&str>, body: Value) -> (u16, Value) {
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let mut req = client
.post(format!("http://127.0.0.1:{port}/tool"))
.json(&body);
if let Some(t) = token {
req = req.header("Authorization", format!("Bearer {t}"));
}
let resp = req.send().await.unwrap();
let status = resp.status().as_u16();
let json: Value = resp.json().await.unwrap_or(Value::Null);
(status, json)
}
#[tokio::test]
async fn start_returns_positive_port_and_token() {
let server = GatewayMcpServer::start().await.unwrap();
assert!(server.http_port() > 0);
assert!(!server.auth_token().is_empty());
}
#[tokio::test]
async fn each_start_uses_a_fresh_auth_token() {
let a = GatewayMcpServer::start().await.unwrap();
let b = GatewayMcpServer::start().await.unwrap();
assert_ne!(a.auth_token(), b.auth_token());
}
#[tokio::test]
async fn tool_call_requires_auth() {
let server = GatewayMcpServer::start().await.unwrap();
let (status, _) = post_tool(
server.http_port(),
None,
json!({"tool": "nomi_list_conversations", "args": {}}),
)
.await;
assert_eq!(status, 401);
}
#[tokio::test]
async fn missing_deps_returns_unavailable() {
// Server started but set_deps never called.
let server = GatewayMcpServer::start().await.unwrap();
let (status, body) = post_tool(
server.http_port(),
Some(server.auth_token()),
json!({"tool": "nomi_list_conversations", "args": {}}),
)
.await;
assert_eq!(status, 200);
assert_eq!(
body.get("error").and_then(Value::as_str),
Some("service_unavailable")
);
}
#[test]
fn require_user_rejects_empty_identity() {
let ctx = CallerCtx::default();
assert!(require_user(&ctx).is_err());
let ctx = CallerCtx {
user_id: "u1".into(),
..Default::default()
};
assert_eq!(require_user(&ctx).unwrap(), "u1");
}
}
@@ -0,0 +1,338 @@
//! Provider/model gateway tools + the shared nomi model resolution chain.
//!
//! The chain exists to kill the "cron job silently bound to a model-less
//! conversation, blows up at execution time with Provider '' not found"
//! class of bug: nomi sessions get a model AT CREATION, resolved as
//! explicit args → calling companion's own profile model → first configured
//! provider's first model → hard error with guidance.
use nomifun_common::ProviderWithModel;
use serde_json::{Value, json};
use crate::deps::{CallerCtx, GatewayDeps};
/// A provider row reduced to what the listing tool + resolution chain need.
#[derive(Debug, Clone)]
pub(crate) struct ProviderSummary {
pub id: String,
pub name: String,
pub platform: String,
pub enabled: bool,
/// Effective model ids: the `models` JSON array filtered by the
/// per-model `model_enabled` map (absent entry = enabled).
pub models: Vec<String>,
}
pub(crate) fn summarize_provider(row: &nomifun_db::models::Provider) -> ProviderSummary {
let all_models: Vec<String> = serde_json::from_str(&row.models).unwrap_or_default();
let enabled_map: serde_json::Map<String, Value> = row
.model_enabled
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let models = all_models
.into_iter()
.filter(|m| enabled_map.get(m).and_then(Value::as_bool).unwrap_or(true))
.collect();
ProviderSummary {
id: row.id.clone(),
name: row.name.clone(),
platform: row.platform.clone(),
enabled: row.enabled,
models,
}
}
pub(crate) async fn load_provider_summaries(deps: &GatewayDeps) -> Result<Vec<ProviderSummary>, Value> {
let rows = deps
.provider_repo
.list()
.await
.map_err(|e| json!({"error": format!("failed to list providers: {e}")}))?;
Ok(rows.iter().map(summarize_provider).collect())
}
/// `nomi_list_providers` lives in `caps_provider`; this module retains only the
/// shared provider summaries + the nomi model-resolution chain.
/// Outcome of the model resolution chain, with the step that produced it
/// (surfaced to the calling agent so it can tell the owner what was picked).
#[derive(Debug, PartialEq)]
pub(crate) struct ResolvedModel {
pub provider_id: String,
pub model: String,
pub source: &'static str,
}
/// The pure nomi model resolution chain:
/// 1. explicit provider+model → as given (provider must exist and be enabled)
/// 2. explicit provider only → that provider's first available model
/// 3. explicit model only → first enabled provider offering it
/// 4. calling companion's profile model (only when its provider still exists+enabled)
/// 5. first enabled provider's first model
/// 6. error with configuration guidance
pub(crate) fn resolve_model_chain(
explicit_provider: Option<&str>,
explicit_model: Option<&str>,
companion_model: Option<(&str, &str)>,
providers: &[ProviderSummary],
) -> Result<ResolvedModel, String> {
let find = |id: &str| providers.iter().find(|p| p.id == id);
let require_enabled = |pid: &str| -> Result<&ProviderSummary, String> {
let p = find(pid)
.ok_or_else(|| format!("provider '{pid}' not found; call nomi_list_providers for valid ids"))?;
if !p.enabled {
return Err(format!(
"provider '{}' ({}) is disabled; pick another via nomi_list_providers",
p.name, p.id
));
}
Ok(p)
};
match (explicit_provider, explicit_model) {
(Some(pid), Some(model)) => {
require_enabled(pid)?;
return Ok(ResolvedModel {
provider_id: pid.to_owned(),
model: model.to_owned(),
source: "explicit",
});
}
(Some(pid), None) => {
let p = require_enabled(pid)?;
let model = p
.models
.first()
.cloned()
.ok_or_else(|| format!("provider '{}' ({}) has no available models", p.name, p.id))?;
return Ok(ResolvedModel {
provider_id: pid.to_owned(),
model,
source: "explicit_provider_first_model",
});
}
(None, Some(model)) => {
let p = providers
.iter()
.find(|p| p.enabled && p.models.iter().any(|m| m == model))
.ok_or_else(|| {
format!("no enabled provider offers model '{model}'; call nomi_list_providers for valid combinations")
})?;
return Ok(ResolvedModel {
provider_id: p.id.clone(),
model: model.to_owned(),
source: "explicit_model",
});
}
(None, None) => {}
}
if let Some((pid, model)) = companion_model
&& !pid.is_empty()
&& !model.is_empty()
&& find(pid).map(|p| p.enabled).unwrap_or(false)
{
return Ok(ResolvedModel {
provider_id: pid.to_owned(),
model: model.to_owned(),
source: "companion_profile",
});
}
if let Some(p) = providers.iter().find(|p| p.enabled && !p.models.is_empty()) {
return Ok(ResolvedModel {
provider_id: p.id.clone(),
model: p.models[0].clone(),
source: "first_available_provider",
});
}
Err("no model available: no provider is configured/enabled on this desktop. Call nomi_list_providers to confirm, then ask the owner to configure one in Settings → Providers — do NOT create nomi sessions or cron jobs without a model.".to_owned())
}
/// Async wrapper around [`resolve_model_chain`]: loads the provider rows and
/// the calling companion's profile model, returns a ready-to-persist
/// `ProviderWithModel` plus the resolution source.
pub(crate) async fn resolve_nomi_model(
deps: &GatewayDeps,
ctx: &CallerCtx,
explicit_provider: Option<&str>,
explicit_model: Option<&str>,
) -> Result<(ProviderWithModel, &'static str), Value> {
let providers = load_provider_summaries(deps).await?;
let companion_model = companion_profile_model(deps, ctx).await;
match resolve_model_chain(
explicit_provider,
explicit_model,
companion_model.as_ref().map(|(p, m)| (p.as_str(), m.as_str())),
&providers,
) {
Ok(r) => {
let model = r.model;
Ok((
ProviderWithModel {
provider_id: r.provider_id,
model: model.clone(),
use_model: Some(model),
},
r.source,
))
}
Err(msg) => Err(json!({"error": msg})),
}
}
/// Explicit-args-only resolution (no companion / first-provider fallback): used by
/// `nomi_update_conversation`, where a model change is an explicit owner
/// instruction that must not be silently substituted.
pub(crate) async fn resolve_explicit_model(
deps: &GatewayDeps,
explicit_provider: Option<&str>,
explicit_model: Option<&str>,
) -> Result<ProviderWithModel, Value> {
let providers = load_provider_summaries(deps).await?;
match resolve_model_chain(explicit_provider, explicit_model, None, &providers) {
Ok(r) => {
let model = r.model;
Ok(ProviderWithModel {
provider_id: r.provider_id,
model: model.clone(),
use_model: Some(model),
})
}
Err(msg) => Err(json!({"error": msg})),
}
}
/// The calling companion's configured profile model `(provider_id, model)`.
/// `ctx.companion_id` first; a missing/unconfigured bound companion degrades to the
/// default companion (mirrors `CompanionMasterAgentProfile`).
async fn companion_profile_model(deps: &GatewayDeps, ctx: &CallerCtx) -> Option<(String, String)> {
if let Some(id) = &ctx.companion_id
&& let Ok(p) = deps.companion_service.get_companion(id).await
&& p.model.is_configured()
{
return Some((p.model.provider_id, p.model.model));
}
let default_id = deps.companion_service.default_companion_id().await?;
let p = deps.companion_service.get_companion(&default_id).await.ok()?;
p.model
.is_configured()
.then(|| (p.model.provider_id, p.model.model))
}
#[cfg(test)]
mod tests {
use super::*;
fn provider(id: &str, enabled: bool, models: &[&str]) -> ProviderSummary {
ProviderSummary {
id: id.to_owned(),
name: format!("name-{id}"),
platform: "openai".to_owned(),
enabled,
models: models.iter().map(|m| m.to_string()).collect(),
}
}
#[test]
fn explicit_provider_and_model_win() {
let providers = vec![provider("p1", true, &["m1"]), provider("p2", true, &["m2"])];
let r = resolve_model_chain(Some("p2"), Some("custom-model"), Some(("p1", "m1")), &providers).unwrap();
assert_eq!(r.provider_id, "p2");
assert_eq!(r.model, "custom-model");
assert_eq!(r.source, "explicit");
}
#[test]
fn explicit_unknown_provider_errors_instead_of_falling_back() {
let providers = vec![provider("p1", true, &["m1"])];
let err = resolve_model_chain(Some("ghost"), Some("m"), Some(("p1", "m1")), &providers).unwrap_err();
assert!(err.contains("ghost"), "{err}");
}
#[test]
fn explicit_disabled_provider_errors() {
let providers = vec![provider("p1", false, &["m1"])];
let err = resolve_model_chain(Some("p1"), None, None, &providers).unwrap_err();
assert!(err.contains("disabled"), "{err}");
}
#[test]
fn explicit_provider_only_takes_its_first_model() {
let providers = vec![provider("p1", true, &["a", "b"])];
let r = resolve_model_chain(Some("p1"), None, None, &providers).unwrap();
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("p1", "a"));
assert_eq!(r.source, "explicit_provider_first_model");
}
#[test]
fn explicit_model_only_scans_enabled_providers() {
let providers = vec![
provider("p0", false, &["target"]),
provider("p1", true, &["other"]),
provider("p2", true, &["target"]),
];
let r = resolve_model_chain(None, Some("target"), None, &providers).unwrap();
assert_eq!(r.provider_id, "p2");
assert_eq!(r.source, "explicit_model");
}
#[test]
fn companion_profile_used_when_no_explicit_args() {
let providers = vec![provider("p1", true, &["m1"]), provider("p2", true, &["m2"])];
let r = resolve_model_chain(None, None, Some(("p2", "m2")), &providers).unwrap();
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("p2", "m2"));
assert_eq!(r.source, "companion_profile");
}
#[test]
fn companion_profile_with_deleted_provider_falls_through_to_first_available() {
let providers = vec![provider("p1", true, &["m1"])];
let r = resolve_model_chain(None, None, Some(("gone", "mx")), &providers).unwrap();
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("p1", "m1"));
assert_eq!(r.source, "first_available_provider");
}
#[test]
fn first_available_skips_disabled_and_empty_providers() {
let providers = vec![
provider("off", false, &["m"]),
provider("empty", true, &[]),
provider("good", true, &["pick-me"]),
];
let r = resolve_model_chain(None, None, None, &providers).unwrap();
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("good", "pick-me"));
}
#[test]
fn nothing_resolvable_returns_guidance_error() {
let err = resolve_model_chain(None, None, None, &[]).unwrap_err();
assert!(err.contains("nomi_list_providers"), "{err}");
}
#[test]
fn summarize_filters_per_model_enabled_map() {
let row = nomifun_db::models::Provider {
id: "p1".into(),
platform: "openai".into(),
name: "P1".into(),
base_url: String::new(),
api_key_encrypted: String::new(),
models: r#"["a","b","c"]"#.into(),
enabled: true,
capabilities: "[]".into(),
context_limit: None,
model_protocols: None,
model_enabled: Some(r#"{"b": false}"#.into()),
model_health: None,
bedrock_config: None,
is_full_url: false,
created_at: 0,
updated_at: 0,
};
let s = summarize_provider(&row);
assert_eq!(s.models, vec!["a".to_owned(), "c".to_owned()]);
}
}
@@ -0,0 +1,34 @@
//! Terminal-domain shared helper: the launch-preset resolver.
//!
//! The terminal CAPABILITIES live in `caps_terminal`; this module retains only
//! `preset_launch`, the backend mirror of the frontend launch presets, reused
//! by the terminal capability handlers (and unit-tested there).
/// Backend mirror of the frontend launch presets
/// (`ui/src/renderer/pages/terminal/launchPresets.ts`) — keep the two in sync.
/// Returns `(command, args, backend)`; the `$SHELL` sentinel is resolved to the
/// platform shell by `TerminalService`.
pub(crate) fn preset_launch(preset: &str, full_auto: bool) -> Result<(String, Vec<String>, Option<String>), String> {
let flag = |f: &str| {
if full_auto {
vec![f.to_owned()]
} else {
vec![]
}
};
match preset {
"shell" => Ok((nomifun_terminal::types::SHELL_SENTINEL.to_owned(), vec![], None)),
"claude" => Ok((
"claude".to_owned(),
flag("--dangerously-skip-permissions"),
Some("claude".to_owned()),
)),
"codex" => Ok((
"codex".to_owned(),
flag("--dangerously-bypass-approvals-and-sandbox"),
Some("codex".to_owned()),
)),
"gemini" => Ok(("gemini".to_owned(), flag("--yolo"), Some("gemini".to_owned()))),
other => Err(format!("unknown preset '{other}' (expected shell | claude | codex | gemini)")),
}
}
@@ -0,0 +1,46 @@
//! 网关并行执行面:`BrowserRegistry::execute_parallel` 异 key 并发、同 key 串行,结果保输入序。
//!
//! **Hermetic(不启动 Chrome**:用**未知动作**——facade 在 dispatch 即返 `Unknown action {name}`
//! 错误,**不调 `engine()`**(不解析/下载 Chrome,但仍走完 `execute` 的 per-key 锁 + 派发路径。
//! 错误消息含动作名,故可据此断言结果**按输入序**返回。
//!
//! 跑:`cargo nextest run -p nomifun-gateway --features browser-use -E 'test(execute_parallel)'`
#![cfg(feature = "browser-use")]
use nomifun_gateway::browser_registry::{tool_result_to_value, BrowserRegistry};
use serde_json::json;
#[tokio::test]
async fn execute_parallel_distinct_keys_results_in_input_order() {
let reg = BrowserRegistry::default_for_browser_use();
// 两个不同 key(异 key 并发)。未知动作 → 各自快速返错,不启动 Chrome。
let batch = vec![
("companion-a".to_string(), json!({"action": "zzz_marker_alpha"})),
("companion-b".to_string(), json!({"action": "zzz_marker_beta"})),
];
let started = std::time::Instant::now();
let results = reg.execute_parallel(batch).await;
assert_eq!(results.len(), 2, "一输入一结果");
// 保序:结果[0] 对应 alpha、结果[1] 对应 beta(错误消息回带动作名)。
let strs: Vec<String> = results
.into_iter()
.map(|r| tool_result_to_value(r).to_string())
.collect();
assert!(strs[0].contains("zzz_marker_alpha"), "结果须按输入序(idx0=alpha): {}", strs[0]);
assert!(strs[1].contains("zzz_marker_beta"), "结果须按输入序(idx1=beta): {}", strs[1]);
// 不得死锁/卡住(未知动作不启动 Chrome,应近乎瞬时)。
assert!(started.elapsed().as_secs() < 20, "execute_parallel 不得死锁/卡住");
}
#[tokio::test]
async fn execute_parallel_same_key_serializes_and_returns_all() {
let reg = BrowserRegistry::default_for_browser_use();
// 同一 key 两次:必须经该 key 的 CompanionBrowser 锁**串行**(不并发撞同一引擎),但两结果都返回。
let batch = vec![
("companion-a".to_string(), json!({"action": "zzz_one"})),
("companion-a".to_string(), json!({"action": "zzz_two"})),
];
let results = reg.execute_parallel(batch).await;
assert_eq!(results.len(), 2, "同 key 两调用都须返回(串行,无丢失)");
}