story-219: add Always Allow button to web UI permission dialog

Cherry-pick from feature branch — code was never squash-merged
despite story being accepted (bug 226).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-27 10:00:33 +00:00
parent de03cfe8b3
commit eeec745abc
6 changed files with 331 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
use crate::http::context::AppContext;
use crate::http::context::{AppContext, PermissionDecision};
use crate::http::workflow::{PipelineState, load_pipeline_state};
use crate::io::onboarding;
use crate::io::watcher::WatcherEvent;
@@ -29,6 +29,8 @@ enum WsRequest {
PermissionResponse {
request_id: String,
approved: bool,
#[serde(default)]
always_allow: bool,
},
/// Heartbeat ping from the client. The server responds with `Pong` so the
/// client can detect stale (half-closed) connections.
@@ -250,7 +252,7 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
// Permission requests arrive from the MCP `prompt_permission` tool via
// `ctx.perm_rx` and are forwarded to the client as `PermissionRequest`.
// When the client responds, we resolve the corresponding oneshot.
let mut pending_perms: HashMap<String, oneshot::Sender<bool>> = HashMap::new();
let mut pending_perms: HashMap<String, oneshot::Sender<PermissionDecision>> = HashMap::new();
loop {
// Outer loop: wait for the next WebSocket message.
@@ -324,9 +326,16 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
// (permission responses and cancellations).
Some(Ok(WsMessage::Text(inner_text))) = stream.next() => {
match serde_json::from_str::<WsRequest>(&inner_text) {
Ok(WsRequest::PermissionResponse { request_id, approved }) => {
Ok(WsRequest::PermissionResponse { request_id, approved, always_allow }) => {
if let Some(resp_tx) = pending_perms.remove(&request_id) {
let _ = resp_tx.send(approved);
let decision = if always_allow {
PermissionDecision::AlwaysAllow
} else if approved {
PermissionDecision::Approve
} else {
PermissionDecision::Deny
};
let _ = resp_tx.send(decision);
}
}
Ok(WsRequest::Cancel) => {
@@ -457,9 +466,11 @@ mod tests {
WsRequest::PermissionResponse {
request_id,
approved,
always_allow,
} => {
assert_eq!(request_id, "req-42");
assert!(approved);
assert!(!always_allow);
}
_ => panic!("expected PermissionResponse variant"),
}
@@ -477,9 +488,34 @@ mod tests {
WsRequest::PermissionResponse {
request_id,
approved,
always_allow,
} => {
assert_eq!(request_id, "req-99");
assert!(!approved);
assert!(!always_allow);
}
_ => panic!("expected PermissionResponse variant"),
}
}
#[test]
fn deserialize_permission_response_always_allow() {
let json = r#"{
"type": "permission_response",
"request_id": "req-100",
"approved": true,
"always_allow": true
}"#;
let req: WsRequest = serde_json::from_str(json).unwrap();
match req {
WsRequest::PermissionResponse {
request_id,
approved,
always_allow,
} => {
assert_eq!(request_id, "req-100");
assert!(approved);
assert!(always_allow);
}
_ => panic!("expected PermissionResponse variant"),
}