feat(server,agent): v2 secure-session-core Task 5 - attended consent
SPEC-002 Phase 1 Task 5, code-reviewed APPROVED. An attended (support-code) session is invisible and inert to the technician until the end user accepts a consent prompt on their own machine. - proto: ConsentRequest / ConsentResponse + ConsentAccessMode enum (oneof fields 80/81; no existing field renumbered). - server: ConsentState on Session; attended -> Pending, managed -> NotRequired; join_session refuses viewers unless Granted/NotRequired (single chokepoint - StartStream only fires from join_session, so no frames or input flow pre- consent); run_consent_handshake sends ConsentRequest, 60s timeout, granted -> proceed, denied/timeout/disconnect -> teardown (end_session denied, machine offline, support code released). consent_state persisted; consent_requested/ granted/denied audited. - agent: Windows MessageBox (topmost/system-modal) on spawn_blocking; anything but an explicit Yes = deny; non-Windows build is a fail-closed stub. Not cargo-check-verified locally (no toolchain). Server verified on the build host; the Windows agent half is verified by CI build-agent (Pluto). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -369,6 +369,17 @@ impl SessionManager {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
message::Payload::ConsentRequest(req) => {
|
||||
// ATTENDED-MODE CONSENT (Task 5). The server is holding
|
||||
// this session in `consent_state = pending` and will not
|
||||
// surface it to the technician until we reply. Show the
|
||||
// end user a native dialog and return their decision; the
|
||||
// dialog blocks, so run it off the async runtime. If the
|
||||
// user closes it / no choice is made, `prompt_consent`
|
||||
// returns false (deny).
|
||||
self.handle_consent_request(req.clone()).await;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -498,6 +509,65 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an attended-mode `ConsentRequest` from the server (Task 5).
|
||||
///
|
||||
/// Shows the end user a native consent dialog (off the async runtime, since
|
||||
/// it blocks) and sends a `ConsentResponse` carrying their decision. A
|
||||
/// closed dialog / unavailable surface is treated as a DENY. The server
|
||||
/// gates the whole session on this reply, so we always send a response (even
|
||||
/// on send failure the server's consent timeout will deny).
|
||||
async fn handle_consent_request(&mut self, req: crate::proto::ConsentRequest) {
|
||||
use crate::consent::{prompt_consent, ConsentAccessMode};
|
||||
use crate::proto::ConsentResponse;
|
||||
|
||||
let session_id = req.session_id.clone();
|
||||
let technician_name = req.technician_name.clone();
|
||||
let access = ConsentAccessMode::from_proto(req.access_mode);
|
||||
|
||||
tracing::info!(
|
||||
"Consent requested for session {} by '{}' ({:?}); prompting end user",
|
||||
session_id,
|
||||
technician_name,
|
||||
access
|
||||
);
|
||||
|
||||
// The MessageBox blocks the calling thread; run it on the blocking pool
|
||||
// so the agent's async loop is not stalled and heartbeats keep flowing.
|
||||
let granted = tokio::task::spawn_blocking(move || {
|
||||
prompt_consent(&technician_name, access)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
// The blocking task panicked — fail closed (deny).
|
||||
tracing::error!("Consent dialog task failed: {}; denying", e);
|
||||
false
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
"End user {} consent for session {}",
|
||||
if granted { "GRANTED" } else { "DENIED" },
|
||||
session_id
|
||||
);
|
||||
|
||||
let response = Message {
|
||||
payload: Some(message::Payload::ConsentResponse(ConsentResponse {
|
||||
session_id,
|
||||
granted,
|
||||
reason: if granted {
|
||||
String::new()
|
||||
} else {
|
||||
"user_declined".to_string()
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
if let Some(transport) = self.transport.as_mut() {
|
||||
if let Err(e) = transport.send(response).await {
|
||||
tracing::error!("Failed to send ConsentResponse: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming message from server
|
||||
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||
match msg.payload {
|
||||
|
||||
Reference in New Issue
Block a user