@@ -11,7 +11,7 @@ use std::collections::BTreeMap;
1111use std:: time:: { SystemTime , UNIX_EPOCH } ;
1212
1313use crate :: host_ctx:: HostCtx ;
14- use agentos_client:: { AgentOs , CreateSessionOptions } ;
14+ use agentos_client:: { AgentOs , CreateSessionOptions , PermissionReply } ;
1515use anyhow:: { anyhow, Result } ;
1616use futures:: StreamExt ;
1717use serde:: { Deserialize , Serialize } ;
@@ -135,6 +135,111 @@ fn spawn_event_capture(
135135 . insert ( live_session_id. to_owned ( ) , handle) ;
136136}
137137
138+ /// Build the `permissionRequest` broadcast body for one request.
139+ ///
140+ /// The RivetKit event wire is CBOR and the body is the array of handler
141+ /// ARGUMENTS the client spreads into the listener (`handler(...body)`). The
142+ /// documented listener is `(data) => …`, so the single argument is the TS
143+ /// `PermissionRequestPayload` — `{ sessionId, request: { permissionId,
144+ /// description?, params } }` — and the body is `[ <that object> ]`. The
145+ /// `sessionId` is the client-facing external id (== live for native sessions).
146+ fn permission_event_body (
147+ external_session_id : & str ,
148+ permission_id : & str ,
149+ description : Option < & str > ,
150+ params : & JsonValue ,
151+ ) -> JsonValue {
152+ json ! ( [ {
153+ "sessionId" : external_session_id,
154+ "request" : {
155+ "permissionId" : permission_id,
156+ "description" : description,
157+ "params" : params,
158+ } ,
159+ } ] )
160+ }
161+
162+ /// Map the wire reply string to a [`PermissionReply`] (`"once"` / `"always"` /
163+ /// `"reject"`), matching the TS `PermissionReply` union.
164+ fn parse_permission_reply ( reply : & str ) -> Result < PermissionReply > {
165+ match reply {
166+ "once" => Ok ( PermissionReply :: Once ) ,
167+ "always" => Ok ( PermissionReply :: Always ) ,
168+ "reject" => Ok ( PermissionReply :: Reject ) ,
169+ other => Err ( anyhow ! (
170+ "invalid permission reply {other:?} (expected \" once\" | \" always\" | \" reject\" )"
171+ ) ) ,
172+ }
173+ }
174+
175+ /// Subscribe to the session's permission-request stream and spawn a task that
176+ /// broadcasts each request to connected clients as a `permissionRequest` event
177+ /// (`conn.on("permissionRequest", …)`).
178+ ///
179+ /// Mirrors [`spawn_event_capture`]. A subscriber MUST exist before the guest
180+ /// agent raises a permission request, otherwise the client auto-rejects it
181+ /// (`deliver_sidecar_permission_request` checks `receiver_count() == 0`) — so
182+ /// this is started at session-create time. Clients answer via the
183+ /// `respondPermission` action (→ [`respond_permission`]), which resolves the
184+ /// pending reply slot; this pump only fans the request out, so dropping the
185+ /// broadcast item's responder clone here is harmless.
186+ fn spawn_permission_pump (
187+ ctx : & HostCtx ,
188+ vm : & AgentOs ,
189+ vars : & mut Vars ,
190+ external_session_id : & str ,
191+ live_session_id : & str ,
192+ ) {
193+ let ( mut stream, subscription) = match vm. on_permission_request ( live_session_id) {
194+ Ok ( sub) => sub,
195+ Err ( error) => {
196+ tracing:: warn!( ?error, live_session_id, "on_permission_request subscribe failed" ) ;
197+ return ;
198+ }
199+ } ;
200+ if let Some ( old) = vars. permission_tasks . remove ( live_session_id) {
201+ old. abort ( ) ;
202+ }
203+ let ctx = ctx. clone ( ) ;
204+ let external = external_session_id. to_owned ( ) ;
205+ let handle = tokio:: spawn ( async move {
206+ // Keep the RAII guard alive for the pump's lifetime; dropping the stream
207+ // (on abort / channel close) is the unsubscribe.
208+ let _subscription = subscription;
209+ while let Some ( request) = stream. next ( ) . await {
210+ let body = permission_event_body (
211+ & external,
212+ & request. permission_id ,
213+ request. description . as_deref ( ) ,
214+ & request. params ,
215+ ) ;
216+ let mut cbor = Vec :: new ( ) ;
217+ if ciborium:: into_writer ( & body, & mut cbor) . is_ok ( ) {
218+ let _ = ctx. broadcast ( b"permissionRequest" . to_vec ( ) , cbor) ;
219+ }
220+ }
221+ } ) ;
222+ vars. permission_tasks
223+ . insert ( live_session_id. to_owned ( ) , handle) ;
224+ }
225+
226+ /// Answer a permission request raised by the session's guest agent
227+ /// (`respondPermission`). Resolves the pending reply slot through the client's
228+ /// `respond_permission`, keyed by the live session id.
229+ pub async fn respond_permission (
230+ vm : & AgentOs ,
231+ vars : & Vars ,
232+ session_id : & str ,
233+ permission_id : & str ,
234+ reply : & str ,
235+ ) -> Result < ( ) > {
236+ let reply = parse_permission_reply ( reply) ?;
237+ let live_session_id = vars. live_id ( session_id) . to_owned ( ) ;
238+ vm. respond_permission ( & live_session_id, permission_id, reply)
239+ . await ?;
240+ Ok ( ( ) )
241+ }
242+
138243pub async fn create_session (
139244 ctx : & HostCtx ,
140245 vm : & AgentOs ,
@@ -176,8 +281,11 @@ pub async fn create_session(
176281 )
177282 . await ?;
178283 // At create time `external == live`; capture every `session/update` for this
179- // session under the external id (spec §3/§5).
284+ // session under the external id (spec §3/§5), and start fanning the guest's
285+ // permission requests out to connected clients. The permission pump must be
286+ // subscribed before the agent runs, or requests would auto-reject.
180287 spawn_event_capture ( ctx, vm, vars, & session_id, & session_id) ;
288+ spawn_permission_pump ( ctx, vm, vars, & session_id, & session_id) ;
181289 Ok ( SessionIdDto { session_id } )
182290}
183291
@@ -227,11 +335,14 @@ pub async fn close_session(
227335 vars : & mut Vars ,
228336 session_id : & str ,
229337) -> Result < ( ) > {
230- // Stop event capture + drop the remap for this external session.
338+ // Stop event capture + the permission pump + drop the remap for this session.
231339 let live_session_id = vars. live_id ( session_id) . to_owned ( ) ;
232340 if let Some ( task) = vars. capture_tasks . remove ( & live_session_id) {
233341 task. abort ( ) ;
234342 }
343+ if let Some ( task) = vars. permission_tasks . remove ( & live_session_id) {
344+ task. abort ( ) ;
345+ }
235346 vars. live_sessions . remove ( session_id) ;
236347 vm. close_session ( & live_session_id) . map_err ( |e| anyhow ! ( e) ) ?;
237348 // Drop persisted metadata + events (explicit, since SQLite FK cascade is
@@ -397,3 +508,58 @@ pub async fn resume_session(
397508 AcpResumeSessionRequest contract"
398509 ) )
399510}
511+
512+ #[ cfg( test) ]
513+ mod tests {
514+ use super :: * ;
515+ use agentos_client:: PermissionReply ;
516+
517+ #[ test]
518+ fn permission_event_body_matches_ts_payload_shape ( ) {
519+ // The TS client listener is `(data) => …` where data is
520+ // `PermissionRequestPayload { sessionId, request: { permissionId,
521+ // description?, params } }`, delivered as the single broadcast arg.
522+ let params = json ! ( {
523+ "toolCall" : { "title" : "Bash" , "kind" : "execute" } ,
524+ "options" : [ { "optionId" : "allow_once" } ] ,
525+ } ) ;
526+ let body = permission_event_body ( "sess-1" , "perm-7" , Some ( "run a command" ) , & params) ;
527+
528+ // Body is the args array spread into the listener: exactly one argument.
529+ let args = body. as_array ( ) . expect ( "body is an array" ) ;
530+ assert_eq ! ( args. len( ) , 1 , "exactly one handler argument" ) ;
531+ let data = & args[ 0 ] ;
532+
533+ assert_eq ! ( data[ "sessionId" ] , json!( "sess-1" ) ) ;
534+ assert_eq ! ( data[ "request" ] [ "permissionId" ] , json!( "perm-7" ) ) ;
535+ assert_eq ! ( data[ "request" ] [ "description" ] , json!( "run a command" ) ) ;
536+ // params are forwarded verbatim so the client can inspect the tool/paths.
537+ assert_eq ! ( data[ "request" ] [ "params" ] , params) ;
538+ }
539+
540+ #[ test]
541+ fn permission_event_body_serializes_absent_description_as_null ( ) {
542+ let body = permission_event_body ( "sess-1" , "perm-1" , None , & json ! ( { } ) ) ;
543+ assert_eq ! ( body[ 0 ] [ "request" ] [ "description" ] , JsonValue :: Null ) ;
544+ }
545+
546+ #[ test]
547+ fn parse_permission_reply_maps_each_wire_value ( ) {
548+ assert_eq ! ( parse_permission_reply( "once" ) . unwrap( ) , PermissionReply :: Once ) ;
549+ assert_eq ! (
550+ parse_permission_reply( "always" ) . unwrap( ) ,
551+ PermissionReply :: Always
552+ ) ;
553+ assert_eq ! (
554+ parse_permission_reply( "reject" ) . unwrap( ) ,
555+ PermissionReply :: Reject
556+ ) ;
557+ }
558+
559+ #[ test]
560+ fn parse_permission_reply_rejects_unknown_value ( ) {
561+ let err = parse_permission_reply ( "maybe" ) . unwrap_err ( ) . to_string ( ) ;
562+ assert ! ( err. contains( "invalid permission reply" ) , "got: {err}" ) ;
563+ assert ! ( err. contains( "maybe" ) , "names the bad value: {err}" ) ;
564+ }
565+ }
0 commit comments