mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
feat(web): show error details for failed tool calls (#490)
* feat(web): show error details and input params for failed tool calls Failed tool calls in the gateway UI previously showed only a red X icon with an empty expandable body. This change: - Adds optional `error` and `parameters` fields to `ToolCompleted` SSE events so the browser receives failure details in real-time - Auto-expands failed tool cards to make errors immediately visible - Adds `StatusUpdate::tool_completed()` constructor that centralizes the 5 duplicated construction sites and applies `redact_params()` to prevent sensitive values (e.g. secret_save's "value" param) from leaking through SSE broadcasts - Adds `sensitive_params()` trait method to `Tool` for declaring which parameters must be redacted before logging, hooks, and UI display - Adds `redact_params()` utility and wires it through hooks, approvals, ActionRecord storage, and debug logs in dispatcher/worker - Adds `SecretListTool` and `SecretDeleteTool` for LLM-driven secret management (values never returned, only names/metadata) - Fixes auth flow: setup-only extensions show configure modal instead of OAuth card; auth_completed SSE dismisses both UI paths - CI: release workflow creates PR instead of pushing directly to main - Registry: MissingChecksum error enables source fallback for bootstrapping when checksums haven't been populated yet Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: apply cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: keep original params in PendingApproval for execution, redact only for display Address two PR review comments: 1. execute_chat_tool_standalone now redacts sensitive params before logging, matching the pattern already used in worker.rs. 2. PendingApproval previously stored redacted parameters, which meant approved tool calls received "[REDACTED]" instead of the actual values. Add a display_parameters field for UI/logs and keep parameters as the original values used for execution. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review comments - worker.rs: redact sensitive params before BeforeToolCall hook, matching dispatcher.rs — hooks in the autonomous job path now receive redacted params instead of raw values - registry.rs: fix docstring for register_secrets_tools (list, delete, not save/list/delete — no SecretSaveTool is registered) - app.js: fix double toast/loadExtensions in submitConfigureModal — for non-OAuth success the auth_completed SSE already handles both, so skip them in the HTTP response handler to avoid duplicates [skip-regression-check] Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
13697976db
commit
902492bcdb
@@ -10,6 +10,7 @@ mod memory;
|
||||
mod message;
|
||||
pub mod path_utils;
|
||||
pub mod routine;
|
||||
pub mod secrets_tools;
|
||||
pub(crate) mod shell;
|
||||
pub mod skill_tools;
|
||||
mod time;
|
||||
@@ -31,6 +32,7 @@ pub use message::MessageTool;
|
||||
pub use routine::{
|
||||
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
||||
};
|
||||
pub use secrets_tools::{SecretDeleteTool, SecretListTool};
|
||||
pub use shell::ShellTool;
|
||||
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
|
||||
pub use time::TimeTool;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Agent-callable tools for inspecting user secrets.
|
||||
//!
|
||||
//! These tools allow the LLM to query and manage secrets on behalf of the
|
||||
//! user. The zero-exposure model is preserved throughout:
|
||||
//!
|
||||
//! - `secret_list` returns only names and metadata (no values).
|
||||
//! - `secret_delete` removes a secret by name.
|
||||
//!
|
||||
//! Storing secrets is handled via the extensions setup flow — the user types
|
||||
//! values directly into the secure UI, which submits them to
|
||||
//! `/api/extensions/{name}/setup`. Values never appear in the LLM conversation,
|
||||
//! logs, or ActionRecords.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
// ── secret_list ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SecretListTool {
|
||||
store: Arc<dyn SecretsStore + Send + Sync>,
|
||||
}
|
||||
|
||||
impl SecretListTool {
|
||||
pub fn new(store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SecretListTool {
|
||||
fn name(&self) -> &str {
|
||||
"secret_list"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List all stored secrets by name. Never returns values — only names and \
|
||||
optional provider metadata. Use this to check what credentials are available \
|
||||
before attempting a task that requires them."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let refs = self
|
||||
.store
|
||||
.list(&ctx.user_id)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
let secrets: Vec<serde_json::Value> = refs
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"name": r.name,
|
||||
"provider": r.provider,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let count = secrets.len();
|
||||
let output = serde_json::json!({
|
||||
"secrets": secrets,
|
||||
"count": count,
|
||||
});
|
||||
|
||||
Ok(ToolOutput::success(output, start.elapsed()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── secret_delete ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SecretDeleteTool {
|
||||
store: Arc<dyn SecretsStore + Send + Sync>,
|
||||
}
|
||||
|
||||
impl SecretDeleteTool {
|
||||
pub fn new(store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SecretDeleteTool {
|
||||
fn name(&self) -> &str {
|
||||
"secret_delete"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Permanently delete a stored secret by name. This cannot be undone."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the secret to delete."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let deleted = self
|
||||
.store
|
||||
.delete(&ctx.user_id, name)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
let output = if deleted {
|
||||
serde_json::json!({
|
||||
"status": "deleted",
|
||||
"name": name,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"status": "not_found",
|
||||
"name": name,
|
||||
"message": format!("No secret named '{}' found.", name),
|
||||
})
|
||||
};
|
||||
|
||||
Ok(ToolOutput::success(output, start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use super::*;
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
||||
|
||||
fn test_store() -> Arc<InMemorySecretsStore> {
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
Arc::new(InMemorySecretsStore::new(crypto))
|
||||
}
|
||||
|
||||
fn test_ctx() -> JobContext {
|
||||
JobContext::new("test", "test job")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_secret_list() {
|
||||
let store = test_store();
|
||||
let list = SecretListTool::new(Arc::clone(&store) as Arc<dyn SecretsStore + Send + Sync>);
|
||||
let ctx = test_ctx();
|
||||
|
||||
store
|
||||
.create(
|
||||
&ctx.user_id,
|
||||
CreateSecretParams::new("openai_key", "sk-test"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list_result = list.execute(serde_json::json!({}), &ctx).await.unwrap();
|
||||
assert_eq!(list_result.result["count"], 1);
|
||||
assert_eq!(list_result.result["secrets"][0]["name"], "openai_key");
|
||||
assert!(list_result.result["secrets"][0].get("value").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_secret_delete() {
|
||||
let store = test_store();
|
||||
let delete =
|
||||
SecretDeleteTool::new(Arc::clone(&store) as Arc<dyn SecretsStore + Send + Sync>);
|
||||
let ctx = test_ctx();
|
||||
|
||||
store
|
||||
.create(&ctx.user_id, CreateSecretParams::new("to_delete", "secret"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = delete
|
||||
.execute(serde_json::json!({"name": "to_delete"}), &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.result["status"], "deleted");
|
||||
|
||||
// Deleting again returns not_found
|
||||
let result2 = delete
|
||||
.execute(serde_json::json!({"name": "to_delete"}), &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result2.result["status"], "not_found");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -26,5 +26,5 @@ pub use rate_limiter::RateLimiter;
|
||||
pub use registry::ToolRegistry;
|
||||
pub use tool::{
|
||||
ApprovalRequirement, Tool, ToolDomain, ToolError, ToolOutput, ToolRateLimitConfig,
|
||||
validate_tool_schema,
|
||||
redact_params, validate_tool_schema,
|
||||
};
|
||||
|
||||
@@ -346,6 +346,20 @@ impl ToolRegistry {
|
||||
tracing::info!("Registered {} job management tools", job_tool_count);
|
||||
}
|
||||
|
||||
/// Register secret management tools (list, delete).
|
||||
///
|
||||
/// These allow the LLM to persist API keys and tokens encrypted in the database.
|
||||
/// Values are never returned to the LLM; only names and metadata are exposed.
|
||||
pub fn register_secrets_tools(
|
||||
&self,
|
||||
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||
) {
|
||||
use crate::tools::builtin::{SecretDeleteTool, SecretListTool};
|
||||
self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store))));
|
||||
self.register_sync(Arc::new(SecretDeleteTool::new(store)));
|
||||
tracing::info!("Registered 2 secret management tools (list, delete)");
|
||||
}
|
||||
|
||||
/// Register extension management tools (search, install, auth, activate, list, remove).
|
||||
///
|
||||
/// These allow the LLM to manage MCP servers and WASM tools through conversation.
|
||||
|
||||
@@ -239,6 +239,23 @@ pub trait Tool: Send + Sync {
|
||||
ToolDomain::Orchestrator
|
||||
}
|
||||
|
||||
/// Parameter names whose values must be redacted before logging, hooks, and approvals.
|
||||
///
|
||||
/// The agent framework replaces these parameter values with `"[REDACTED]"` before:
|
||||
/// - Writing to debug logs
|
||||
/// - Storing in `ActionRecord` (in-memory job history)
|
||||
/// - Recording in `TurnToolCall` (session state)
|
||||
/// - Sending to `BeforeToolCall` hooks
|
||||
/// - Displaying in the approval UI
|
||||
///
|
||||
/// **The `execute()` method still receives the original, unredacted parameters.**
|
||||
/// Redaction only applies to the observability and audit paths, not execution.
|
||||
///
|
||||
/// Use this for tools that accept plaintext secrets as parameters (e.g. `secret_save`).
|
||||
fn sensitive_params(&self) -> &[&str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Per-invocation rate limit for this tool.
|
||||
///
|
||||
/// Return `Some(config)` to throttle how often this tool can be called per user.
|
||||
@@ -287,6 +304,33 @@ pub fn require_param<'a>(
|
||||
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
||||
}
|
||||
|
||||
/// Replace sensitive parameter values with `"[REDACTED]"`.
|
||||
///
|
||||
/// Returns a new JSON value with the specified keys replaced. Non-object params
|
||||
/// and unknown keys are passed through unchanged. The original value is cloned
|
||||
/// only if there are sensitive params to redact; otherwise it is cloned once
|
||||
/// (cheap — callers own the result).
|
||||
///
|
||||
/// Used by the agent framework before logging, hook dispatch, approval display,
|
||||
/// and `ActionRecord` storage so plaintext secrets never reach those paths.
|
||||
pub fn redact_params(params: &serde_json::Value, sensitive: &[&str]) -> serde_json::Value {
|
||||
if sensitive.is_empty() {
|
||||
return params.clone();
|
||||
}
|
||||
let mut redacted = params.clone();
|
||||
if let Some(obj) = redacted.as_object_mut() {
|
||||
for key in sensitive {
|
||||
if obj.contains_key(*key) {
|
||||
obj.insert(
|
||||
(*key).to_string(),
|
||||
serde_json::Value::String("[REDACTED]".into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
redacted
|
||||
}
|
||||
|
||||
/// Lenient runtime validation of a tool's `parameters_schema()`.
|
||||
///
|
||||
/// Use this function at tool-registration time to catch structural mistakes
|
||||
@@ -500,6 +544,37 @@ mod tests {
|
||||
assert!(ApprovalRequirement::Always.is_required());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_params_replaces_sensitive_key() {
|
||||
let params = serde_json::json!({"name": "openai_key", "value": "sk-secret"});
|
||||
let redacted = redact_params(¶ms, &["value"]);
|
||||
assert_eq!(redacted["name"], "openai_key");
|
||||
assert_eq!(redacted["value"], "[REDACTED]");
|
||||
// Original unchanged
|
||||
assert_eq!(params["value"], "sk-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_params_empty_sensitive_is_noop() {
|
||||
let params = serde_json::json!({"name": "key", "value": "secret"});
|
||||
let redacted = redact_params(¶ms, &[]);
|
||||
assert_eq!(redacted, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_params_missing_key_is_noop() {
|
||||
let params = serde_json::json!({"name": "key"});
|
||||
let redacted = redact_params(¶ms, &["value"]);
|
||||
assert_eq!(redacted, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_params_non_object_is_passthrough() {
|
||||
let params = serde_json::json!("just a string");
|
||||
let redacted = redact_params(¶ms, &["value"]);
|
||||
assert_eq!(redacted, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_schema_valid() {
|
||||
let schema = serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user