Skip to main content

forge_workspace/
vault_ask.rs

1//! The held vault call.
2//!
3//! A vault operation an agent asks for parks here, in the workspace's own
4//! registry, until the person at a client answers it: the blocked MCP handler
5//! awaits a oneshot this module hands back, and no operation runs in the vault
6//! unless that answer approved it. It sits beside the Slack draft and the
7//! hand-offs for the same reason they do - the parking is workspace state,
8//! not a session's pending set, so a seat with no domain can still hold one.
9//!
10//! **The lock state is not stored here.** The draft is parked without it and
11//! stamped from the live session on every read, which is what lets a card
12//! whose sibling unlocked first re-base to an ordinary approve card.
13
14use uuid::Uuid;
15
16use crate::SessionSlot;
17use crate::protocol::{PendingAsk, SessionUpdate};
18use crate::workspace::Workspace;
19use forge_primitives::vault::{VaultAskAnswer, VaultAskEnding, VaultDraft};
20
21/// One vault call held for a decision: the session that asked, the draft the
22/// card draws, and the sender the blocked tool handler awaits.
23pub(crate) type ParkedVaultAsk =
24    (SessionSlot, VaultDraft, Option<tokio::sync::oneshot::Sender<VaultAskAnswer>>);
25
26impl Workspace {
27    /// Hold a vault call and hand back its id plus the receiver the caller
28    /// awaits. Addressed to `caller`, so the card surfaces in the session
29    /// that will read the reply rather than in whichever session is focused.
30    /// `needs_unlock` is the live session state at park time, used only for
31    /// the announcement; every later read restamps it.
32    pub(crate) fn register_vault_ask(
33        &self,
34        caller: &SessionSlot,
35        draft: VaultDraft,
36        needs_unlock: bool,
37    ) -> (Uuid, tokio::sync::oneshot::Receiver<VaultAskAnswer>, bool) {
38        let (sender, receiver) = tokio::sync::oneshot::channel();
39        let id = draft.id;
40        self.parked_vault_asks.lock().insert(id, (caller.clone(), draft.clone(), Some(sender)));
41        let stamped = draft.with_unlock_state(needs_unlock);
42        let answerable = self
43            .update_sender()
44            .send_answering(SessionUpdate::VaultAskPending { key: caller.clone(), draft: stamped });
45        if !answerable {
46            // No subscriber can answer this - either none is attached or none
47            // renders a dock - so holding the caller would park it for ever.
48            self.parked_vault_asks.lock().remove(&id);
49            let (_ignored_sender, dead_receiver) = tokio::sync::oneshot::channel();
50            return (id, dead_receiver, false);
51        }
52        // **A second call is a second ask.** The number is the ask's identity
53        // on the wire, so a call that never moves it is never announced.
54        self.note_ask(caller);
55        (id, receiver, true)
56    }
57
58    /// Whether a vault call with this id is still registered to `caller`,
59    /// without removing it - the read the dispatch guard makes first.
60    pub(crate) fn vault_ask_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool {
61        self.parked_vault_asks.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
62    }
63
64    /// Answer a held vault call, returning whether one was waiting for THIS
65    /// caller. True exactly once, for the entry that was there; a call held
66    /// for another session is refused rather than answered.
67    pub(crate) fn resolve_vault_ask(
68        &self,
69        id: Uuid,
70        caller: &SessionSlot,
71        answer: VaultAskAnswer,
72    ) -> bool {
73        let mut asks = self.parked_vault_asks.lock();
74        let Some((owner, _, sender)) = asks.get_mut(&id) else { return false };
75        if owner != caller {
76            return false;
77        }
78        let Some(sender) = sender.take() else { return false };
79        drop(asks);
80        let _ = sender.send(answer);
81        true
82    }
83
84    /// Settle an answered or abandoned call: remove the entry and emit the
85    /// ending every view reads. Idempotent, because the caller's drop guard
86    /// and the answer path both reach it and only the first has an entry.
87    pub(crate) fn settle_vault_ask(
88        &self,
89        id: Uuid,
90        caller: &SessionSlot,
91        ending: VaultAskEnding,
92    ) -> bool {
93        let mut asks = self.parked_vault_asks.lock();
94        if !asks.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
95            return false;
96        }
97        let Some((owner, _, _)) = asks.remove(&id) else { return false };
98        drop(asks);
99        let _ =
100            self.update_sender().send(SessionUpdate::VaultAskResolved { key: owner, id, ending });
101        true
102    }
103
104    /// End every vault call `slot` still holds as abandoned.
105    ///
106    /// **A parked call outlives its own future.** The blocked MCP handler's
107    /// future is dropped when the session's task goes, but a detached control
108    /// task is not aborted by the client's disconnect, so the registry sweep
109    /// is what tells every other view the dock is over.
110    pub(crate) fn abandon_vault_asks(&self, slot: &SessionSlot) {
111        let ids: Vec<Uuid> = self
112            .parked_vault_asks
113            .lock()
114            .iter()
115            .filter(|(_, (owner, _, _))| owner == slot)
116            .map(|(id, _)| *id)
117            .collect();
118        for id in ids {
119            self.settle_vault_ask(id, slot, VaultAskEnding::Abandoned);
120        }
121    }
122
123    /// Re-stamp every call still parked after the shared session's lock state
124    /// changed: each is now an ordinary approve card when `needs_unlock` is
125    /// false, and asks for the password again when it is true. A fold that
126    /// already holds that id merges the restamped frame in place instead of
127    /// ignoring it. The answered call is already settled and gone from the
128    /// registry.
129    pub(crate) fn rebase_vault_asks(&self, needs_unlock: bool) {
130        // Sent under the registry lock: a settle that raced this read would
131        // otherwise let a restamp overtake its own resolution and re-park a
132        // card the reader already answered.
133        let asks = self.parked_vault_asks.lock();
134        for (owner, draft, _) in asks.values() {
135            let _ = self.update_sender().send(SessionUpdate::VaultAskPending {
136                key: owner.clone(),
137                draft: draft.clone().with_unlock_state(needs_unlock),
138            });
139        }
140    }
141
142    /// Every held vault call this seat owns, stamped with the live session's
143    /// lock state. A `HashMap` keeps no arrival order to offer, the same
144    /// exception the settings writes already have.
145    pub(crate) fn vault_asks(&self, slot: &SessionSlot, needs_unlock: bool) -> Vec<PendingAsk> {
146        self.parked_vault_asks
147            .lock()
148            .values()
149            .filter(|(owner, _, _)| owner == slot)
150            .map(|(_, draft, _)| {
151                PendingAsk::Vault(Box::new(draft.clone().with_unlock_state(needs_unlock)))
152            })
153            .collect()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use std::sync::Arc;
160
161    use super::*;
162    use crate::protocol::Command;
163    use forge_primitives::vault::{VaultAskDecision, VaultTool};
164
165    fn caller() -> SessionSlot {
166        SessionSlot::lead("Default", "forge")
167    }
168
169    fn a_draft() -> VaultDraft {
170        VaultDraft::new(VaultTool::Get, "read the password for 'bank'")
171    }
172
173    fn stub() -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>) {
174        Workspace::testing_stub()
175    }
176
177    /// The prompt is addressed to the session that asked, never the lead.
178    #[test]
179    fn a_call_is_addressed_to_the_session_that_asked() {
180        let (ws, mut rx) = stub();
181        let asked = SessionSlot::worker("Default", "forge", "vault-worker");
182        let _held = ws.register_vault_ask(&asked, a_draft(), false);
183
184        let mut addressed = Vec::new();
185        while let Ok(frame) = rx.try_recv() {
186            if let SessionUpdate::VaultAskPending { key, .. } =
187                std::sync::Arc::unwrap_or_clone(frame.update)
188            {
189                addressed.push(key);
190            }
191        }
192        assert_eq!(addressed, vec![asked], "the call is addressed to the worker that asked");
193    }
194
195    /// The record read a client takes on reconnect carries the held call, and
196    /// the lock state it carries is the one passed to the read, not the one
197    /// stamped when it parked.
198    #[test]
199    fn the_record_read_restamps_the_lock_state() {
200        let (ws, _rx) = stub();
201        let draft = a_draft();
202        let (id, _held, _) = ws.register_vault_ask(&caller(), draft, true);
203
204        let asks = ws.vault_asks(&caller(), false);
205        assert!(
206            asks.iter().any(|waiting| matches!(
207                waiting,
208                PendingAsk::Vault(held) if held.id == id && !held.needs_unlock
209            )),
210            "the live session state wins over the park-time stamp",
211        );
212        assert!(ws.vault_asks(&SessionSlot::lead("Other", "project"), false).is_empty());
213    }
214
215    /// The record read a client takes on reconnect carries the held vault
216    /// call, which is what the dock draws; the registry is not a live-only
217    /// channel.
218    #[test]
219    fn the_record_read_reports_the_held_call() {
220        let (ws, _rx) = stub();
221        let (id, _held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
222        assert!(
223            ws.pending_asks(&caller())
224                .iter()
225                .any(|ask| matches!(ask, PendingAsk::Vault(draft) if draft.id == id)),
226            "the record read carries the held vault call",
227        );
228        assert!(ws.pending_asks(&SessionSlot::lead("Other", "project")).is_empty());
229    }
230
231    /// Two calls parked on a locked session: when the first unlocks it, the
232    /// second's next read is an ordinary approve card and never asks for the
233    /// password again.
234    #[test]
235    fn a_second_parked_call_rebases_when_the_first_unlocks() {
236        let (ws, _rx) = stub();
237        let (first, _held_first, _) = ws.register_vault_ask(&caller(), a_draft(), true);
238        let (second, _held_second, _) = ws.register_vault_ask(&caller(), a_draft(), true);
239
240        let locked = ws.vault_asks(&caller(), true);
241        assert_eq!(locked.len(), 2);
242        assert!(
243            locked.iter().all(|ask| matches!(
244                ask,
245                PendingAsk::Vault(draft) if draft.needs_unlock
246            )),
247            "both cards start locked",
248        );
249
250        let rebased = ws.vault_asks(&caller(), false);
251        assert_eq!(rebased.len(), 2);
252        assert!(
253            rebased.iter().all(|ask| matches!(
254                ask,
255                PendingAsk::Vault(draft) if !draft.needs_unlock
256            )),
257            "the live session state re-bases every other card",
258        );
259        for id in [first, second] {
260            assert!(
261                rebased.iter().any(|ask| matches!(
262                    ask,
263                    PendingAsk::Vault(draft) if draft.id == id
264                )),
265                "the re-base keeps the call's identity",
266            );
267        }
268    }
269
270    /// One call's unlock pushes a restamped frame for every card still parked,
271    /// which is what lets a dock that already drew them stop asking.
272    #[test]
273    fn unlocking_pushes_a_restamped_frame_for_the_other_cards() {
274        let (ws, mut rx) = stub();
275        let seat = caller();
276        let (answered, _held, _) = ws.register_vault_ask(&seat, a_draft(), true);
277        let (other, _other_held, _) = ws.register_vault_ask(&seat, a_draft(), true);
278        while rx.try_recv().is_ok() {}
279
280        ws.settle_vault_ask(answered, &seat, VaultAskEnding::Approved);
281        while rx.try_recv().is_ok() {}
282        ws.rebase_vault_asks(false);
283
284        let mut seen = Vec::new();
285        while let Ok(frame) = rx.try_recv() {
286            if let SessionUpdate::VaultAskPending { draft, .. } =
287                std::sync::Arc::unwrap_or_clone(frame.update)
288            {
289                seen.push(draft);
290            }
291        }
292        assert_eq!(seen.len(), 1, "only the card still parked is pushed");
293        assert_eq!(seen[0].id, other);
294        assert!(!seen[0].needs_unlock, "the unlock is stamped on the pushed draft");
295    }
296
297    /// A re-lock stamps every parked card as needing the password again, which
298    /// is what makes a refresh failure reach the cards still waiting.
299    #[test]
300    fn a_relock_pushes_a_restamped_frame() {
301        let (ws, mut rx) = stub();
302        let (id, _held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
303        while rx.try_recv().is_ok() {}
304
305        ws.rebase_vault_asks(true);
306
307        let mut seen = Vec::new();
308        while let Ok(frame) = rx.try_recv() {
309            if let SessionUpdate::VaultAskPending { draft, .. } =
310                std::sync::Arc::unwrap_or_clone(frame.update)
311            {
312                seen.push(draft);
313            }
314        }
315        assert_eq!(seen.len(), 1);
316        assert_eq!(seen[0].id, id);
317        assert!(seen[0].needs_unlock, "the re-lock is stamped on the pushed draft");
318    }
319
320    /// An approval resolves the waiter and a decline resolves it too; a second
321    /// answer for the same id is refused rather than silently taken.
322    #[test]
323    fn an_answer_resolves_the_waiter_exactly_once() {
324        let (ws, _rx) = stub();
325        let draft = a_draft();
326        let (id, mut held, _) = ws.register_vault_ask(&caller(), draft, false);
327
328        assert!(ws.vault_ask_waiting(id, &caller()));
329        let answer = VaultAskAnswer {
330            decision: VaultAskDecision::Approve,
331            master_password: None,
332            two_factor: None,
333            released: None,
334        };
335        assert!(ws.resolve_vault_ask(id, &caller(), answer.clone()));
336        assert_eq!(held.try_recv(), Ok(answer), "the approval reaches the blocked caller");
337        assert!(
338            !ws.resolve_vault_ask(
339                id,
340                &caller(),
341                VaultAskAnswer {
342                    decision: VaultAskDecision::Decline,
343                    master_password: None,
344                    two_factor: None,
345                    released: None,
346                }
347            ),
348            "one answer only",
349        );
350        assert!(ws.settle_vault_ask(id, &caller(), VaultAskEnding::Approved));
351        assert!(!ws.vault_ask_waiting(id, &caller()));
352    }
353
354    /// A call held for this seat is not another seat's to answer.
355    #[test]
356    fn another_seat_cannot_answer_this_call() {
357        let (ws, _rx) = stub();
358        let (id, _held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
359        let elsewhere = SessionSlot::worker("Default", "forge", "someone-else");
360
361        assert!(!ws.resolve_vault_ask(
362            id,
363            &elsewhere,
364            VaultAskAnswer {
365                decision: VaultAskDecision::Approve,
366                master_password: None,
367                two_factor: None,
368                released: None,
369            }
370        ));
371        assert!(ws.vault_ask_waiting(id, &caller()), "the call is still the asker's");
372    }
373
374    /// No client to answer: the registry must not hold the call, and the
375    /// awaiting caller sees a dead receiver - the fail-closed read.
376    #[tokio::test]
377    async fn a_call_with_no_ui_to_answer_it_fails_closed() {
378        let (ws, rx) = Workspace::testing_stub();
379        drop(rx);
380
381        let (_id, held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
382
383        assert!(ws.parked_vault_asks.lock().is_empty(), "an unanswerable call is dropped");
384        assert!(held.await.is_err(), "a dead receiver reads as not-approved");
385    }
386
387    /// Through `Workspace::dispatch`, the route a view's dock answer takes.
388    #[tokio::test]
389    async fn the_dock_answer_reaches_the_registry_through_dispatch() {
390        let (ws, _rx) = stub();
391        let (id, decision, _) = ws.register_vault_ask(&caller(), a_draft(), false);
392        let answer = VaultAskAnswer {
393            decision: VaultAskDecision::Approve,
394            master_password: None,
395            two_factor: None,
396            released: None,
397        };
398
399        ws.dispatch(Command::RespondVaultAsk { key: caller(), id, answer: answer.clone() })
400            .expect("the dock answer routes to the workspace");
401
402        assert_eq!(decision.await.expect("the held call answers"), answer);
403    }
404
405    /// A click on a dock the core has already resolved is refused.
406    #[tokio::test]
407    async fn a_stale_dock_answer_is_refused_with_the_reason() {
408        let (ws, _rx) = stub();
409        let (id, _decision, _) = ws.register_vault_ask(&caller(), a_draft(), false);
410        assert!(ws.resolve_vault_ask(
411            id,
412            &caller(),
413            VaultAskAnswer {
414                decision: VaultAskDecision::Approve,
415                master_password: None,
416                two_factor: None,
417                released: None,
418            }
419        ));
420
421        let refused = ws.dispatch(Command::RespondVaultAsk {
422            key: caller(),
423            id,
424            answer: VaultAskAnswer {
425                decision: VaultAskDecision::Approve,
426                master_password: None,
427                two_factor: None,
428                released: None,
429            },
430        });
431
432        assert!(
433            matches!(refused, Err(crate::protocol::DispatchError::NoVaultAskWaiting { .. })),
434            "a stale dock must report why the answer did not land: {refused:?}",
435        );
436    }
437
438    /// A session going away ends every call it holds.
439    #[test]
440    fn a_session_teardown_abandons_the_calls_it_holds() {
441        let (ws, mut rx) = Workspace::testing_stub();
442        let (id, mut held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
443
444        ws.abandon_vault_asks(&caller());
445
446        assert!(ws.parked_vault_asks.lock().is_empty(), "the teardown leaves nothing parked");
447        assert!(held.try_recv().is_err(), "the blocked caller is released as abandoned");
448        let mut endings = Vec::new();
449        while let Ok(frame) = rx.try_recv() {
450            if let SessionUpdate::VaultAskResolved { id: ended, ending, .. } =
451                std::sync::Arc::unwrap_or_clone(frame.update)
452            {
453                endings.push((ended, ending));
454            }
455        }
456        assert_eq!(endings, vec![(id, VaultAskEnding::Abandoned)]);
457    }
458}