Compare commits

...
Author SHA1 Message Date
Zaki 18b22a03b0 ci: retrigger regression check with skip label 2026-03-12 15:29:53 -07:00
ZakiandClaude Opus 4.6 779dd70a0d fix: remove duplicate path_routing_tests module in memory.rs
After rebase, the path_routing_tests test module appeared twice in
memory.rs -- once before MemoryTreeTool and once at the end. Remove
the first occurrence to fix the compilation error.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-12 15:21:35 -07:00
6f00490900 fix: relax approval requirements for low-risk tools (#922)
* fix: relax approval requirements for low-risk tools

Remove unnecessary UnlessAutoApproved friction from list_dir, image_gen,
image_analyze, image_edit, tool_install, tool_auth, tool_upgrade, and
build_tool — these operate on trusted inputs or are low-risk operations
so they now use the trait default (Never).

For the http tool, GET requests without credentials now return Never
instead of UnlessAutoApproved, while credential-bearing requests and
non-GET methods retain their existing approval levels.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: apply cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review feedback on approval changes

Rename test_requires_approval_returns_unless_auto_approved to
test_requires_approval_returns_never to match the asserted behavior.

In http requires_approval(), treat missing method as unknown (falls
through to UnlessAutoApproved) instead of defaulting to GET, since
the schema requires method. Updated comment to reflect this.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: make http method optional, default to GET

Make method optional in schema (only url is required) and default to
GET in both execute() and requires_approval(). This aligns approval
logic with execution and reduces friction for simple GET requests.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: restore UnlessAutoApproved for build_tool, tool_install, tool_upgrade

Address review feedback: these tools modify the system's trust boundary
(shell execution, WASM installation, version mutation) and should retain
approval gating. tool_auth kept as Never per owner decision.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-12 22:10:56 +00:00
NigeGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
e522d33a53 fix(web): make approval requests appear without page reload (#996) (#1073)
* fix(web): show approval requests in realtime without reload

* Update src/channels/web/static/app.js

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-12 15:04:25 -07:00
NigeandGitHub 7a9cbb3b50 fix(routines): run cron checks immediately on ticker startup (#1066)
* fix(routines): run cron check immediately at ticker startup

* test/ci: add routine_engine test and fix style lint drift
2026-03-12 15:04:21 -07:00
NigeandGitHub 442a42d996 fix(web): recompute cron next_fire_at when re-enabling routines (#1080) 2026-03-12 15:03:38 -07:00
11 changed files with 224 additions and 79 deletions
+11 -2
View File
@@ -1150,9 +1150,11 @@ pub fn spawn_cron_ticker(
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Run one check immediately so routines due at startup don't wait
// an extra full polling interval.
engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval);
// Skip immediate first tick
ticker.tick().await;
loop {
ticker.tick().await;
@@ -1358,4 +1360,11 @@ mod tests {
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
}
#[test]
fn test_truncate_adds_ellipsis_when_over_limit() {
let input = "abcdefghijk";
let out = super::truncate(input, 5);
assert_eq!(out, "abcde...");
}
}
+10
View File
@@ -10,6 +10,7 @@ use axum::{
use serde::Deserialize;
use uuid::Uuid;
use crate::agent::routine::{Trigger, next_cron_fire};
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
use crate::error::RoutineError;
@@ -182,12 +183,21 @@ pub async fn routines_toggle_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
let was_enabled = routine.enabled;
// If a specific value was provided, use it; otherwise toggle.
routine.enabled = match body {
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
None => !routine.enabled,
};
if routine.enabled
&& !was_enabled
&& let Trigger::Cron { schedule, timezone } = &routine.trigger
{
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
store
.update_routine(&routine)
.await
+10
View File
@@ -26,6 +26,7 @@ use tower_http::set_header::SetResponseHeaderLayer;
use uuid::Uuid;
use crate::agent::SessionManager;
use crate::agent::routine::{Trigger, next_cron_fire};
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
@@ -2416,12 +2417,21 @@ async fn routines_toggle_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
let was_enabled = routine.enabled;
// If a specific value was provided, use it; otherwise toggle.
routine.enabled = match body {
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
None => !routine.enabled,
};
if routine.enabled
&& !was_enabled
&& let Trigger::Cron { schedule, timezone } = &routine.trigger
{
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
store
.update_routine(&routine)
.await
+17 -2
View File
@@ -342,8 +342,19 @@ function connectSSE() {
eventSource.addEventListener('approval_needed', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
showApproval(data);
const hasThread = !!data.thread_id;
const forCurrentThread = !hasThread || isCurrentThread(data.thread_id);
if (forCurrentThread) {
showApproval(data);
} else {
// Keep thread list fresh when approval is requested in a background thread.
unreadThreads.set(data.thread_id, (unreadThreads.get(data.thread_id) || 0) + 1);
debouncedLoadThreads();
}
// Extension setup flows can surface approvals while user is on Extensions tab.
if (currentTab === 'extensions') loadExtensions();
});
eventSource.addEventListener('auth_required', (e) => {
@@ -991,6 +1002,10 @@ function finalizeActivityGroup() {
}
function showApproval(data) {
// Avoid duplicate cards on reconnect/history refresh.
const existing = document.querySelector('.approval-card[data-request-id="' + CSS.escape(data.request_id) + '"]');
if (existing) return;
const container = document.getElementById('chat-messages');
const card = document.createElement('div');
card.className = 'approval-card';
-4
View File
@@ -397,10 +397,6 @@ impl Tool for ListDirTool {
false // Directory listings are safe
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn domain(&self) -> ToolDomain {
ToolDomain::Container
}
+34 -32
View File
@@ -398,7 +398,7 @@ impl Tool for HttpTool {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"],
"description": "HTTP method"
"description": "HTTP method (default: GET)"
},
"url": {
"type": "string",
@@ -429,7 +429,7 @@ impl Tool for HttpTool {
"description": "Save response body as raw bytes to this file path instead of returning it. Use for binary downloads (images, PDFs, etc.). The path must be under /tmp/."
}
},
"required": ["method", "url"]
"required": ["url"]
})
}
@@ -440,7 +440,7 @@ impl Tool for HttpTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let method = require_str(&params, "method")?;
let method = params["method"].as_str().unwrap_or("GET");
let method_upper = method.to_uppercase();
let url = require_str(&params, "url")?;
@@ -829,18 +829,22 @@ impl Tool for HttpTool {
}
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
// 1. Manual auth headers/query params in LLM params
if crate::safety::params_contain_manual_credentials(params) {
let has_credentials = crate::safety::params_contain_manual_credentials(params)
|| (self.credential_registry.as_ref().is_some_and(|registry| {
extract_host_from_params(params)
.is_some_and(|host| registry.has_credentials_for_host(&host))
}));
if has_credentials {
return ApprovalRequirement::Always;
}
// 2. Target host has credential mappings (will be auto-injected)
if let Some(ref registry) = self.credential_registry
&& let Some(host) = extract_host_from_params(params)
&& registry.has_credentials_for_host(&host)
{
return ApprovalRequirement::Always;
// GET requests (or missing method, since GET is the default) are low-risk
let method = params["method"].as_str().unwrap_or("GET");
if method.eq_ignore_ascii_case("GET") {
return ApprovalRequirement::Never;
}
// Default: outbound HTTP still needs approval unless auto-approved
ApprovalRequirement::UnlessAutoApproved
}
@@ -1063,12 +1067,22 @@ mod tests {
// ── Approval requirement tests ──────────────────────────────────────
#[test]
fn test_no_auth_headers_returns_unless_auto_approved() {
fn test_get_no_auth_headers_returns_never() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data"
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
}
#[test]
fn test_post_no_auth_headers_returns_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "POST",
"url": "https://api.example.com/data"
});
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
@@ -1152,21 +1166,18 @@ mod tests {
}
#[test]
fn test_non_auth_headers_return_unless_auto_approved() {
fn test_get_non_auth_headers_return_never() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"Content-Type": "application/json", "Accept": "text/html"}
});
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
}
#[test]
fn test_empty_headers_return_unless_auto_approved() {
fn test_get_empty_headers_return_never() {
let tool = HttpTool::new();
// Empty object
@@ -1175,10 +1186,7 @@ mod tests {
"url": "https://example.com",
"headers": {}
});
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
// Empty array
let params = serde_json::json!({
@@ -1186,10 +1194,7 @@ mod tests {
"url": "https://example.com",
"headers": []
});
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
}
// ── Credential registry approval tests ─────────────────────────────
@@ -1219,7 +1224,7 @@ mod tests {
}
#[test]
fn test_host_without_credential_mapping_returns_unless_auto_approved() {
fn test_get_host_without_credential_mapping_returns_never() {
use crate::tools::wasm::SharedCredentialRegistry;
let registry = Arc::new(SharedCredentialRegistry::new());
@@ -1231,10 +1236,7 @@ mod tests {
"method": "GET",
"url": "https://api.example.com/data"
});
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
}
#[test]
+4 -7
View File
@@ -8,7 +8,7 @@ use secrecy::{ExposeSecret, SecretString};
use crate::context::JobContext;
use crate::tools::builtin::path_utils::validate_path;
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for analyzing images using a vision-capable model.
pub struct ImageAnalyzeTool {
@@ -86,10 +86,6 @@ impl Tool for ImageAnalyzeTool {
})
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn requires_sanitization(&self) -> bool {
true
}
@@ -185,6 +181,7 @@ impl Tool for ImageAnalyzeTool {
mod tests {
use super::super::media_type_from_path;
use super::*;
use crate::tools::tool::ApprovalRequirement;
use tempfile::TempDir;
#[test]
@@ -199,7 +196,7 @@ mod tests {
}
#[test]
fn test_requires_approval_returns_unless_auto_approved() {
fn test_requires_approval_returns_never() {
let tool = ImageAnalyzeTool::new(
"https://api.example.com".to_string(),
"test-key".to_string(),
@@ -208,7 +205,7 @@ mod tests {
);
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved
ApprovalRequirement::Never
);
}
+3 -6
View File
@@ -7,7 +7,7 @@ use secrecy::{ExposeSecret, SecretString};
use crate::context::JobContext;
use crate::tools::builtin::path_utils::validate_path;
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for editing images using an AI image editing API.
pub struct ImageEditTool {
@@ -85,10 +85,6 @@ impl Tool for ImageEditTool {
})
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn requires_sanitization(&self) -> bool {
false
}
@@ -266,6 +262,7 @@ impl ImageEditTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::tool::ApprovalRequirement;
use tempfile::TempDir;
#[test]
@@ -280,7 +277,7 @@ mod tests {
assert!(!tool.requires_sanitization());
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved
ApprovalRequirement::Never
);
}
+2 -6
View File
@@ -5,7 +5,6 @@ use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use crate::context::JobContext;
use crate::tools::tool::ApprovalRequirement;
use crate::tools::{Tool, ToolError, ToolOutput};
/// Tool for generating images using FLUX or compatible image generation APIs.
@@ -87,10 +86,6 @@ impl Tool for ImageGenerateTool {
})
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn requires_sanitization(&self) -> bool {
false
}
@@ -186,6 +181,7 @@ impl Tool for ImageGenerateTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::tool::ApprovalRequirement;
#[test]
fn test_tool_metadata() {
@@ -197,7 +193,7 @@ mod tests {
assert_eq!(tool.name(), "image_generate");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved
ApprovalRequirement::Never
);
let schema = tool.parameters_schema();
+20 -20
View File
@@ -539,26 +539,6 @@ impl Tool for MemoryTreeTool {
}
}
#[cfg(test)]
mod path_routing_tests {
use super::looks_like_filesystem_path;
#[test]
fn detects_filesystem_paths() {
assert!(looks_like_filesystem_path("/Users/nige/file.md"));
assert!(looks_like_filesystem_path("C:\\Users\\nige\\file.md"));
assert!(looks_like_filesystem_path("D:/work/file.md"));
assert!(looks_like_filesystem_path("~/notes.md"));
}
#[test]
fn allows_workspace_memory_paths() {
assert!(!looks_like_filesystem_path("MEMORY.md"));
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
}
}
#[cfg(all(test, feature = "postgres"))]
mod tests {
use super::*;
@@ -636,3 +616,23 @@ mod tests {
assert_eq!(schema["properties"]["depth"]["default"], 1);
}
}
#[cfg(test)]
mod path_routing_tests {
use super::looks_like_filesystem_path;
#[test]
fn detects_filesystem_paths() {
assert!(looks_like_filesystem_path("/Users/nige/file.md"));
assert!(looks_like_filesystem_path("C:\\Users\\nige\\file.md"));
assert!(looks_like_filesystem_path("D:/work/file.md"));
assert!(looks_like_filesystem_path("~/notes.md"));
}
#[test]
fn allows_workspace_memory_paths() {
assert!(!looks_like_filesystem_path("MEMORY.md"));
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
}
}
+113
View File
@@ -13,6 +13,8 @@ mod support;
mod tests {
use std::time::Duration;
use uuid::Uuid;
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
use crate::support::mock_openai_server::{
MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall,
@@ -147,4 +149,115 @@ mod tests {
harness.shutdown().await;
mock.shutdown().await;
}
#[tokio::test]
async fn routines_toggle_reenable_cron_recomputes_next_fire_at() {
let mock = MockOpenAiServerBuilder::new()
.with_rule(MockOpenAiRule::on_user_contains(
"create cron routine",
MockOpenAiResponse::ToolCalls(vec![MockToolCall::new(
"call_create_cron_1",
"routine_create",
serde_json::json!({
"name": "wf-cron-toggle-reenable",
"description": "Cron toggle regression test",
"trigger_type": "cron",
"schedule": "0 */5 * * * *",
"timezone": "UTC",
"action_type": "lightweight",
"prompt": "noop"
}),
)]),
))
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
.await;
let thread_id = harness.create_thread().await;
harness.send_chat(&thread_id, "create cron routine").await;
harness
.wait_for_turns(&thread_id, 1, Duration::from_secs(10))
.await;
let routine = harness
.routine_by_name("wf-cron-toggle-reenable")
.await
.expect("routine should exist");
let routine_id = routine
.get("id")
.and_then(|v| v.as_str())
.expect("routine id missing");
let routine_uuid = Uuid::parse_str(routine_id).expect("valid routine uuid");
// Disable through the web toggle endpoint.
harness
.client
.post(format!(
"{}/api/routines/{routine_id}/toggle",
harness.base_url()
))
.bearer_auth(&harness.auth_token)
.json(&serde_json::json!({ "enabled": false }))
.send()
.await
.expect("disable toggle request failed")
.error_for_status()
.expect("disable toggle non-2xx");
// Simulate an unscheduled disabled cron routine (next_fire_at missing).
let mut stored = harness
.db
.get_routine(routine_uuid)
.await
.expect("db get_routine")
.expect("routine should still exist");
stored.next_fire_at = None;
harness
.db
.update_routine(&stored)
.await
.expect("db update_routine");
// Re-enable through the web toggle endpoint.
harness
.client
.post(format!(
"{}/api/routines/{routine_id}/toggle",
harness.base_url()
))
.bearer_auth(&harness.auth_token)
.json(&serde_json::json!({ "enabled": true }))
.send()
.await
.expect("enable toggle request failed")
.error_for_status()
.expect("enable toggle non-2xx");
let detail = harness
.client
.get(format!("{}/api/routines/{routine_id}", harness.base_url()))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("detail request failed")
.error_for_status()
.expect("detail non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid detail response");
assert_eq!(detail["enabled"].as_bool(), Some(true));
assert!(
detail["next_fire_at"].as_str().is_some(),
"expected next_fire_at to be recomputed when re-enabling cron routine, got {detail}"
);
harness.shutdown().await;
mock.shutdown().await;
}
}