@@ -8,7 +8,7 @@ import { Permission } from "../../src/permission"
88import { Session } from "../../src/session"
99import { Bus } from "../../src/bus"
1010import { SessionID } from "../../src/session/schema"
11- import { RunEvents } from "../../src/cli/cmd/run-events"
11+ import { MAX_LINEAGE_DEPTH , RunEvents } from "../../src/cli/cmd/run-events"
1212
1313const it = testEffect (
1414 Layer . mergeAll (
@@ -33,6 +33,19 @@ const waitForQuestionCount = (
3333 return yield * Effect . fail ( new Error ( `timed out waiting for ${ count } question(s)` ) )
3434 } )
3535
36+ const waitForPermissionCount = (
37+ permission : Permission . Interface ,
38+ count : number ,
39+ ) : Effect . Effect < ReadonlyArray < Permission . Request > , Error > =>
40+ Effect . gen ( function * ( ) {
41+ for ( const _ of Array . from ( { length : 100 } ) ) {
42+ const pending = yield * permission . list ( )
43+ if ( pending . length === count ) return pending
44+ yield * Effect . sleep ( "10 millis" )
45+ }
46+ return yield * Effect . fail ( new Error ( `timed out waiting for ${ count } permission(s)` ) )
47+ } )
48+
3649describe ( "cli/run-events" , ( ) => {
3750 it . live ( "auto-rejects question.asked for the root session (non-attach, non-json)" , ( ) =>
3851 provideTmpdirInstance ( ( ) =>
@@ -102,6 +115,62 @@ describe("cli/run-events", () => {
102115 ) ,
103116 )
104117
118+ it . live ( "auto-rejects question.asked across a grandchild lineage walk" , ( ) =>
119+ provideTmpdirInstance ( ( ) =>
120+ Effect . gen ( function * ( ) {
121+ const question = yield * Question . Service
122+ const session = yield * Session . Service
123+ const rootSessionID = SessionID . make ( "ses_root_grandchild_0000000000000" )
124+ const middle = yield * session . create ( { parentID : rootSessionID , title : "Middle" } )
125+ const child = yield * session . create ( { parentID : middle . id , title : "Grandchild" } )
126+ const handler = yield * RunEvents . make ( {
127+ rootSessionID,
128+ skipPermissions : false ,
129+ jsonMode : false ,
130+ } )
131+
132+ const childResult = yield * Effect . exit (
133+ question . ask ( {
134+ sessionID : child . id ,
135+ questions : [
136+ {
137+ question : "first?" ,
138+ header : "h" ,
139+ options : [ { label : "a" , description : "a" } ] ,
140+ } ,
141+ ] ,
142+ } ) ,
143+ )
144+
145+ expect ( Exit . isFailure ( childResult ) ) . toBe ( true )
146+ if ( Exit . isFailure ( childResult ) ) {
147+ expect ( Cause . squash ( childResult . cause ) ) . toBeInstanceOf ( Question . RejectedError )
148+ }
149+
150+ const middleResult = yield * Effect . exit (
151+ question . ask ( {
152+ sessionID : middle . id ,
153+ questions : [
154+ {
155+ question : "second?" ,
156+ header : "h" ,
157+ options : [ { label : "b" , description : "b" } ] ,
158+ } ,
159+ ] ,
160+ } ) ,
161+ )
162+
163+ expect ( Exit . isFailure ( middleResult ) ) . toBe ( true )
164+ if ( Exit . isFailure ( middleResult ) ) {
165+ expect ( Cause . squash ( middleResult . cause ) ) . toBeInstanceOf ( Question . RejectedError )
166+ }
167+ expect ( handler . stats . autoRejectedQuestions ) . toBe ( 2 )
168+
169+ yield * Effect . sync ( ( ) => handler . unsubscribe ( ) )
170+ } ) ,
171+ ) ,
172+ )
173+
105174 it . live ( "ignores question.asked for an unrelated session tree" , ( ) =>
106175 provideTmpdirInstance ( ( ) =>
107176 Effect . gen ( function * ( ) {
@@ -142,6 +211,53 @@ describe("cli/run-events", () => {
142211 ) ,
143212 )
144213
214+ it . live ( "does not auto-reject when lineage depth exceeds MAX_LINEAGE_DEPTH" , ( ) =>
215+ provideTmpdirInstance ( ( ) =>
216+ Effect . gen ( function * ( ) {
217+ const question = yield * Question . Service
218+ const session = yield * Session . Service
219+ const rootSessionID = SessionID . make ( "ses_root_depth_cutoff_000000000000" )
220+ const handler = yield * RunEvents . make ( {
221+ rootSessionID,
222+ skipPermissions : false ,
223+ jsonMode : false ,
224+ } )
225+
226+ const createDeepChild = ( parentID : SessionID , remaining : number ) : Effect . Effect < SessionID > => {
227+ if ( remaining === 0 ) return Effect . succeed ( parentID )
228+ return session
229+ . create ( { parentID, title : "Depth child" } )
230+ . pipe ( Effect . flatMap ( ( created ) => createDeepChild ( created . id , remaining - 1 ) ) )
231+ }
232+
233+ const deepSessionID = yield * createDeepChild ( rootSessionID , MAX_LINEAGE_DEPTH + 1 )
234+ const fiber = yield * question
235+ . ask ( {
236+ sessionID : deepSessionID ,
237+ questions : [
238+ {
239+ question : "deep?" ,
240+ header : "h" ,
241+ options : [ { label : "n" , description : "n" } ] ,
242+ } ,
243+ ] ,
244+ } )
245+ . pipe ( Effect . forkScoped )
246+
247+ const pending = yield * waitForQuestionCount ( question , 1 )
248+ expect ( pending [ 0 ] . sessionID ) . toBe ( deepSessionID )
249+ expect ( handler . stats . autoRejectedQuestions ) . toBe ( 0 )
250+
251+ yield * question . reject ( pending [ 0 ] . id )
252+ const exit = yield * Fiber . await ( fiber )
253+ expect ( Exit . isFailure ( exit ) ) . toBe ( true )
254+ if ( Exit . isFailure ( exit ) ) expect ( Cause . squash ( exit . cause ) ) . toBeInstanceOf ( Question . RejectedError )
255+
256+ yield * Effect . sync ( ( ) => handler . unsubscribe ( ) )
257+ } ) ,
258+ ) ,
259+ )
260+
145261 it . live ( "RunEvents.Config does not expose an attach field" , ( ) =>
146262 Effect . sync ( ( ) => {
147263 const validConfig : RunEvents . Config = {
@@ -229,6 +345,109 @@ describe("cli/run-events", () => {
229345 ) ,
230346 )
231347
348+ it . live ( "does not cache unrelated walks as descendants for permission.asked" , ( ) =>
349+ provideTmpdirInstance ( ( ) =>
350+ Effect . gen ( function * ( ) {
351+ const permission = yield * Permission . Service
352+ const session = yield * Session . Service
353+ const rootSessionID = SessionID . make ( "ses_root_cache_guard_0000000000000" )
354+ const unrelatedRootSessionID = SessionID . make ( "ses_unrelated_root_000000000000" )
355+ const x = yield * session . create ( { parentID : unrelatedRootSessionID , title : "X" } )
356+ const y = yield * session . create ( { parentID : x . id , title : "Y" } )
357+ const handler = yield * RunEvents . make ( {
358+ rootSessionID,
359+ skipPermissions : false ,
360+ jsonMode : false ,
361+ } )
362+
363+ const askPermission = ( sessionID : SessionID ) =>
364+ permission . ask ( {
365+ sessionID,
366+ permission : "bash" ,
367+ patterns : [ "ls" ] ,
368+ metadata : { } ,
369+ always : [ ] ,
370+ ruleset : [ { permission : "bash" , pattern : "*" , action : "ask" } ] ,
371+ } )
372+
373+ const yFiber = yield * askPermission ( y . id ) . pipe ( Effect . forkScoped )
374+ const firstPending = yield * waitForPermissionCount ( permission , 1 )
375+ expect ( firstPending [ 0 ] . sessionID ) . toBe ( y . id )
376+ expect ( handler . stats . autoRejectedPermissions ) . toBe ( 0 )
377+ yield * permission . reply ( { requestID : firstPending [ 0 ] . id , reply : "once" } )
378+ const yExit = yield * Fiber . await ( yFiber )
379+ expect ( Exit . isSuccess ( yExit ) ) . toBe ( true )
380+
381+ const xFiber = yield * askPermission ( x . id ) . pipe ( Effect . forkScoped )
382+ const secondPending = yield * waitForPermissionCount ( permission , 1 )
383+ expect ( secondPending [ 0 ] . sessionID ) . toBe ( x . id )
384+ expect ( handler . stats . autoRejectedPermissions ) . toBe ( 0 )
385+ yield * permission . reply ( { requestID : secondPending [ 0 ] . id , reply : "once" } )
386+ const xExit = yield * Fiber . await ( xFiber )
387+ expect ( Exit . isSuccess ( xExit ) ) . toBe ( true )
388+
389+ const descendant = yield * session . create ( { parentID : rootSessionID , title : "Descendant" } )
390+ const descendantExit = yield * Effect . exit ( askPermission ( descendant . id ) )
391+ expect ( Exit . isFailure ( descendantExit ) ) . toBe ( true )
392+ if ( Exit . isFailure ( descendantExit ) ) {
393+ expect ( Cause . squash ( descendantExit . cause ) ) . toBeInstanceOf ( Permission . RejectedError )
394+ }
395+ expect ( handler . stats . autoRejectedPermissions ) . toBe ( 1 )
396+
397+ yield * Effect . sync ( ( ) => handler . unsubscribe ( ) )
398+ } ) ,
399+ ) ,
400+ )
401+
402+ it . live ( "auto-approves permission.asked for the root when skipPermissions=true" , ( ) =>
403+ provideTmpdirInstance ( ( ) =>
404+ Effect . gen ( function * ( ) {
405+ const permission = yield * Permission . Service
406+ const bus = yield * Bus . Service
407+ const rootSessionID = SessionID . make ( "ses_root_skip_perm_root_000000000" )
408+ const replies : Array < { sessionID : SessionID ; reply : string } > = [ ]
409+ const unsubscribeReply = yield * bus . subscribeCallback ( Permission . Event . Replied , ( evt ) => {
410+ replies . push ( { sessionID : evt . properties . sessionID , reply : evt . properties . reply } )
411+ } )
412+ const handler = yield * RunEvents . make ( {
413+ rootSessionID,
414+ skipPermissions : true ,
415+ jsonMode : false ,
416+ } )
417+
418+ const exit = yield * Effect . exit (
419+ permission . ask ( {
420+ sessionID : rootSessionID ,
421+ permission : "bash" ,
422+ patterns : [ "ls" ] ,
423+ metadata : { } ,
424+ always : [ ] ,
425+ ruleset : [ { permission : "bash" , pattern : "*" , action : "ask" } ] ,
426+ } ) ,
427+ )
428+
429+ yield * Effect . gen ( function * ( ) {
430+ for ( const _ of Array . from ( { length : 100 } ) ) {
431+ if ( replies . length === 1 ) return
432+ yield * Effect . sleep ( "10 millis" )
433+ }
434+ return yield * Effect . fail ( new Error ( "timed out waiting for permission.replied event" ) )
435+ } )
436+
437+ expect ( Exit . isSuccess ( exit ) ) . toBe ( true )
438+ expect ( handler . stats . autoRejectedPermissions ) . toBe ( 0 )
439+ expect ( replies [ 0 ] ?. sessionID ) . toBe ( rootSessionID )
440+ expect ( replies [ 0 ] ?. reply ) . toBe ( "once" )
441+ expect ( yield * permission . list ( ) ) . toHaveLength ( 0 )
442+
443+ yield * Effect . sync ( ( ) => {
444+ unsubscribeReply ( )
445+ handler . unsubscribe ( )
446+ } )
447+ } ) ,
448+ ) ,
449+ )
450+
232451 it . live ( "emits structured JSON event to stdout when jsonMode=true" , ( ) =>
233452 provideTmpdirInstance ( ( ) =>
234453 Effect . gen ( function * ( ) {
0 commit comments