From 30341652780bd536fa8a7173db4e01c0998d837e Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 17:58:47 +0800 Subject: [PATCH 1/8] update git hash for leptos_wasi --- examples/leptos_ssr_axum/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/leptos_ssr_axum/Cargo.toml b/examples/leptos_ssr_axum/Cargo.toml index 91c4b8c..09f639c 100644 --- a/examples/leptos_ssr_axum/Cargo.toml +++ b/examples/leptos_ssr_axum/Cargo.toml @@ -19,7 +19,7 @@ hydration_context = { version = "0.3.0" } leptos = { version = "0.8.9" } leptos_meta = { version = "0.8.5" } leptos_router = { version = "0.8.7" } -leptos_wasi = { git = "https://github.com/leptos-rs/leptos_wasi", rev = "216acc484b0d6fe4b18876f1c96b68272498592b", default-features = false, features = ["wasip3"], optional = true } +leptos_wasi = { git = "https://github.com/leptos-rs/leptos_wasi", rev = "0ee7282c8115e5747cbd3d61a375d805601ca6ef", default-features = false, features = ["wasip3"], optional = true } server_fn = { version = "0.8.7", features = ["axum-no-default"] } spin-sdk = { version = "6.0.0", features = ["json"], optional = true } wasip3 = { version = "0.6.0", features = ["http-compat"], optional = true } From 7867ff725f2b4e7dd522f18d9c161620b64cea86 Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 18:40:37 +0800 Subject: [PATCH 2/8] feat: add ClientInfo handshake, Vertex AI config, TerminalError status, and MCP server fields - Add ClientInfo message (language, version) to InputConfig proto for SDK identification - Add use_vertex, project, location fields to InputConfig for Vertex AI backend support - Add TerminalError variant to StepStatus enum (proto state=5) - Add AntigravityExecutionError type for terminal failures - Add name, enabled_tools, disabled_tools fields to all McpServerConfig variants - Add McpServerConfig::name() accessor method - Extend GeminiConfig with vertex/project/location fields and update default tests --- proto/localharness.proto | 11 +++++++ src/types.rs | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/proto/localharness.proto b/proto/localharness.proto index b3c04ef..a1b84a5 100644 --- a/proto/localharness.proto +++ b/proto/localharness.proto @@ -6,10 +6,17 @@ enum NullValue { NULL_VALUE = 0; } +message ClientInfo { + optional string language = 1; + optional string version = 2; + optional string language_version = 3; +} + message InputConfig { optional string storage_directory = 1; optional uint32 port = 2; optional string bind_address = 3; + optional ClientInfo client_info = 4; } message InitializeConversationEvent { @@ -50,6 +57,9 @@ message GeminiConfig { optional string thinking_level = 4; optional bool enable_url_context = 5; optional bool enable_google_search = 6; + optional bool use_vertex = 7; + optional string project = 8; + optional string location = 9; } message GemmaConfig { @@ -173,6 +183,7 @@ message StepUpdate { STATE_DONE = 2; STATE_WAITING_FOR_USER = 3; STATE_ERROR = 4; + STATE_TERMINAL_ERROR = 5; } enum Source { diff --git a/src/types.rs b/src/types.rs index 68f1cd3..74b8a43 100644 --- a/src/types.rs +++ b/src/types.rs @@ -100,6 +100,15 @@ pub struct GeminiConfig { /// Global API key for Gemini endpoints. #[serde(skip_serializing_if = "Option::is_none")] pub api_key: Option, + /// If true, uses the Vertex AI backend instead of Gemini Developer API. + #[serde(default)] + pub vertex: bool, + /// GCP Project ID for Vertex AI. + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + /// GCP Location/Region for Vertex AI (e.g., "us-central1"). + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, /// Model configurations. #[serde(default)] pub models: ModelConfig, @@ -238,23 +247,41 @@ pub enum McpServerConfig { /// Launch the MCP server as a local stdio process. #[serde(rename = "stdio")] Stdio { + /// Unique identifier for this MCP server. + name: String, /// command binary. command: String, /// execution arguments. args: Vec, + /// Explicit allowlist of tools to enable. Mutually exclusive with `disabled_tools`. + #[serde(skip_serializing_if = "Option::is_none")] + enabled_tools: Option>, + /// Explicit denylist of tools to disable. Mutually exclusive with `enabled_tools`. + #[serde(skip_serializing_if = "Option::is_none")] + disabled_tools: Option>, }, /// Connect to the MCP server via Server-Sent Events (SSE). #[serde(rename = "sse")] Sse { + /// Unique identifier for this MCP server. + name: String, /// HTTP URL endpoint. url: String, /// Additional HTTP headers. #[serde(skip_serializing_if = "Option::is_none")] headers: Option>, + /// Explicit allowlist of tools to enable. + #[serde(skip_serializing_if = "Option::is_none")] + enabled_tools: Option>, + /// Explicit denylist of tools to disable. + #[serde(skip_serializing_if = "Option::is_none")] + disabled_tools: Option>, }, /// Connect to the MCP server via standard HTTP. #[serde(rename = "http")] Http { + /// Unique identifier for this MCP server. + name: String, /// HTTP URL endpoint. url: String, /// Additional HTTP headers. @@ -269,9 +296,42 @@ pub enum McpServerConfig { /// Flag whether to terminate the channel connection when closed. #[serde(default = "default_true")] terminate_on_close: bool, + /// Explicit allowlist of tools to enable. + #[serde(skip_serializing_if = "Option::is_none")] + enabled_tools: Option>, + /// Explicit denylist of tools to disable. + #[serde(skip_serializing_if = "Option::is_none")] + disabled_tools: Option>, }, } +impl McpServerConfig { + /// Returns the unique name identifier of this MCP server. + pub fn name(&self) -> &str { + match self { + Self::Stdio { name, .. } | Self::Sse { name, .. } | Self::Http { name, .. } => name, + } + } +} + +/// Error raised when the agent execution encounters a terminal (non-recoverable) error. +/// +/// This indicates that the agent loop has terminated due to a fatal error +/// (e.g. model call failure, system constraint violation) and cannot continue. +#[derive(Debug, Clone)] +pub struct AntigravityExecutionError { + /// The error message describing the terminal failure. + pub message: String, +} + +impl std::fmt::Display for AntigravityExecutionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Terminal execution error: {}", self.message) + } +} + +impl std::error::Error for AntigravityExecutionError {} + const fn default_mcp_timeout() -> f64 { 30.0 } @@ -404,6 +464,9 @@ pub enum StepStatus { /// Execution was canceled. #[serde(rename = "CANCELED")] Canceled, + /// A fatal, non-recoverable error occurred during execution. + #[serde(rename = "TERMINAL_ERROR")] + TerminalError, /// Unknown status. #[serde(rename = "UNKNOWN")] Unknown, @@ -695,6 +758,9 @@ mod tests { fn test_gemini_config_defaults() { let config = GeminiConfig::default(); assert!(config.api_key.is_none()); + assert!(!config.vertex); + assert!(config.project.is_none()); + assert!(config.location.is_none()); assert_eq!(config.models.default.name, DEFAULT_MODEL); assert!(config.models.default.generation.thinking_level.is_none()); assert!(config.enable_google_search.is_none()); From 938f375fc1dad0cb0c3bb02ee697667f9577d8a0 Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 18:40:45 +0800 Subject: [PATCH 3/8] feat: upgrade PolicyEnforcer to 9-bucket priority system with MCP tool parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand from 6-bucket to 9-bucket system (specific/prefix/global × deny/ask/allow) - Add longest-match MCP tool parsing via sorted server_names list - Add fail-closed security guard: reject MCP policies without registered servers - Update enforce() signature to accept mcp_servers parameter - Add matches_target() for granular policy matching (exact, prefix wildcard, global) - Update all existing tests for new enforce() API signature --- src/policy.rs | 463 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 384 insertions(+), 79 deletions(-) diff --git a/src/policy.rs b/src/policy.rs index 17197f1..7ba513f 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -5,7 +5,7 @@ //! or require explicit user confirmation. use crate::hooks::Hook; -use crate::types::{HookResult, ToolCall}; +use crate::types::{HookResult, McpServerConfig, ToolCall}; use std::path::Path; use std::sync::Arc; @@ -208,49 +208,133 @@ pub fn workspace_only(workspaces: Vec) -> Vec { /// Validates and compiles a list of [`Policy`] items into a [`PolicyEnforcer`]. /// +/// # Arguments +/// +/// * `policies` - Policy rules to enforce. +/// * `mcp_servers` - Registered MCP server configurations. Required when any policy +/// uses MCP-style targets (containing `/`). If MCP policies are present without +/// registered servers, returns an error to prevent silent security bypasses (fail-closed). +/// /// # Errors /// -/// Returns an error if any `AskUser` policy is missing a confirmation handler callback. -pub fn enforce(policies: Vec) -> Result { +/// Returns an error if any `AskUser` policy is missing a confirmation handler callback, +/// or if MCP policies are present but `mcp_servers` is empty/None. +pub fn enforce( + policies: Vec, + mcp_servers: Option<&[McpServerConfig]>, +) -> Result { + // Validate MCP policies (fail-closed security guard). + let has_mcp_policy = policies + .iter() + .any(|p| p.tool.contains('/') && p.tool != "*"); + let mcp_empty = mcp_servers.is_none_or(<[McpServerConfig]>::is_empty); + if has_mcp_policy && mcp_empty { + return Err(anyhow::anyhow!( + "MCP policies (containing '/') were detected, but 'mcp_servers' was not \ + provided to enforce(). You must pass the registered MCP servers to \ + enable secure policy matching and prevent silent bypasses." + )); + } + for p in &policies { if p.decision == Decision::AskUser && p.ask_user.is_none() { return Err(anyhow::anyhow!( - "ASK_USER policy '{}' is missing an ask_user handler. Provide one via policy.ask_user(tool, handler).", + "ASK_USER policy '{}' is missing an ask_user handler. \ + Provide one via policy.ask_user(tool, handler).", if p.name.is_empty() { &p.tool } else { &p.name } )); } } - Ok(PolicyEnforcer::new(policies)) + + let server_names: Vec = mcp_servers + .map(|servers| servers.iter().map(|s| s.name().to_string()).collect()) + .unwrap_or_default(); + + Ok(PolicyEnforcer::new(policies, server_names)) } /// Safety policy middleware enforcer implemented as a lifecycle [`Hook`]. pub struct PolicyEnforcer { - buckets: [Vec; 6], + buckets: [Vec; 9], + /// Known MCP server names, sorted descending by length for longest-match parsing. + server_names: Vec, +} + +/// Priority bucket indices (lower = higher priority). +/// +/// Specific tool matches (e.g. `run_command`, `server/tool`) have highest priority. +/// Prefix wildcards (e.g. `server/*`) have medium priority. +/// Global wildcards (`*`) have lowest priority. +const LEVEL_SPECIFIC_DENY: usize = 0; +const LEVEL_SPECIFIC_ASK: usize = 1; +const LEVEL_SPECIFIC_ALLOW: usize = 2; +const LEVEL_PREFIX_DENY: usize = 3; +const LEVEL_PREFIX_ASK: usize = 4; +const LEVEL_PREFIX_ALLOW: usize = 5; +const LEVEL_GLOBAL_DENY: usize = 6; +const LEVEL_GLOBAL_ASK: usize = 7; +const LEVEL_GLOBAL_ALLOW: usize = 8; + +/// Returns true if the tool selector is a global wildcard ("*"). +fn is_global_wildcard(tool: &str) -> bool { + tool == "*" +} + +/// Returns true if the tool selector is a prefix wildcard (e.g. "server/*"). +fn is_prefix_wildcard(tool: &str) -> bool { + tool.ends_with("/*") +} + +/// Returns the priority bucket index for a policy. +fn bucket_index(p: &Policy) -> usize { + if is_global_wildcard(&p.tool) { + match p.decision { + Decision::Deny => LEVEL_GLOBAL_DENY, + Decision::AskUser => LEVEL_GLOBAL_ASK, + Decision::Approve => LEVEL_GLOBAL_ALLOW, + } + } else if is_prefix_wildcard(&p.tool) { + match p.decision { + Decision::Deny => LEVEL_PREFIX_DENY, + Decision::AskUser => LEVEL_PREFIX_ASK, + Decision::Approve => LEVEL_PREFIX_ALLOW, + } + } else { + match p.decision { + Decision::Deny => LEVEL_SPECIFIC_DENY, + Decision::AskUser => LEVEL_SPECIFIC_ASK, + Decision::Approve => LEVEL_SPECIFIC_ALLOW, + } + } } impl PolicyEnforcer { /// Compiles policies into prioritized buckets for performance and precedence. - pub fn new(policies: Vec) -> Self { - let mut buckets = [ - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ]; + pub fn new(policies: Vec, server_names: Vec) -> Self { + let mut buckets: [Vec; 9] = Default::default(); for p in policies { - let level = match (p.tool == "*", p.decision) { - (false, Decision::Deny) => 0, - (false, Decision::AskUser) => 1, - (false, Decision::Approve) => 2, - (true, Decision::Deny) => 3, - (true, Decision::AskUser) => 4, - (true, Decision::Approve) => 5, - }; - buckets[level].push(p); + buckets[bucket_index(&p)].push(p); + } + // Sort server names descending by length for longest-match-first parsing (security). + let mut server_names = server_names; + server_names.sort_by_key(|b| std::cmp::Reverse(b.len())); + Self { + buckets, + server_names, + } + } + + /// Parses an MCP tool call name like `mcp_server_tool` into (`server`, `tool`) + /// using the known server names list, trying longest names first for security. + fn parse_mcp_tool(&self, tool_name: &str) -> Option<(String, String)> { + let rest = tool_name.strip_prefix("mcp_")?; + for server in &self.server_names { + let prefix = format!("{}_", server); + if let Some(tool) = rest.strip_prefix(&prefix).filter(|t| !t.is_empty()) { + return Some((server.clone(), tool.to_string())); + } } - Self { buckets } + None } } @@ -258,16 +342,46 @@ impl std::fmt::Debug for PolicyEnforcer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PolicyEnforcer") .field("buckets", &self.buckets) + .field("server_names", &self.server_names) .finish() } } +/// Matches a policy's tool selector against a parsed call target. +/// +/// - Global wildcard ("*") matches everything. +/// - For MCP tools: prefix wildcard ("server/*") matches the server prefix; +/// exact match ("server/tool") matches exactly. +/// - For non-MCP tools: exact match only. +fn matches_target(policy_tool: &str, call_target: &str, is_mcp: bool) -> bool { + if policy_tool == "*" { + return true; + } + if is_mcp { + if is_prefix_wildcard(policy_tool) { + // "server/*" → extract "server" and compare with the call's server part + let policy_server = &policy_tool[..policy_tool.len() - 2]; // strip "/*" + if let Some((call_server, _)) = call_target.split_once('/') { + return policy_server == call_server; + } + return false; + } + return policy_tool == call_target; + } + policy_tool == call_target +} + impl Hook for PolicyEnforcer { async fn pre_tool_call(&self, tool_call: &ToolCall) -> Result { + // Parse MCP tool name once for all policy evaluations. + let (call_target, is_mcp) = match self.parse_mcp_tool(&tool_call.name) { + Some((server, tool)) => (format!("{server}/{tool}"), true), + None => (tool_call.name.clone(), false), + }; + for bucket in &self.buckets { for p in bucket { - let matches_tool = p.tool == "*" || p.tool == tool_call.name; - if !matches_tool { + if !matches_target(&p.tool, &call_target, is_mcp) { continue; } @@ -356,6 +470,74 @@ impl Hook for PolicyEnforcer { } } +/// Creates APPROVE policies for an MCP server's tools. +/// +/// If `tools` is None, creates a prefix wildcard policy (`server/*`) for all tools. +/// If `tools` is Some, creates specific policies (`server/tool`) for each tool. +pub fn allow_mcp(server: &McpServerConfig, tools: Option<&[&str]>) -> Vec { + mcp_policies(server.name(), Decision::Approve, tools, None) +} + +/// Creates DENY policies for an MCP server's tools. +pub fn deny_mcp(server: &McpServerConfig, tools: Option<&[&str]>) -> Vec { + mcp_policies(server.name(), Decision::Deny, tools, None) +} + +/// Creates `ASK_USER` policies for an MCP server's tools. +pub fn ask_user_mcp( + server: &McpServerConfig, + tools: Option<&[&str]>, + handler: impl Fn(&ToolCall) -> bool + Send + Sync + 'static + Clone, +) -> Vec { + mcp_policies( + server.name(), + Decision::AskUser, + tools, + Some(Arc::new(handler) as Arc bool + Send + Sync>), + ) +} + +/// Internal helper for generating MCP policies. +fn mcp_policies( + server_name: &str, + decision: Decision, + tools: Option<&[&str]>, + handler: Option bool + Send + Sync>>, +) -> Vec { + match tools { + None => { + // Server-wide wildcard + vec![Policy::new( + format!("{server_name}/*"), + decision, + None, + handler, + format!("{}_{}_all", decision_label(decision), server_name), + )] + } + Some(tools) => tools + .iter() + .map(|t| { + Policy::new( + format!("{server_name}/{t}"), + decision, + None, + handler.clone(), + format!("{}_{}_{t}", decision_label(decision), server_name), + ) + }) + .collect(), + } +} + +const fn decision_label(d: Decision) -> &'static str { + match d { + Decision::Approve => "allow", + Decision::Deny => "deny", + Decision::AskUser => "ask_user", + } +} + #[cfg(test)] mod tests { #![allow( @@ -447,7 +629,7 @@ mod tests { None, "oops".to_string(), ); - let res = enforce(vec![bad_policy]); + let res = enforce(vec![bad_policy], None); assert!(res.is_err()); let err_msg = res.err().unwrap().to_string(); assert!(err_msg.contains("oops")); @@ -456,7 +638,7 @@ mod tests { #[tokio::test] async fn test_specific_deny_overrides_wildcard_allow() { - let enforcer = enforce(vec![allow_all(), deny("dangerous_tool")]).unwrap(); + let enforcer = enforce(vec![allow_all(), deny("dangerous_tool")], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("dangerous_tool", json!({}))) .await @@ -466,7 +648,7 @@ mod tests { #[tokio::test] async fn test_specific_deny_overrides_specific_allow() { - let enforcer = enforce(vec![allow("run_command"), deny("run_command")]).unwrap(); + let enforcer = enforce(vec![allow("run_command"), deny("run_command")], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("run_command", json!({}))) .await @@ -476,7 +658,7 @@ mod tests { #[tokio::test] async fn test_specific_ask_overrides_wildcard_deny() { - let enforcer = enforce(vec![deny_all(), ask_user("run_command", |_| true)]).unwrap(); + let enforcer = enforce(vec![deny_all(), ask_user("run_command", |_| true)], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("run_command", json!({}))) .await @@ -486,7 +668,7 @@ mod tests { #[tokio::test] async fn test_specific_allow_overrides_wildcard_deny() { - let enforcer = enforce(vec![deny_all(), allow("read_file")]).unwrap(); + let enforcer = enforce(vec![deny_all(), allow("read_file")], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("read_file", json!({}))) @@ -503,7 +685,7 @@ mod tests { #[tokio::test] async fn test_wildcard_deny_blocks_unmatched_tools() { - let enforcer = enforce(vec![deny_all()]).unwrap(); + let enforcer = enforce(vec![deny_all()], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("anything", json!({}))) .await @@ -513,7 +695,7 @@ mod tests { #[tokio::test] async fn test_wildcard_ask_user() { - let enforcer = enforce(vec![ask_user("*", |_| false)]).unwrap(); + let enforcer = enforce(vec![ask_user("*", |_| false)], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("any_tool", json!({}))) .await @@ -523,7 +705,7 @@ mod tests { #[tokio::test] async fn test_wildcard_allow() { - let enforcer = enforce(vec![allow_all()]).unwrap(); + let enforcer = enforce(vec![allow_all()], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("any_tool", json!({}))) .await @@ -536,20 +718,23 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); - let enforcer = enforce(vec![ - deny("run_command") - .when(|_| { - CALL_COUNT.fetch_add(1, Ordering::SeqCst); - true - }) - .with_name("first"), - deny("run_command") - .when(|_| { - CALL_COUNT.fetch_add(1, Ordering::SeqCst); - true - }) - .with_name("second"), - ]) + let enforcer = enforce( + vec![ + deny("run_command") + .when(|_| { + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + true + }) + .with_name("first"), + deny("run_command") + .when(|_| { + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + true + }) + .with_name("second"), + ], + None, + ) .unwrap(); let res = enforcer @@ -565,16 +750,19 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); - let enforcer = enforce(vec![ - allow("read_file").when(|_| { - CALL_COUNT.fetch_add(1, Ordering::SeqCst); - true - }), - allow("read_file").when(|_| { - CALL_COUNT.fetch_add(1, Ordering::SeqCst); - true - }), - ]) + let enforcer = enforce( + vec![ + allow("read_file").when(|_| { + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + true + }), + allow("read_file").when(|_| { + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + true + }), + ], + None, + ) .unwrap(); let res = enforcer @@ -587,10 +775,13 @@ mod tests { #[tokio::test] async fn test_skips_non_matching_predicate() { - let enforcer = enforce(vec![ - deny("run_command").when(|_| false).with_name("skip-me"), - deny("run_command").when(|_| true).with_name("catch-me"), - ]) + let enforcer = enforce( + vec![ + deny("run_command").when(|_| false).with_name("skip-me"), + deny("run_command").when(|_| true).with_name("catch-me"), + ], + None, + ) .unwrap(); let res = enforcer @@ -603,13 +794,16 @@ mod tests { #[tokio::test] async fn test_predicate_exception_matches_fail_closed() { - let enforcer = enforce(vec![ - deny("run_command") - .when(|_| { - panic!("boom"); - }) - .with_name("broken"), - ]) + let enforcer = enforce( + vec![ + deny("run_command") + .when(|_| { + panic!("boom"); + }) + .with_name("broken"), + ], + None, + ) .unwrap(); let res = enforcer @@ -623,12 +817,15 @@ mod tests { #[tokio::test] async fn test_handler_exception_denies() { - let enforcer = enforce(vec![ - ask_user("run_command", |_| { - panic!("handler broke"); - }) - .with_name("broken-ask"), - ]) + let enforcer = enforce( + vec![ + ask_user("run_command", |_| { + panic!("handler broke"); + }) + .with_name("broken-ask"), + ], + None, + ) .unwrap(); let res = enforcer @@ -642,7 +839,7 @@ mod tests { #[tokio::test] async fn test_no_matching_policy_allows() { - let enforcer = enforce(vec![deny("other_tool")]).unwrap(); + let enforcer = enforce(vec![deny("other_tool")], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("unrelated_tool", json!({}))) .await @@ -652,7 +849,7 @@ mod tests { #[tokio::test] async fn test_empty_policies_allows_all() { - let enforcer = enforce(vec![]).unwrap(); + let enforcer = enforce(vec![], None).unwrap(); let res = enforcer .pre_tool_call(&make_tool_call("any_tool", json!({}))) .await @@ -663,7 +860,7 @@ mod tests { #[tokio::test] async fn test_workspace_only() { let policies = workspace_only(vec!["/allowed/workspace".to_string()]); - let enforcer = enforce(policies).unwrap(); + let enforcer = enforce(policies, None).unwrap(); let tc1 = make_tool_call( "VIEW_FILE", @@ -676,4 +873,112 @@ mod tests { let res2 = enforcer.pre_tool_call(&tc2).await.unwrap(); assert!(!res2.allow); } + + #[tokio::test] + async fn test_prefix_wildcard_matches_mcp_tool() { + let server = McpServerConfig::Stdio { + name: "math".to_string(), + command: "echo".to_string(), + args: vec![], + enabled_tools: None, + disabled_tools: None, + }; + let mut policies = deny_mcp(&server, None); // "math/*" deny + policies.push(allow_all()); + let enforcer = enforce(policies, Some(&[server])).unwrap(); + + // MCP tool "mcp_math_add" should be denied by "math/*" prefix + let res = enforcer + .pre_tool_call(&make_tool_call("mcp_math_add", json!({}))) + .await + .unwrap(); + assert!(!res.allow); + + // Non-MCP tool "read_file" should be allowed by wildcard + let res = enforcer + .pre_tool_call(&make_tool_call("read_file", json!({}))) + .await + .unwrap(); + assert!(res.allow); + } + + #[tokio::test] + async fn test_specific_allow_beats_prefix_deny() { + let server = McpServerConfig::Stdio { + name: "calc".to_string(), + command: "echo".to_string(), + args: vec![], + enabled_tools: None, + disabled_tools: None, + }; + let mut policies = deny_mcp(&server, None); // "calc/*" deny (level 3) + policies.extend(allow_mcp(&server, Some(&["add"]))); // "calc/add" allow (level 2) + let enforcer = enforce(policies, Some(&[server])).unwrap(); + + // "add" should be allowed (specific > prefix) + let res = enforcer + .pre_tool_call(&make_tool_call("mcp_calc_add", json!({}))) + .await + .unwrap(); + assert!(res.allow); + + // "subtract" should be denied (prefix deny applies) + let res = enforcer + .pre_tool_call(&make_tool_call("mcp_calc_subtract", json!({}))) + .await + .unwrap(); + assert!(!res.allow); + } + + #[tokio::test] + async fn test_enforce_fails_closed_on_missing_servers() { + let policy = Policy::new( + "myserver/tool".to_string(), + Decision::Deny, + None, + None, + "mcp_deny".to_string(), + ); + let result = enforce(vec![policy], None); + assert!(result.is_err()); + let msg = result.err().unwrap().to_string(); + assert!(msg.contains("MCP policies")); + assert!(msg.contains("mcp_servers")); + } + + #[tokio::test] + async fn test_longest_match_mcp_parsing() { + let s1 = McpServerConfig::Stdio { + name: "math".to_string(), + command: "echo".to_string(), + args: vec![], + enabled_tools: None, + disabled_tools: None, + }; + let s2 = McpServerConfig::Stdio { + name: "math_advanced".to_string(), + command: "echo".to_string(), + args: vec![], + enabled_tools: None, + disabled_tools: None, + }; + + let mut policies = deny_mcp(&s2, Some(&["calc"])); // "math_advanced/calc" deny + policies.push(allow_all()); + let enforcer = enforce(policies, Some(&[s1, s2])).unwrap(); + + // "mcp_math_advanced_calc" should be parsed as server="math_advanced", tool="calc" + let res = enforcer + .pre_tool_call(&make_tool_call("mcp_math_advanced_calc", json!({}))) + .await + .unwrap(); + assert!(!res.allow); + + // "mcp_math_add" should parse as server="math", tool="add" → allowed by wildcard + let res = enforcer + .pre_tool_call(&make_tool_call("mcp_math_add", json!({}))) + .await + .unwrap(); + assert!(res.allow); + } } From 050491268c2f4ae1a91f7d3aabaff93f8f133d6d Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 18:40:53 +0800 Subject: [PATCH 4/8] feat: wire ClientInfo handshake, Vertex AI, and TerminalError into connection strategies - Send ClientInfo (language='rust', version=crate version) in InputConfig during handshake - Pass use_vertex/project/location fields from GeminiConfig to proto InputConfig - Map proto state=5 to StepStatus::TerminalError in both local and wasm readers - Propagate TerminalError as AntigravityExecutionError to terminate agent execution - Add rustc_version() helper for client metadata reporting - Update start_harness.rs import for ProtoClientInfo --- src/bin/start_harness.rs | 1 + src/local.rs | 71 ++++++++++++++++++++++++++++++++-------- src/wasm.rs | 20 +++++++++-- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/bin/start_harness.rs b/src/bin/start_harness.rs index fb87d27..e10fe48 100644 --- a/src/bin/start_harness.rs +++ b/src/bin/start_harness.rs @@ -40,6 +40,7 @@ fn main() -> Result<(), Box> { storage_directory: Some("target/harness_store".to_string()), port: Some(8000), bind_address: Some("127.0.0.1".to_string()), + client_info: None, }; // Serialize InputConfig diff --git a/src/local.rs b/src/local.rs index 563588c..be59fc7 100644 --- a/src/local.rs +++ b/src/local.rs @@ -7,21 +7,21 @@ use crate::connection::Connection; use crate::hooks::HookRunner; use crate::proto::localharness::{ - FileEditToolConfig, FilesystemWorkspace, FindToolConfig, GeminiConfig as ProtoGeminiConfig, - GenerateImageToolConfig, GrepSearchToolConfig, HarnessConfig, HarnessSideTools, - InitializeConversationEvent, InputConfig, InputEvent, ListDirToolConfig, MultipleChoiceAnswer, - OutputConfig, OutputEvent, RunCommandToolConfig, SubagentsConfig, - SystemInstructions as ProtoSystemInstructions, Tool as ProtoTool, ToolConfirmation, - ToolResponse, UserQuestionAnswer, UserQuestionsConfig, UserQuestionsResponse, + ClientInfo as ProtoClientInfo, FileEditToolConfig, FilesystemWorkspace, FindToolConfig, + GeminiConfig as ProtoGeminiConfig, GenerateImageToolConfig, GrepSearchToolConfig, + HarnessConfig, HarnessSideTools, InitializeConversationEvent, InputConfig, InputEvent, + ListDirToolConfig, MultipleChoiceAnswer, OutputConfig, OutputEvent, RunCommandToolConfig, + SubagentsConfig, SystemInstructions as ProtoSystemInstructions, Tool as ProtoTool, + ToolConfirmation, ToolResponse, UserQuestionAnswer, UserQuestionsConfig, UserQuestionsResponse, ViewFileToolConfig, Workspace as ProtoWorkspace, WriteToFileToolConfig, appended_system_instructions::Section, custom_system_instructions::Part, user_questions_response::QuestionsResponse, workspace::WorkspaceType, }; use crate::tools::ToolRunner; use crate::types::{ - AskQuestionEntry, AskQuestionOption, BuiltinTools, CapabilitiesConfig, GeminiConfig, - QuestionHookResult, Step, StepSource, StepStatus, StepTarget, StepType, SystemInstructions, - ToolCall, ToolResult, UsageMetadata, + AntigravityExecutionError, AskQuestionEntry, AskQuestionOption, BuiltinTools, + CapabilitiesConfig, GeminiConfig, QuestionHookResult, Step, StepSource, StepStatus, StepTarget, + StepType, SystemInstructions, ToolCall, ToolResult, UsageMetadata, }; use anyhow::anyhow; @@ -381,6 +381,7 @@ impl LocalConnectionStrategy { /// or the WebSocket upgrade fails. #[allow(clippy::too_many_lines)] pub async fn connect(&self) -> Result { + let use_vertex = self.gemini_config.vertex; let api_key = self .gemini_config .models @@ -390,11 +391,24 @@ impl LocalConnectionStrategy { .or_else(|| self.gemini_config.api_key.clone()) .or_else(|| std::env::var("GEMINI_API_KEY").ok()); - let api_key = api_key.ok_or_else(|| { - anyhow!( + if !use_vertex && api_key.is_none() { + return Err(anyhow!( "A Gemini API key is required. Set it via GeminiConfig or GEMINI_API_KEY env var." - ) - })?; + )); + } + + if use_vertex { + let has_project = self.gemini_config.project.is_some(); + let has_location = self.gemini_config.location.is_some(); + if api_key.is_none() && !(has_project && has_location) { + return Err(anyhow!( + "For Vertex AI, either a GCP project and location, or an API key \ + (Express Mode) must be set." + )); + } + } + + let api_key = api_key.unwrap_or_default(); // 1. Spawning localharness subprocess // Explicitly forward SHELL and PATH so the harness can fork /bin/sh for @@ -426,10 +440,17 @@ impl LocalConnectionStrategy { .ok_or_else(|| anyhow!("Failed to open child stderr"))?; // 2. Perform Handshake via length-prefixed protocol buffer over stdin/stdout + let client_info = ProtoClientInfo { + language: Some("rust".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + language_version: Some(rustc_version()), + }; + let input_config = InputConfig { storage_directory: self.save_dir.clone(), port: None, bind_address: None, + client_info: Some(client_info), }; let mut input_buf = Vec::new(); @@ -555,6 +576,9 @@ impl LocalConnectionStrategy { }), enable_url_context: self.gemini_config.enable_url_context, enable_google_search: self.gemini_config.enable_google_search, + use_vertex: Some(self.gemini_config.vertex), + project: self.gemini_config.project.clone(), + location: self.gemini_config.location.clone(), }; let mut proto_workspaces = Vec::new(); @@ -793,6 +817,7 @@ impl LocalConnectionStrategy { Some(2) => StepStatus::Done, Some(3) => StepStatus::WaitingForUser, Some(4) => StepStatus::Error, + Some(5) => StepStatus::TerminalError, _ => StepStatus::Unknown, }; @@ -855,11 +880,21 @@ impl LocalConnectionStrategy { let err_str = step_update.error.as_ref().and_then(|e| e.error_message.clone()).unwrap_or_else(|| "System error occurred.".to_string()); let _ = step_tx.send(Err(anyhow!("System step error (HTTP {}): {}", http_code, err_str))); break; + } + + // Handle terminal errors — non-recoverable agent execution failure. + if status == StepStatus::TerminalError { + let err_msg = step_update.error_message.clone() + .unwrap_or_else(|| "Terminal error occurred during execution".to_string()); + let _ = step_tx.send(Err( + AntigravityExecutionError { message: err_msg }.into() + )); + break; } // Dispatch post-tool-call or on-tool-error hooks for built-in tools let state_val = step_update.state.unwrap_or(0); - if state_val == 2 || state_val == 4 { + if state_val == 2 || state_val == 4 || state_val == 5 { let mut pending = pending_builtin_tool_calls.lock().await; if let (Some(tc), Some(runner)) = (pending.remove(&key), hook_runner.as_ref()) { if state_val == 2 { @@ -1387,3 +1422,11 @@ fn extract_tool_result(step_update: &crate::proto::localharness::StepUpdate) -> error, }) } + +/// Returns the Rust compiler version used to build this crate. +fn rustc_version() -> String { + option_env!("RUSTC_VERSION") + .or(option_env!("CARGO_PKG_RUST_VERSION")) + .unwrap_or("unknown") + .to_string() +} diff --git a/src/wasm.rs b/src/wasm.rs index 384f5aa..552fb1f 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -35,9 +35,9 @@ use crate::proto::localharness::{ }; use crate::tools::ToolRunner; use crate::types::{ - AskQuestionEntry, AskQuestionOption, BuiltinTools, CapabilitiesConfig, GeminiConfig, - QuestionHookResult, Step, StepSource, StepStatus, StepTarget, StepType, SystemInstructions, - ToolCall, ToolResult, UsageMetadata, + AntigravityExecutionError, AskQuestionEntry, AskQuestionOption, BuiltinTools, + CapabilitiesConfig, GeminiConfig, QuestionHookResult, Step, StepSource, StepStatus, StepTarget, + StepType, SystemInstructions, ToolCall, ToolResult, UsageMetadata, }; /// Internal state tracker for matching `StepUpdate` payloads with active handshakes. @@ -216,6 +216,9 @@ impl WasmConnectionStrategy { }), enable_url_context: self.gemini_config.enable_url_context, enable_google_search: self.gemini_config.enable_google_search, + use_vertex: Some(self.gemini_config.vertex), + project: self.gemini_config.project.clone(), + location: self.gemini_config.location.clone(), }; let mut proto_workspaces = Vec::new(); @@ -457,6 +460,7 @@ impl WasmConnectionStrategy { Some(2) => StepStatus::Done, Some(3) => StepStatus::WaitingForUser, Some(4) => StepStatus::Error, + Some(5) => StepStatus::TerminalError, _ => StepStatus::Unknown, }; @@ -521,6 +525,16 @@ impl WasmConnectionStrategy { break; } + // Handle terminal errors — non-recoverable agent execution failure. + if status == StepStatus::TerminalError { + let err_msg = step_update.error_message.clone() + .unwrap_or_else(|| "Terminal error occurred during execution".to_string()); + let _ = step_tx.send(Err( + AntigravityExecutionError { message: err_msg }.into() + )); + break; + } + // Dispatch post-tool-call or on-tool-error hooks for built-in tools let state_val = step_update.state.unwrap_or(0); if state_val == 2 || state_val == 4 { From 65c897d020c001dfc2fcdc101430f56da4c1ea38 Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 18:40:59 +0800 Subject: [PATCH 5/8] feat: update agent PolicyEnforcer init and agent_server TerminalError handling - Update Agent::start() to pass empty server_names to PolicyEnforcer::new() - Handle StepStatus::TerminalError in agent_server SSE streaming - Map TerminalError to 'TERMINAL_ERROR' status string in SSE events --- examples/agent_server/src/main.rs | 2 ++ src/agent.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/agent_server/src/main.rs b/examples/agent_server/src/main.rs index 5844cfb..aea7ec5 100644 --- a/examples/agent_server/src/main.rs +++ b/examples/agent_server/src/main.rs @@ -569,6 +569,7 @@ async fn chat_stream_handler( } } else if step.status == StepStatus::Done || step.status == StepStatus::Error + || step.status == StepStatus::TerminalError { // If this tool was never seen as Active (e.g. auto-approved // non-WRITE_TOOLS like LIST_DIR that go straight from @@ -657,6 +658,7 @@ async fn chat_stream_handler( StepStatus::Error => Some("ERROR"), StepStatus::WaitingForUser => Some("WAITING_FOR_USER"), StepStatus::Canceled => Some("CANCELED"), + StepStatus::TerminalError => Some("TERMINAL_ERROR"), StepStatus::Unknown => None, }; if let Some(status) = status_str { diff --git a/src/agent.rs b/src/agent.rs index ca700e2..f75b4e5 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -286,7 +286,7 @@ impl Agent { } if !final_policies.is_empty() { - let enforcer = Arc::new(PolicyEnforcer::new(final_policies)); + let enforcer = Arc::new(PolicyEnforcer::new(final_policies, Vec::new())); self.hook_runner.register(enforcer).await; } From 8091b4e9301b1824167091ace0edb7b809821da0 Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 18:41:05 +0800 Subject: [PATCH 6/8] test: add integration tests for ClientInfo handshake and TerminalError propagation - Add test_agent_terminal_error_propagation to verify fatal error handling - Update test_agent_chat_integration to verify client metadata (language/version) - Refactor mock_localharness: extract handle_ws_connection() to satisfy clippy - Update mock harness to decode InputConfig and echo client info in responses - Fix model version: gemini-2.5-flash -> gemini-3.5-flash in all tests --- src/bin/mock_localharness.rs | 118 ++++++++++++++++++++++++----------- tests/integration_tests.rs | 57 +++++++++++++++-- 2 files changed, 135 insertions(+), 40 deletions(-) diff --git a/src/bin/mock_localharness.rs b/src/bin/mock_localharness.rs index 4112e49..c20ecec 100644 --- a/src/bin/mock_localharness.rs +++ b/src/bin/mock_localharness.rs @@ -1,5 +1,5 @@ #[cfg(not(target_arch = "wasm32"))] -use antigravity_sdk_rust::proto::localharness::OutputConfig; +use antigravity_sdk_rust::proto::localharness::{InputConfig, OutputConfig}; #[cfg(not(target_arch = "wasm32"))] use futures_util::{SinkExt, StreamExt}; #[cfg(not(target_arch = "wasm32"))] @@ -28,6 +28,15 @@ async fn main() -> Result<(), Box> { let mut input_buf = vec![0u8; length]; stdin.read_exact(&mut input_buf).await?; + let input_config = InputConfig::decode(&input_buf[..]).ok(); + let client_info = input_config.as_ref().and_then(|c| c.client_info.as_ref()); + let client_lang = client_info + .and_then(|ci| ci.language.as_deref()) + .unwrap_or("unknown"); + let client_ver = client_info + .and_then(|ci| ci.version.as_deref()) + .unwrap_or("unknown"); + // 2. Bind TCP listener to random port on localhost let listener = TcpListener::bind("127.0.0.1:0").await?; let port = listener.local_addr()?.port(); @@ -48,14 +57,33 @@ async fn main() -> Result<(), Box> { // 4. Accept a TCP connection and upgrade to WebSocket let (stream, _) = listener.accept().await?; - let mut ws_stream = accept_async(stream).await?; + let ws_stream = accept_async(stream).await?; + + // 5. Handle the WebSocket conversation + handle_ws_connection(ws_stream, client_lang, client_ver).await +} - // 5. Read client config message (InitializeConversationEvent / HarnessConfig) +#[cfg(not(target_arch = "wasm32"))] +async fn handle_ws_connection( + mut ws_stream: tokio_tungstenite::WebSocketStream, + client_lang: &str, + client_ver: &str, +) -> Result<(), Box> { + // Read client config message (InitializeConversationEvent / HarnessConfig) if let Some(msg_res) = ws_stream.next().await { let _ = msg_res?; } - // 6. Send trajectoryStateUpdate (RUNNING) to signal the turn is active + // Read client user prompt message + let mut prompt = String::new(); + if let Some(msg_res) = ws_stream.next().await { + let msg = msg_res?; + if let WsMessage::Text(text) = msg { + prompt = text; + } + } + + // Send trajectoryStateUpdate (RUNNING) to signal the turn is active let traj_running = serde_json::json!({ "trajectoryStateUpdate": { "trajectoryId": "test_traj", @@ -66,41 +94,59 @@ async fn main() -> Result<(), Box> { .send(WsMessage::Text(traj_running.to_string())) .await?; - // 7. Send the step updates - let step1 = serde_json::json!({ - "stepUpdate": { - "stepIndex": 1, - "cascadeId": "test_traj", - "trajectoryId": "test_traj", - "text": "Hello from mock harness!", - "textDelta": "Hello from mock harness!", - "state": "STATE_ACTIVE", - "source": "SOURCE_MODEL", - "target": "TARGET_USER" - } - }); - ws_stream.send(WsMessage::Text(step1.to_string())).await?; + if prompt.contains("trigger_terminal_error") { + let step_terminal = serde_json::json!({ + "stepUpdate": { + "stepIndex": 1, + "cascadeId": "test_traj", + "trajectoryId": "test_traj", + "text": "Terminal error triggered", + "state": "STATE_TERMINAL_ERROR", + "source": "SOURCE_MODEL", + "target": "TARGET_USER", + "errorMessage": "Terminal error triggered by prompt" + } + }); + ws_stream + .send(WsMessage::Text(step_terminal.to_string())) + .await?; + } else { + // Send the step updates including client language/version info in output + let step1 = serde_json::json!({ + "stepUpdate": { + "stepIndex": 1, + "cascadeId": "test_traj", + "trajectoryId": "test_traj", + "text": format!("Client info language: {}, version: {}", client_lang, client_ver), + "textDelta": format!("Client info language: {}, version: {}", client_lang, client_ver), + "state": "STATE_ACTIVE", + "source": "SOURCE_MODEL", + "target": "TARGET_USER" + } + }); + ws_stream.send(WsMessage::Text(step1.to_string())).await?; - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(100)).await; - let step2 = serde_json::json!({ - "stepUpdate": { - "stepIndex": 2, - "cascadeId": "test_traj", - "trajectoryId": "test_traj", - "text": "Hello from mock harness!How can I help you today?", - "textDelta": "How can I help you today?", - "state": "STATE_DONE", - "source": "SOURCE_MODEL", - "target": "TARGET_USER", - "finish": { - "outputString": "\"done\"" + let step2 = serde_json::json!({ + "stepUpdate": { + "stepIndex": 2, + "cascadeId": "test_traj", + "trajectoryId": "test_traj", + "text": "Hello from mock harness!How can I help you today?", + "textDelta": "How can I help you today?", + "state": "STATE_DONE", + "source": "SOURCE_MODEL", + "target": "TARGET_USER", + "finish": { + "outputString": "\"done\"" + } } - } - }); - ws_stream.send(WsMessage::Text(step2.to_string())).await?; + }); + ws_stream.send(WsMessage::Text(step2.to_string())).await?; + } - // 8. Send trajectoryStateUpdate (IDLE) to signal the turn is complete + // Send trajectoryStateUpdate (IDLE) to signal the turn is complete let traj_idle = serde_json::json!({ "trajectoryStateUpdate": { "trajectoryId": "test_traj", @@ -111,7 +157,7 @@ async fn main() -> Result<(), Box> { .send(WsMessage::Text(traj_idle.to_string())) .await?; - // 9. Keep reading until client disconnects or we get terminated + // Keep reading until client disconnects or we get terminated while let Some(msg_res) = ws_stream.next().await { if msg_res.is_err() { break; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index e558ccb..32ce387 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -26,7 +26,7 @@ async fn test_agent_chat_integration() { api_key: Some("test_api_key".to_string()), models: ModelConfig { default: ModelEntry { - name: "gemini-2.5-flash".to_string(), + name: "gemini-3.5-flash".to_string(), api_key: None, generation: GenerationConfig { thinking_level: None, @@ -67,10 +67,12 @@ async fn test_agent_chat_integration() { .expect("Failed to chat with agent"); // 3. Verify response - assert_eq!( - response.text, - "Hello from mock harness!How can I help you today?" + assert!( + response + .text + .contains("Client info language: rust, version:") ); + assert!(response.text.contains("How can I help you today?")); assert_eq!(response.steps.len(), 2); // 4. Verify conversation metadata @@ -182,3 +184,50 @@ async fn test_agent_real_chat_integration() { // 5. Stop agent agent.stop().await.expect("Failed to stop agent"); } + +#[tokio::test] +async fn test_agent_terminal_error_propagation() { + let mut config = AgentConfig::default(); + + // Set up mock harness path + let harness_path = std::env::var("CARGO_BIN_EXE_mock_localharness") + .expect("CARGO_BIN_EXE_mock_localharness not set — run via `cargo test`"); + + config.binary_path = Some(harness_path); + config.gemini_config = GeminiConfig { + api_key: Some("test_api_key".to_string()), + models: ModelConfig { + default: ModelEntry { + name: "gemini-3.5-flash".to_string(), + api_key: None, + generation: GenerationConfig { + thinking_level: None, + }, + }, + image_generation: ModelEntry::default(), + }, + ..Default::default() + }; + + config.capabilities = CapabilitiesConfig { + enabled_tools: Some(vec![BuiltinTools::ViewFile]), + disabled_tools: None, + compaction_threshold: None, + image_model: None, + finish_tool_schema_json: None, + }; + + config.policies = Some(vec![policy::allow_all()]); + config.conversation_id = Some("test_conv_err".to_string()); + + let agent = Agent::new(config); + let agent = agent.start().await.expect("Failed to start agent"); + + // Chat with agent triggering terminal error + let res = agent.chat("trigger_terminal_error").await; + assert!(res.is_err()); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("Terminal execution error: Terminal error triggered by prompt")); + + agent.stop().await.expect("Failed to stop agent"); +} From afaff7c9a718ce45ab30da19a7f2f9861a4bead9 Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 18:41:10 +0800 Subject: [PATCH 7/8] chore: bump harness version to 0.1.1 --- scripts/install_harness.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install_harness.sh b/scripts/install_harness.sh index 7fa6e80..b417ba0 100755 --- a/scripts/install_harness.sh +++ b/scripts/install_harness.sh @@ -4,7 +4,7 @@ set -euo pipefail -VERSION="0.1.0" +VERSION="0.1.1" PLATFORM="" case "$(uname -s)" in Darwin) From 715c689e2a8c3f7e7ff8af1d70d0dace9ced932c Mon Sep 17 00:00:00 2001 From: codeitlikemiley Date: Fri, 29 May 2026 18:44:44 +0800 Subject: [PATCH 8/8] fix: add ./bin/localharness as primary fallback binary path The install script (just install / scripts/install_harness.sh) places the binary at ./bin/localharness. Prioritize this location before system PATH since it's the standard install method for Rust SDK users. Lookup order is now: 1. ANTIGRAVITY_HARNESS_PATH env var (explicit override) 2. ./bin/localharness (local install via just install) 3. System PATH (pip install google-antigravity) 4. Python site-packages --- src/agent.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/agent.rs b/src/agent.rs index f75b4e5..4148246 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -603,7 +603,15 @@ fn get_default_binary_path() -> Option { if let Ok(path) = std::env::var("ANTIGRAVITY_HARNESS_PATH") { return Some(path); } - // Check if it is in standard PATH + // Check ./bin/localharness relative to the current working directory + // (this is where `just install` / `scripts/install_harness.sh` places the binary) + if let Ok(cwd) = std::env::current_dir() { + let local_bin = cwd.join("bin").join("localharness"); + if local_bin.exists() { + return Some(local_bin.to_string_lossy().into_owned()); + } + } + // Check if it is in standard PATH (e.g. via `pip install google-antigravity`) if let Ok(paths) = std::env::var("PATH") { for path in std::env::split_paths(&paths) { let p = path.join("localharness");