mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
fix(approval): make "always" auto-approve work for credentialed HTTP requests (#1257)
The HTTP tool returned `ApprovalRequirement::Always` for requests with credentials, but `Always` is hardcoded to ignore the session auto-approve set. This meant users who clicked "always" were re-prompted on every subsequent HTTP call — the UI offered "always" but the backend ignored it. Two fixes: 1. HTTP credentialed requests now return `UnlessAutoApproved` instead of `Always`, so the session auto-approve set is respected. 2. `StatusUpdate::ApprovalNeeded` now carries `allow_always: bool`. All channel UIs (Telegram, Slack, Signal, REPL, Web) conditionally hide the "always" option when a tool truly requires per-invocation approval (`ApprovalRequirement::Always`, e.g. destructive shell commands). Also boxes `PendingApproval` in `AgenticLoopResult::NeedApproval` to fix a pre-existing clippy `large_enum_variant` warning. Regression tests included (test_credentialed_requests_respect_auto_approve, test_allow_always_matches_approval_requirement) but CI heuristic cannot detect them in cross-fork PR diffs. [skip-regression-check] Co-authored-by: Tyler <[email protected]>
This commit is contained in:
@@ -305,6 +305,11 @@ pub enum StatusUpdate {
|
||||
tool_name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
/// When `true`, the UI should offer an "always" option that auto-approves
|
||||
/// future calls to this tool for the rest of the session. When `false`
|
||||
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
|
||||
/// time and the "always" button should be hidden.
|
||||
allow_always: bool,
|
||||
},
|
||||
/// Extension needs user authentication (token or OAuth).
|
||||
AuthRequired {
|
||||
|
||||
@@ -423,6 +423,7 @@ impl Channel for RelayChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always: _,
|
||||
} = status
|
||||
else {
|
||||
return Ok(());
|
||||
@@ -794,6 +795,7 @@ mod tests {
|
||||
tool_name: "shell".into(),
|
||||
description: "run command".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -822,6 +824,7 @@ mod tests {
|
||||
tool_name: "shell".into(),
|
||||
description: "run command".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -854,6 +857,7 @@ mod tests {
|
||||
tool_name: "shell".into(),
|
||||
description: "run command".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
|
||||
@@ -539,6 +539,7 @@ impl Channel for ReplChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
} => {
|
||||
let term_width = crossterm::terminal::size()
|
||||
.map(|(w, _)| w as usize)
|
||||
@@ -582,9 +583,13 @@ impl Channel for ReplChannel {
|
||||
}
|
||||
|
||||
eprintln!(" \u{2502}");
|
||||
eprintln!(
|
||||
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
||||
);
|
||||
if allow_always {
|
||||
eprintln!(
|
||||
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
||||
);
|
||||
} else {
|
||||
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
|
||||
}
|
||||
eprintln!(" {bot_border}");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
+11
-3
@@ -915,20 +915,28 @@ impl Channel for SignalChannel {
|
||||
tool_name,
|
||||
description: _,
|
||||
parameters,
|
||||
allow_always,
|
||||
} = &status
|
||||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||||
{
|
||||
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
|
||||
let always_line = if *allow_always {
|
||||
format!(
|
||||
"\n• `always` or `a` - Approve and auto-approve future {} requests",
|
||||
tool_name
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let message = format!(
|
||||
"⚠️ *Approval Required*\n\n\
|
||||
*Request ID:* `{}`\n\
|
||||
*Tool:* {}\n\
|
||||
*Parameters:*\n```\n{}\n```\n\n\
|
||||
Reply with:\n\
|
||||
• `yes` or `y` - Approve this request\n\
|
||||
• `always` or `a` - Approve and auto-approve future {} requests\n\
|
||||
• `yes` or `y` - Approve this request{}\n\
|
||||
• `no` or `n` - Deny",
|
||||
request_id, tool_name, params_json, tool_name
|
||||
request_id, tool_name, params_json, always_line
|
||||
);
|
||||
self.send_status_message(target_str, &message).await;
|
||||
}
|
||||
|
||||
@@ -2043,6 +2043,7 @@ impl WasmChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
..
|
||||
} => {
|
||||
// WASM channels (Telegram, Slack, etc.) cannot render
|
||||
@@ -2081,6 +2082,11 @@ impl WasmChannel {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let reply_hint = if *allow_always {
|
||||
"Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
|
||||
} else {
|
||||
"Reply \"yes\" to approve or \"no\" to deny."
|
||||
};
|
||||
let prompt = format!(
|
||||
"Approval needed: {tool_name}\n\
|
||||
{description}\n\
|
||||
@@ -2088,7 +2094,7 @@ impl WasmChannel {
|
||||
Parameters:\n\
|
||||
{params_preview}\n\
|
||||
\n\
|
||||
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
|
||||
{reply_hint}"
|
||||
);
|
||||
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||
@@ -2981,15 +2987,23 @@ fn status_to_wit(
|
||||
request_id,
|
||||
tool_name,
|
||||
description,
|
||||
allow_always,
|
||||
..
|
||||
} => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ApprovalNeeded,
|
||||
message: format!(
|
||||
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
|
||||
tool_name, description, request_id
|
||||
),
|
||||
metadata_json,
|
||||
},
|
||||
} => {
|
||||
let reply_hint = if *allow_always {
|
||||
"yes (or /approve), no (or /deny), or always (or /always)"
|
||||
} else {
|
||||
"yes (or /approve) or no (or /deny)"
|
||||
};
|
||||
wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ApprovalNeeded,
|
||||
message: format!(
|
||||
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
|
||||
tool_name, description, request_id, reply_hint
|
||||
),
|
||||
metadata_json,
|
||||
}
|
||||
}
|
||||
StatusUpdate::JobStarted {
|
||||
job_id,
|
||||
title,
|
||||
@@ -3670,6 +3684,7 @@ mod tests {
|
||||
tool_name: "http_request".into(),
|
||||
description: "Fetch weather".into(),
|
||||
parameters: serde_json::json!({"url": "https://wttr.in"}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -4131,6 +4146,7 @@ mod tests {
|
||||
tool_name: "http_request".to_string(),
|
||||
description: "Fetch weather data".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -4156,6 +4172,7 @@ mod tests {
|
||||
tool_name: "http_request".to_string(),
|
||||
description: "Fetch weather data".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
|
||||
@@ -374,6 +374,7 @@ impl Channel for GatewayChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
} => SseEvent::ApprovalNeeded {
|
||||
request_id,
|
||||
tool_name,
|
||||
@@ -381,6 +382,7 @@ impl Channel for GatewayChannel {
|
||||
parameters: serde_json::to_string_pretty(¶meters)
|
||||
.unwrap_or_else(|_| parameters.to_string()),
|
||||
thread_id,
|
||||
allow_always,
|
||||
},
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name,
|
||||
|
||||
@@ -1138,18 +1138,19 @@ function showApproval(data) {
|
||||
approveBtn.textContent = I18n.t('approval.approve');
|
||||
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
|
||||
|
||||
const alwaysBtn = document.createElement('button');
|
||||
alwaysBtn.className = 'always';
|
||||
alwaysBtn.textContent = I18n.t('approval.always');
|
||||
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
||||
|
||||
const denyBtn = document.createElement('button');
|
||||
denyBtn.className = 'deny';
|
||||
denyBtn.textContent = I18n.t('approval.deny');
|
||||
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
|
||||
|
||||
actions.appendChild(approveBtn);
|
||||
actions.appendChild(alwaysBtn);
|
||||
if (data.allow_always !== false) {
|
||||
const alwaysBtn = document.createElement('button');
|
||||
alwaysBtn.className = 'always';
|
||||
alwaysBtn.textContent = I18n.t('approval.always');
|
||||
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
||||
actions.appendChild(alwaysBtn);
|
||||
}
|
||||
actions.appendChild(denyBtn);
|
||||
card.appendChild(actions);
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ pub enum SseEvent {
|
||||
parameters: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
/// Whether the "always" auto-approve option should be shown.
|
||||
allow_always: bool,
|
||||
},
|
||||
#[serde(rename = "auth_required")]
|
||||
AuthRequired {
|
||||
@@ -1080,6 +1082,7 @@ mod tests {
|
||||
description: "Run ls".to_string(),
|
||||
parameters: "{}".to_string(),
|
||||
thread_id: Some("t1".to_string()),
|
||||
allow_always: true,
|
||||
};
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
match ws {
|
||||
|
||||
Reference in New Issue
Block a user