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,18 @@
[package]
name = "nomi-protocol"
description = "JSON stream protocol (events, commands, approval manager) for Nomi host integration"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
[dev-dependencies]
rstest.workspace = true
@@ -0,0 +1,248 @@
use std::collections::HashMap;
use serde::Deserialize;
/// Commands sent from the client to the agent (Client -> Agent)
#[derive(Debug, Deserialize, PartialEq)]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
pub enum ProtocolCommand {
Message {
msg_id: String,
content: String,
#[serde(default)]
files: Vec<String>,
},
Stop,
ToolApprove {
call_id: String,
#[serde(default)]
scope: ApprovalScope,
},
ToolDeny {
call_id: String,
#[serde(default)]
reason: String,
},
InitHistory {
text: String,
},
SetMode {
mode: SessionMode,
},
SetConfig {
#[serde(default)]
model: Option<String>,
#[serde(default)]
thinking: Option<String>,
#[serde(default)]
thinking_budget: Option<u32>,
#[serde(default)]
effort: Option<String>,
#[serde(default)]
compaction: Option<String>,
},
AddMcpServer {
name: String,
transport: String,
#[serde(default)]
command: Option<String>,
#[serde(default)]
args: Option<Vec<String>>,
#[serde(default)]
env: Option<HashMap<String, String>>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
headers: Option<HashMap<String, String>>,
},
Ping,
}
#[derive(Debug, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalScope {
#[default]
Once,
Always,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionMode {
Default,
AutoEdit,
Yolo,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_config_debug_format() {
let cmd = ProtocolCommand::SetConfig {
model: Some("test-model".into()),
thinking: None,
thinking_budget: None,
effort: None,
compaction: None,
};
let dbg = format!("{cmd:?}");
assert!(dbg.contains("SetConfig"));
assert!(dbg.contains("test-model"));
}
#[test]
fn set_config_equality() {
let a = ProtocolCommand::SetConfig {
model: Some("m".into()),
thinking: None,
thinking_budget: None,
effort: None,
compaction: None,
};
let b = ProtocolCommand::SetConfig {
model: Some("m".into()),
thinking: None,
thinking_budget: None,
effort: None,
compaction: None,
};
assert_eq!(a, b);
let c = ProtocolCommand::SetConfig {
model: None,
thinking: None,
thinking_budget: None,
effort: None,
compaction: None,
};
assert_ne!(a, c);
}
#[test]
fn set_config_with_all_fields_equality() {
let a = ProtocolCommand::SetConfig {
model: Some("m".into()),
thinking: Some("enabled".into()),
thinking_budget: Some(8000),
effort: Some("high".into()),
compaction: None,
};
let b = ProtocolCommand::SetConfig {
model: Some("m".into()),
thinking: Some("enabled".into()),
thinking_budget: Some(8000),
effort: Some("high".into()),
compaction: None,
};
assert_eq!(a, b);
}
#[test]
fn set_config_all_none_fields() {
let cmd = ProtocolCommand::SetConfig {
model: None,
thinking: None,
thinking_budget: None,
effort: None,
compaction: None,
};
let dbg = format!("{cmd:?}");
assert!(dbg.contains("SetConfig"));
}
#[test]
fn set_config_with_compaction() {
let json = r#"{"type":"set_config","compaction":"full"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { compaction, .. } => {
assert_eq!(compaction.unwrap(), "full");
}
_ => panic!("expected SetConfig"),
}
}
#[test]
fn set_config_compaction_none_by_default() {
let json = r#"{"type":"set_config","model":"test"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { compaction, .. } => {
assert!(compaction.is_none());
}
_ => panic!("expected SetConfig"),
}
}
#[test]
fn add_mcp_server_stdio_deserialize() {
let json = r#"{
"type": "add_mcp_server",
"name": "team-tools",
"transport": "stdio",
"command": "node",
"args": ["bridge.js", "--port", "9000"],
"env": {"TOKEN": "abc123"}
}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::AddMcpServer {
name,
transport,
command,
args,
env,
url,
headers,
} => {
assert_eq!(name, "team-tools");
assert_eq!(transport, "stdio");
assert_eq!(command.unwrap(), "node");
assert_eq!(args.unwrap(), vec!["bridge.js", "--port", "9000"]);
assert_eq!(env.unwrap().get("TOKEN").unwrap(), "abc123");
assert!(url.is_none());
assert!(headers.is_none());
}
_ => panic!("expected AddMcpServer"),
}
}
#[test]
fn ping_deserialize() {
let json = r#"{"type":"ping"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd, ProtocolCommand::Ping);
}
#[test]
fn add_mcp_server_sse_deserialize() {
let json = r#"{
"type": "add_mcp_server",
"name": "remote-tools",
"transport": "sse",
"url": "http://localhost:8080/sse",
"headers": {"Authorization": "Bearer tok"}
}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::AddMcpServer {
name,
transport,
command,
url,
headers,
..
} => {
assert_eq!(name, "remote-tools");
assert_eq!(transport, "sse");
assert!(command.is_none());
assert_eq!(url.unwrap(), "http://localhost:8080/sse");
assert_eq!(headers.unwrap().get("Authorization").unwrap(), "Bearer tok");
}
_ => panic!("expected AddMcpServer"),
}
}
}
@@ -0,0 +1,358 @@
use serde::Serialize;
use serde_json::Value;
/// Events emitted by the agent to the client (Agent -> Client)
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
pub enum ProtocolEvent {
Ready {
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
capabilities: Capabilities,
},
StreamStart {
msg_id: String,
},
TextDelta {
text: String,
msg_id: String,
},
Thinking {
text: String,
msg_id: String,
},
ToolRequest {
msg_id: String,
call_id: String,
tool: ToolInfo,
},
ToolRunning {
msg_id: String,
call_id: String,
tool_name: String,
},
ToolResult {
msg_id: String,
call_id: String,
tool_name: String,
status: ToolStatus,
output: String,
output_type: OutputType,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<Value>,
},
ToolCancelled {
msg_id: String,
call_id: String,
reason: String,
},
StreamEnd {
msg_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
usage: Option<Usage>,
},
Error {
#[serde(skip_serializing_if = "Option::is_none")]
msg_id: Option<String>,
error: ErrorInfo,
},
Info {
msg_id: String,
message: String,
},
ConfigChanged {
capabilities: Capabilities,
},
McpReady {
name: String,
tools: Vec<String>,
},
Pong,
}
#[derive(Debug, Clone, Serialize)]
pub struct Capabilities {
pub tool_approval: bool,
pub thinking: bool,
pub effort: bool,
pub effort_levels: Vec<String>,
pub modes: Vec<String>,
pub current_mode: String,
pub mcp: bool,
}
#[derive(Debug, Serialize)]
pub struct ToolInfo {
pub name: String,
pub category: ToolCategory,
pub args: Value,
pub description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCategory {
Info,
Edit,
Exec,
Mcp,
/// Irreversible action (submit / payment / delete / send). Highest approval
/// severity: never silently auto-approved by AutoEdit mode; used by the
/// browser-use action facade for fail-closed approval gating.
Irreversible,
}
impl std::fmt::Display for ToolCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => write!(f, "info"),
Self::Edit => write!(f, "edit"),
Self::Exec => write!(f, "exec"),
Self::Mcp => write!(f, "mcp"),
Self::Irreversible => write!(f, "irreversible"),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolStatus {
Success,
Error,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputType {
Text,
Diff,
Image,
}
#[derive(Debug, Serialize)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct ErrorInfo {
pub code: String,
pub message: String,
pub retryable: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_ready_event_serialization() {
let event = ProtocolEvent::Ready {
version: "0.1.0".to_string(),
session_id: Some("abc123".to_string()),
capabilities: Capabilities {
tool_approval: true,
thinking: true,
effort: false,
effort_levels: vec![],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: false,
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "ready");
assert_eq!(json["version"], "0.1.0");
assert_eq!(json["session_id"], "abc123");
assert_eq!(json["capabilities"]["tool_approval"], true);
// session_id omitted when None
let event_no_sid = ProtocolEvent::Ready {
version: "0.1.0".to_string(),
session_id: None,
capabilities: Capabilities {
tool_approval: true,
thinking: true,
effort: false,
effort_levels: vec![],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: false,
},
};
let json2 = serde_json::to_value(&event_no_sid).unwrap();
assert!(json2.get("session_id").is_none());
}
#[test]
fn test_text_delta_event_serialization() {
let event = ProtocolEvent::TextDelta {
text: "hello".to_string(),
msg_id: "m1".to_string(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "text_delta");
assert_eq!(json["text"], "hello");
assert_eq!(json["msg_id"], "m1");
}
#[test]
fn test_tool_request_event_serialization() {
let event = ProtocolEvent::ToolRequest {
msg_id: "m1".to_string(),
call_id: "c1".to_string(),
tool: ToolInfo {
name: "Bash".to_string(),
category: ToolCategory::Exec,
args: json!({"command": "ls"}),
description: "Execute: ls".to_string(),
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "tool_request");
assert_eq!(json["tool"]["category"], "exec");
}
#[test]
fn test_tool_result_event_serialization() {
let event = ProtocolEvent::ToolResult {
msg_id: "m1".to_string(),
call_id: "c1".to_string(),
tool_name: "Read".to_string(),
status: ToolStatus::Success,
output: "file content".to_string(),
output_type: OutputType::Text,
metadata: None,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "tool_result");
assert_eq!(json["status"], "success");
assert!(json.get("metadata").is_none());
}
#[test]
fn test_error_event_serialization() {
let event = ProtocolEvent::Error {
msg_id: None,
error: ErrorInfo {
code: "rate_limit".to_string(),
message: "Too many requests".to_string(),
retryable: true,
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "error");
assert!(json.get("msg_id").is_none());
assert_eq!(json["error"]["retryable"], true);
}
#[test]
fn test_stream_end_with_usage() {
let event = ProtocolEvent::StreamEnd {
msg_id: "m1".to_string(),
usage: Some(Usage {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: Some(20),
cache_write_tokens: None,
}),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "stream_end");
assert_eq!(json["usage"]["input_tokens"], 100);
assert!(json["usage"].get("cache_write_tokens").is_none());
}
#[test]
fn test_tool_category_display() {
assert_eq!(ToolCategory::Info.to_string(), "info");
assert_eq!(ToolCategory::Edit.to_string(), "edit");
assert_eq!(ToolCategory::Exec.to_string(), "exec");
assert_eq!(ToolCategory::Mcp.to_string(), "mcp");
}
#[test]
fn tool_category_irreversible_serde_roundtrip() {
// The enum only derives Serialize (matching the existing contract), so
// we assert the serialized form rather than a deserialize round-trip.
let c = ToolCategory::Irreversible;
let s = serde_json::to_string(&c).unwrap();
assert_eq!(s, "\"irreversible\""); // serde snake_case, same convention as Info/Edit/Exec/Mcp
}
#[test]
fn tool_category_irreversible_display() {
assert_eq!(ToolCategory::Irreversible.to_string(), "irreversible");
}
#[test]
fn test_ready_event_with_expanded_capabilities() {
let event = ProtocolEvent::Ready {
version: "0.2.0".to_string(),
session_id: Some("abc".to_string()),
capabilities: Capabilities {
tool_approval: true,
thinking: true,
effort: true,
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: false,
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["capabilities"]["thinking"], true);
assert_eq!(json["capabilities"]["effort"], true);
assert_eq!(json["capabilities"]["effort_levels"][0], "low");
assert_eq!(json["capabilities"]["modes"][2], "yolo");
}
#[test]
fn test_mcp_ready_event_serialization() {
let event = ProtocolEvent::McpReady {
name: "team-tools".to_string(),
tools: vec!["team_send_message".into(), "team_task_create".into()],
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "mcp_ready");
assert_eq!(json["name"], "team-tools");
assert_eq!(json["tools"][0], "team_send_message");
assert_eq!(json["tools"][1], "team_task_create");
assert_eq!(json["tools"].as_array().unwrap().len(), 2);
}
#[test]
fn test_pong_event_serialization() {
let event = ProtocolEvent::Pong;
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "pong");
assert_eq!(json.as_object().unwrap().len(), 1);
}
#[test]
fn test_config_changed_event_serialization() {
let event = ProtocolEvent::ConfigChanged {
capabilities: Capabilities {
tool_approval: true,
thinking: false,
effort: true,
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: true,
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["type"], "config_changed");
assert_eq!(json["capabilities"]["thinking"], false);
assert_eq!(json["capabilities"]["effort"], true);
}
}
@@ -0,0 +1,327 @@
// JSON stream protocol for host ↔ agent communication.
// Contains: events (agent→host), commands (host→agent), approval manager.
pub mod commands;
pub mod events;
pub mod reader;
pub mod writer;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Mutex;
use tokio::sync::oneshot;
use crate::commands::{ApprovalScope, SessionMode};
use crate::events::ToolCategory;
/// Result of a tool approval request
pub enum ToolApprovalResult {
Approved,
Denied { reason: String },
}
struct PendingApproval {
tx: oneshot::Sender<ToolApprovalResult>,
category: String,
}
/// Manages pending tool approval requests using oneshot channels.
///
/// Each pending request also stores its tool category so a client approval with
/// `ApprovalScope::Always` can persist auto-approval for future requests in the
/// same category.
///
/// Also holds the current `SessionMode` which determines which tool categories
/// are auto-approved based on the active approval policy.
pub struct ToolApprovalManager {
pending: Mutex<HashMap<String, PendingApproval>>,
auto_approved: Mutex<HashSet<String>>,
session_mode: Mutex<SessionMode>,
}
impl ToolApprovalManager {
pub fn new() -> Self {
Self {
pending: Mutex::new(HashMap::new()),
auto_approved: Mutex::new(HashSet::new()),
session_mode: Mutex::new(SessionMode::Default),
}
}
pub fn request_approval(
&self,
call_id: &str,
category: &ToolCategory,
) -> oneshot::Receiver<ToolApprovalResult> {
let (tx, rx) = oneshot::channel();
if let Ok(mut pending) = self.pending.lock() {
pending.insert(
call_id.to_string(),
PendingApproval {
tx,
category: category.to_string(),
},
);
}
rx
}
pub fn approve(&self, call_id: &str, scope: ApprovalScope) {
let pending = self
.pending
.lock()
.ok()
.and_then(|mut pending| pending.remove(call_id));
if let Some(pending) = pending {
if matches!(scope, ApprovalScope::Always) {
self.add_auto_approve(&pending.category);
}
let _ = pending.tx.send(ToolApprovalResult::Approved);
}
}
pub fn resolve(&self, call_id: &str, result: ToolApprovalResult) {
if let Some(pending) = self
.pending
.lock()
.ok()
.and_then(|mut pending| pending.remove(call_id))
{
let _ = pending.tx.send(result);
}
}
pub fn is_auto_approved(&self, category: &str) -> bool {
// Check session mode first
let mode_approved = self
.session_mode
.lock()
.map(|mode| match *mode {
SessionMode::Yolo => true,
SessionMode::AutoEdit => category == "info" || category == "edit",
SessionMode::Default => false,
})
.unwrap_or(false);
if mode_approved {
return true;
}
// Fall back to per-category "always" approvals
self.auto_approved
.lock()
.map(|auto| auto.contains(category))
.unwrap_or(false)
}
/// Set the session approval mode. Takes effect immediately.
pub fn set_mode(&self, mode: SessionMode) {
if let Ok(mut current) = self.session_mode.lock() {
*current = mode;
}
}
/// **P3-X1: does the *current* session mode bypass orchestration approval entirely?**
///
/// This is the LIVE, runtime-flippable analogue of `config.tools.auto_approve`: it reads
/// the current `session_mode` (mutated by [`Self::set_mode`], which takes effect
/// immediately) and answers whether *every* tool category — including
/// [`ToolCategory::Irreversible`] — is auto-approved.
///
/// **Mapping (the F1-sec redline direction, authoritative here so it lives in one place):**
/// - [`SessionMode::Yolo`] → `true` (bypasses approval for all categories, including
/// irreversible — this is exactly when the browser facade's independent fail-closed
/// redline gate must arm);
/// - [`SessionMode::AutoEdit`] → **`false`** — auto-edit auto-approves only `info`/`edit`,
/// **never** `exec`/`mcp`/irreversible, so the orchestration approval gate still fires for
/// an irreversible web action; it does NOT bypass approval and must NOT arm the redline gate;
/// - [`SessionMode::Default`] → `false`.
///
/// This mirrors the `Yolo => true` arm of [`Self::is_auto_approved`] (which is `true` for
/// every category iff yolo), keeping "bypasses approval" === "yolo" in a single definition.
/// Per-category user "always" approvals are intentionally NOT consulted: a manual
/// `add_auto_approve("exec")` is a scoped grant, not a wholesale approval bypass, so it must
/// not arm the facade redline gate against irreversible actions.
pub fn session_bypasses_approval(&self) -> bool {
self.session_mode
.lock()
.map(|mode| matches!(*mode, SessionMode::Yolo))
.unwrap_or(false)
}
/// Return the current session mode as a string for capability reporting.
pub fn current_mode(&self) -> String {
self.session_mode
.lock()
.map(|mode| match *mode {
SessionMode::Default => "default",
SessionMode::AutoEdit => "auto_edit",
SessionMode::Yolo => "yolo",
})
.unwrap_or("default")
.to_string()
}
pub fn drop_pending(&self, call_id: &str) {
if let Ok(mut pending) = self.pending.lock() {
pending.remove(call_id);
}
}
pub fn add_auto_approve(&self, category: &str) {
if let Ok(mut auto) = self.auto_approved.lock() {
auto.insert(category.to_string());
}
}
}
impl Default for ToolApprovalManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- SessionMode: default mode ---
#[test]
fn default_mode_does_not_auto_approve_any_category() {
let mgr = ToolApprovalManager::new();
assert!(!mgr.is_auto_approved("info"));
assert!(!mgr.is_auto_approved("edit"));
assert!(!mgr.is_auto_approved("exec"));
assert!(!mgr.is_auto_approved("mcp"));
}
#[test]
fn default_mode_current_mode_string() {
let mgr = ToolApprovalManager::new();
assert_eq!(mgr.current_mode(), "default");
}
// --- SessionMode: auto_edit mode ---
#[test]
fn auto_edit_mode_approves_info_and_edit() {
let mgr = ToolApprovalManager::new();
mgr.set_mode(SessionMode::AutoEdit);
assert!(mgr.is_auto_approved("info"));
assert!(mgr.is_auto_approved("edit"));
}
#[test]
fn auto_edit_mode_requires_approval_for_exec_and_mcp() {
let mgr = ToolApprovalManager::new();
mgr.set_mode(SessionMode::AutoEdit);
assert!(!mgr.is_auto_approved("exec"));
assert!(!mgr.is_auto_approved("mcp"));
}
#[test]
fn auto_edit_mode_current_mode_string() {
let mgr = ToolApprovalManager::new();
mgr.set_mode(SessionMode::AutoEdit);
assert_eq!(mgr.current_mode(), "auto_edit");
}
// --- SessionMode: yolo mode ---
#[test]
fn yolo_mode_approves_all_categories() {
let mgr = ToolApprovalManager::new();
mgr.set_mode(SessionMode::Yolo);
assert!(mgr.is_auto_approved("info"));
assert!(mgr.is_auto_approved("edit"));
assert!(mgr.is_auto_approved("exec"));
assert!(mgr.is_auto_approved("mcp"));
}
#[test]
fn yolo_mode_current_mode_string() {
let mgr = ToolApprovalManager::new();
mgr.set_mode(SessionMode::Yolo);
assert_eq!(mgr.current_mode(), "yolo");
}
// --- Mode switching ---
#[test]
fn switching_mode_changes_approval_behavior() {
let mgr = ToolApprovalManager::new();
// Start in default
assert!(!mgr.is_auto_approved("edit"));
// Switch to auto_edit
mgr.set_mode(SessionMode::AutoEdit);
assert!(mgr.is_auto_approved("edit"));
assert!(!mgr.is_auto_approved("exec"));
// Switch to yolo
mgr.set_mode(SessionMode::Yolo);
assert!(mgr.is_auto_approved("exec"));
// Switch back to default
mgr.set_mode(SessionMode::Default);
assert!(!mgr.is_auto_approved("edit"));
assert!(!mgr.is_auto_approved("exec"));
}
// --- Mode + user "always" approval coexistence ---
#[test]
fn user_always_approval_persists_across_mode_changes() {
let mgr = ToolApprovalManager::new();
// User manually approves "exec" category with "always"
mgr.add_auto_approve("exec");
assert!(mgr.is_auto_approved("exec"));
// Switch to auto_edit: exec still approved via user "always"
mgr.set_mode(SessionMode::AutoEdit);
assert!(mgr.is_auto_approved("exec"));
assert!(mgr.is_auto_approved("info")); // from mode
// Switch back to default: exec still approved via user "always"
mgr.set_mode(SessionMode::Default);
assert!(mgr.is_auto_approved("exec"));
assert!(!mgr.is_auto_approved("info")); // mode no longer provides this
}
// --- P3-X1: session_bypasses_approval (the LIVE redline-arming query) ---
#[test]
fn session_bypasses_approval_only_yolo_bypasses() {
let mgr = ToolApprovalManager::new();
// Default: never bypasses.
assert!(!mgr.session_bypasses_approval());
// AutoEdit auto-approves info/edit only — it does NOT bypass approval (irreversible
// still gated). The facade redline gate must NOT arm here.
mgr.set_mode(SessionMode::AutoEdit);
assert!(!mgr.session_bypasses_approval());
// Yolo bypasses approval for every category, including irreversible → arms the gate.
mgr.set_mode(SessionMode::Yolo);
assert!(mgr.session_bypasses_approval());
// Flipping back to default un-arms it (LIVE — set_mode takes effect immediately).
mgr.set_mode(SessionMode::Default);
assert!(!mgr.session_bypasses_approval());
}
#[test]
fn session_bypasses_approval_ignores_per_category_always_grants() {
let mgr = ToolApprovalManager::new();
// A scoped "always exec" grant is NOT a wholesale approval bypass — it must not arm the
// facade redline gate against irreversible actions (only yolo does).
mgr.add_auto_approve("exec");
assert!(mgr.is_auto_approved("exec"));
assert!(!mgr.session_bypasses_approval());
}
}
@@ -0,0 +1,45 @@
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
use crate::commands::ProtocolCommand;
/// Reads JSON Lines from stdin in a background task.
/// Returns a channel receiver for parsed commands.
pub fn spawn_stdin_reader() -> mpsc::UnboundedReceiver<ProtocolCommand> {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break, // EOF - client closed stdin
Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<ProtocolCommand>(trimmed) {
Ok(cmd) => {
if tx.send(cmd).is_err() {
break;
}
}
Err(e) => {
tracing::debug!(target: "nomi_protocol", error = %e, "invalid protocol command");
}
}
}
Err(e) => {
tracing::debug!(target: "nomi_protocol", error = %e, "stdin read error");
break;
}
}
}
});
rx
}
@@ -0,0 +1,75 @@
use std::io::{self, BufWriter, Stdout, Write};
use std::sync::Mutex;
use crate::events::ProtocolEvent;
/// Trait for emitting protocol events to a host.
///
/// The default implementation (`ProtocolWriter`) writes JSON Lines to stdout.
/// Backend integrations provide alternative implementations that bridge events
/// to their own event systems.
pub trait ProtocolEmitter: Send + Sync {
fn emit(&self, event: &ProtocolEvent) -> io::Result<()>;
}
/// Thread-safe JSON Lines writer to stdout
pub struct ProtocolWriter {
writer: Mutex<BufWriter<Stdout>>,
}
impl Default for ProtocolWriter {
fn default() -> Self {
Self::new()
}
}
impl ProtocolWriter {
pub fn new() -> Self {
Self {
writer: Mutex::new(BufWriter::new(io::stdout())),
}
}
}
impl ProtocolEmitter for ProtocolWriter {
fn emit(&self, event: &ProtocolEvent) -> io::Result<()> {
let mut w = self
.writer
.lock()
.map_err(|_| io::Error::other("protocol writer lock poisoned"))?;
serde_json::to_writer(&mut *w, event)
.map_err(|e| io::Error::other(format!("failed to serialize protocol event: {}", e)))?;
writeln!(&mut *w)?;
w.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{Capabilities, ProtocolEvent};
#[test]
fn test_writer_construction() {
let _writer = ProtocolWriter::new();
}
#[test]
fn test_writer_emit_does_not_panic() {
let writer = ProtocolWriter::new();
let event = ProtocolEvent::Ready {
version: "0.1.0".to_string(),
session_id: None,
capabilities: Capabilities {
tool_approval: true,
thinking: false,
effort: false,
effort_levels: vec![],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: false,
},
};
let _ = writer.emit(&event);
}
}
@@ -0,0 +1,44 @@
use nomi_protocol::commands::ApprovalScope;
use nomi_protocol::events::ToolCategory;
use nomi_protocol::{ToolApprovalManager, ToolApprovalResult};
use rstest::rstest;
#[rstest]
#[case(ApprovalScope::Once, ToolCategory::Exec, "exec", false)]
#[case(ApprovalScope::Always, ToolCategory::Edit, "edit", true)]
#[tokio::test]
async fn approve_resolves_request_and_updates_auto_approval(
#[case] scope: ApprovalScope,
#[case] category: ToolCategory,
#[case] category_name: &str,
#[case] should_auto_approve: bool,
) {
let manager = ToolApprovalManager::new();
let rx = manager.request_approval("call-1", &category);
manager.approve("call-1", scope);
let result = rx.await.expect("approval result should arrive");
assert!(matches!(result, ToolApprovalResult::Approved));
assert_eq!(manager.is_auto_approved(category_name), should_auto_approve);
}
#[tokio::test]
async fn resolve_preserves_denial_reason() {
let manager = ToolApprovalManager::new();
let rx = manager.request_approval("call-2", &ToolCategory::Exec);
manager.resolve(
"call-2",
ToolApprovalResult::Denied {
reason: "policy violation".to_string(),
},
);
let result = rx.await.expect("denial result should arrive");
assert!(matches!(
result,
ToolApprovalResult::Denied { reason } if reason == "policy violation"
));
assert!(!manager.is_auto_approved("exec"));
}
@@ -0,0 +1,84 @@
use nomi_protocol::events::{Capabilities, ProtocolEvent};
#[test]
fn capabilities_serialize_with_all_fields() {
let caps = Capabilities {
tool_approval: true,
thinking: true,
effort: false,
effort_levels: vec![],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: true,
};
let event = ProtocolEvent::Ready {
version: "0.2.0".into(),
session_id: None,
capabilities: caps,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "ready");
assert_eq!(parsed["capabilities"]["thinking"], true);
assert_eq!(parsed["capabilities"]["effort"], false);
assert!(
parsed["capabilities"]["effort_levels"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(parsed["capabilities"]["modes"].as_array().unwrap().len(), 3);
assert_eq!(parsed["capabilities"]["current_mode"], "default");
}
#[test]
fn config_changed_event_serializes_correctly() {
let caps = Capabilities {
tool_approval: true,
thinking: false,
effort: true,
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: false,
};
let event = ProtocolEvent::ConfigChanged { capabilities: caps };
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "config_changed");
assert_eq!(parsed["capabilities"]["effort_levels"][1], "medium");
}
#[test]
fn capabilities_with_effort_levels_roundtrip() {
let caps = Capabilities {
tool_approval: true,
thinking: false,
effort: true,
effort_levels: vec!["low".into(), "medium".into(), "high".into()],
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: "default".into(),
mcp: true,
};
let event = ProtocolEvent::Ready {
version: "0.2.0".into(),
session_id: Some("test-session".into()),
capabilities: caps,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["capabilities"]["effort"], true);
assert_eq!(
parsed["capabilities"]["effort_levels"]
.as_array()
.unwrap()
.len(),
3
);
assert_eq!(parsed["capabilities"]["effort_levels"][0], "low");
assert_eq!(parsed["capabilities"]["effort_levels"][2], "high");
assert_eq!(parsed["session_id"], "test-session");
}
@@ -0,0 +1,85 @@
use nomi_protocol::commands::{ApprovalScope, ProtocolCommand, SessionMode};
use rstest::rstest;
#[rstest]
#[case(
r#"{"type":"message","msg_id":"m1","content":"Hello"}"#,
ProtocolCommand::Message {
msg_id: "m1".to_string(),
content: "Hello".to_string(),
files: vec![],
}
)]
#[case(
r#"{"type":"message","msg_id":"m2","content":"Read this","files":["/tmp/a.rs"]}"#,
ProtocolCommand::Message {
msg_id: "m2".to_string(),
content: "Read this".to_string(),
files: vec!["/tmp/a.rs".to_string()],
}
)]
#[case(r#"{"type":"stop"}"#, ProtocolCommand::Stop)]
#[case(
r#"{"type":"init_history","text":"history"}"#,
ProtocolCommand::InitHistory {
text: "history".to_string(),
}
)]
#[case(
r#"{"type":"set_mode","mode":"default"}"#,
ProtocolCommand::SetMode {
mode: SessionMode::Default,
}
)]
#[case(
r#"{"type":"set_mode","mode":"auto_edit"}"#,
ProtocolCommand::SetMode {
mode: SessionMode::AutoEdit,
}
)]
#[case(
r#"{"type":"set_mode","mode":"yolo"}"#,
ProtocolCommand::SetMode {
mode: SessionMode::Yolo,
}
)]
fn deserializes_protocol_commands(#[case] json: &str, #[case] expected: ProtocolCommand) {
let cmd: ProtocolCommand = serde_json::from_str(json).expect("command should deserialize");
assert_eq!(cmd, expected);
}
#[rstest]
#[case(r#"{"type":"tool_approve","call_id":"c1"}"#, ApprovalScope::Once)]
#[case(
r#"{"type":"tool_approve","call_id":"c1","scope":"always"}"#,
ApprovalScope::Always
)]
fn deserializes_tool_approve_scope(#[case] json: &str, #[case] expected_scope: ApprovalScope) {
let cmd: ProtocolCommand = serde_json::from_str(json).expect("tool approve should deserialize");
match cmd {
ProtocolCommand::ToolApprove { call_id, scope } => {
assert_eq!(call_id, "c1");
assert_eq!(scope, expected_scope);
}
other => panic!("expected ToolApprove, got {other:?}"),
}
}
#[rstest]
#[case(r#"{"type":"tool_deny","call_id":"c1"}"#, "")]
#[case(
r#"{"type":"tool_deny","call_id":"c1","reason":"not allowed"}"#,
"not allowed"
)]
fn deserializes_tool_deny_reason(#[case] json: &str, #[case] expected_reason: &str) {
let cmd: ProtocolCommand = serde_json::from_str(json).expect("tool deny should deserialize");
match cmd {
ProtocolCommand::ToolDeny { call_id, reason } => {
assert_eq!(call_id, "c1");
assert_eq!(reason, expected_reason);
}
other => panic!("expected ToolDeny, got {other:?}"),
}
}
@@ -0,0 +1,186 @@
use nomi_protocol::commands::ProtocolCommand;
#[test]
fn parse_set_config_with_model() {
let json = r#"{"type":"set_config","model":"claude-sonnet-4-5-20250514"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { model, .. } => {
assert_eq!(model.as_deref(), Some("claude-sonnet-4-5-20250514"));
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn parse_set_config_empty() {
let json = r#"{"type":"set_config"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { model, .. } => {
assert!(model.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn parse_set_config_null_model() {
let json = r#"{"type":"set_config","model":null}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { model, .. } => {
assert!(model.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn parse_set_config_unknown_fields_ignored() {
let json = r#"{"type":"set_config","model":"x","future_field":true,"nested":{"a":1}}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { model, .. } => {
assert_eq!(model.as_deref(), Some("x"));
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn existing_commands_still_parse() {
// AC-7: Verify SetConfig addition doesn't break existing variants
let message = r#"{"type":"message","msg_id":"m1","content":"hello"}"#;
assert!(serde_json::from_str::<ProtocolCommand>(message).is_ok());
let stop = r#"{"type":"stop"}"#;
assert!(serde_json::from_str::<ProtocolCommand>(stop).is_ok());
let approve = r#"{"type":"tool_approve","call_id":"c1"}"#;
assert!(serde_json::from_str::<ProtocolCommand>(approve).is_ok());
let deny = r#"{"type":"tool_deny","call_id":"c1"}"#;
assert!(serde_json::from_str::<ProtocolCommand>(deny).is_ok());
let init = r#"{"type":"init_history","text":"ctx"}"#;
assert!(serde_json::from_str::<ProtocolCommand>(init).is_ok());
let mode = r#"{"type":"set_mode","mode":"yolo"}"#;
assert!(serde_json::from_str::<ProtocolCommand>(mode).is_ok());
}
// --- Cycle 2: Effort parsing tests ---
#[test]
fn parse_set_config_with_effort() {
let json = r#"{"type":"set_config","effort":"high"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { effort, model, .. } => {
assert_eq!(effort.as_deref(), Some("high"));
assert!(model.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn parse_set_config_with_null_effort() {
let json = r#"{"type":"set_config","effort":null}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { effort, .. } => {
assert!(effort.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
// --- Cycle 2: Thinking parsing tests ---
#[test]
fn parse_set_config_with_thinking_enabled_and_budget() {
let json = r#"{"type":"set_config","thinking":"enabled","thinking_budget":16000}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig {
thinking,
thinking_budget,
..
} => {
assert_eq!(thinking.as_deref(), Some("enabled"));
assert_eq!(thinking_budget, Some(16000));
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn parse_set_config_with_thinking_disabled() {
let json = r#"{"type":"set_config","thinking":"disabled"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig {
thinking,
thinking_budget,
..
} => {
assert_eq!(thinking.as_deref(), Some("disabled"));
assert!(thinking_budget.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn parse_set_config_with_null_thinking() {
let json = r#"{"type":"set_config","thinking":null}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig { thinking, .. } => {
assert!(thinking.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
#[test]
fn parse_set_config_thinking_enabled_no_budget() {
let json = r#"{"type":"set_config","thinking":"enabled"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig {
thinking,
thinking_budget,
..
} => {
assert_eq!(thinking.as_deref(), Some("enabled"));
assert!(thinking_budget.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}
// --- Cycle 2: Combined fields test ---
#[test]
fn parse_set_config_all_fields() {
let json = r#"{"type":"set_config","model":"m","effort":"low","thinking":"disabled"}"#;
let cmd: ProtocolCommand = serde_json::from_str(json).unwrap();
match cmd {
ProtocolCommand::SetConfig {
model,
effort,
thinking,
thinking_budget,
..
} => {
assert_eq!(model.as_deref(), Some("m"));
assert_eq!(effort.as_deref(), Some("low"));
assert_eq!(thinking.as_deref(), Some("disabled"));
assert!(thinking_budget.is_none());
}
other => panic!("expected SetConfig, got: {other:?}"),
}
}