@@ -13,15 +13,52 @@ vi.mock("../infra/outbound/message.js", () => ({
1313 sendMessage : vi . fn ( async ( ) => ( { ok : true } ) ) ,
1414} ) ) ;
1515
16+ import fs from "node:fs" ;
17+ import os from "node:os" ;
18+ import path from "node:path" ;
19+ import {
20+ closeSqliteSessionStoreDatabase ,
21+ replaceSqliteSessionStore ,
22+ } from "../config/sessions/store-sqlite.js" ;
23+ import { clearSessionStoreCacheForTest } from "../config/sessions/store.js" ;
24+ import type { SessionEntry } from "../config/sessions/types.js" ;
1625import { sendMessage } from "../infra/outbound/message.js" ;
1726import {
1827 buildExecApprovalFollowupPrompt ,
1928 sendExecApprovalFollowup ,
2029} from "./bash-tools.exec-approval-followup.js" ;
2130import { callGatewayTool } from "./tools/gateway.js" ;
2231
32+ const tempStoreDirs : string [ ] = [ ] ;
33+ const tempStorePaths : string [ ] = [ ] ;
34+
35+ // Seed the same SQLite-backed session store path the runtime reads; mocking this
36+ // boundary would hide stale-session regressions in shared workers.
37+ function writeTempSessionStore ( entries : Record < string , { sessionId : string } > ) : string {
38+ const dir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "exec-approval-followup-store-" ) ) ;
39+ tempStoreDirs . push ( dir ) ;
40+ const storePath = path . join ( dir , "sessions.json" ) ;
41+ tempStorePaths . push ( storePath ) ;
42+ replaceSqliteSessionStore ( storePath , entries as Record < string , SessionEntry > ) ;
43+ clearSessionStoreCacheForTest ( ) ;
44+ return storePath ;
45+ }
46+
2347afterEach ( ( ) => {
2448 vi . resetAllMocks ( ) ;
49+ clearSessionStoreCacheForTest ( ) ;
50+ while ( tempStorePaths . length > 0 ) {
51+ const storePath = tempStorePaths . pop ( ) ;
52+ if ( storePath ) {
53+ closeSqliteSessionStoreDatabase ( storePath ) ;
54+ }
55+ }
56+ while ( tempStoreDirs . length > 0 ) {
57+ const dir = tempStoreDirs . pop ( ) ;
58+ if ( dir ) {
59+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
60+ }
61+ }
2562} ) ;
2663
2764function requireRecord ( value : unknown , label : string ) : Record < string , unknown > {
@@ -118,6 +155,93 @@ describe("exec approval followup", () => {
118155 expect ( sendMessage ) . not . toHaveBeenCalled ( ) ;
119156 } ) ;
120157
158+ it ( "forwards the approval-time session id so the gateway can drop stale followups" , async ( ) => {
159+ await sendExecApprovalFollowup ( {
160+ approvalId : "req-pin-59349" ,
161+ sessionKey : "agent:main:main" ,
162+ expectedSessionId : "session-original" ,
163+ resultText : "Exec completed: echo ok" ,
164+ } ) ;
165+
166+ expectGatewayAgentFollowup ( {
167+ sessionKey : "agent:main:main" ,
168+ execApprovalFollowupExpectedSessionId : "session-original" ,
169+ } ) ;
170+ } ) ;
171+
172+ it ( "omits the expected session id when none was captured" , async ( ) => {
173+ await sendExecApprovalFollowup ( {
174+ approvalId : "req-no-pin" ,
175+ sessionKey : "agent:main:main" ,
176+ resultText : "Exec completed: echo ok" ,
177+ } ) ;
178+
179+ const params = expectGatewayAgentFollowup ( { sessionKey : "agent:main:main" } ) ;
180+ expect ( params ) . not . toHaveProperty ( "execApprovalFollowupExpectedSessionId" ) ;
181+ } ) ;
182+
183+ it ( "drops a denied direct followup when the session key was rebound by /new or /reset" , async ( ) => {
184+ const sessionStore = writeTempSessionStore ( {
185+ "agent:main:main" : { sessionId : "session-after-reset" } ,
186+ } ) ;
187+
188+ const result = await sendExecApprovalFollowup ( {
189+ approvalId : "req-denied-rebound" ,
190+ sessionKey : "agent:main:main" ,
191+ expectedSessionId : "session-original" ,
192+ sessionStore,
193+ direct : true ,
194+ turnSourceChannel : "telegram" ,
195+ turnSourceTo : "-100123" ,
196+ resultText : "Exec denied (gateway id=req-denied-rebound, user-denied): uname -a" ,
197+ } ) ;
198+
199+ expect ( result ) . toBe ( false ) ;
200+ expect ( sendMessage ) . not . toHaveBeenCalled ( ) ;
201+ expect ( callGatewayTool ) . not . toHaveBeenCalled ( ) ;
202+ } ) ;
203+
204+ it ( "delivers a denied direct followup when the key still resolves to the approval-time session" , async ( ) => {
205+ const sessionStore = writeTempSessionStore ( {
206+ "agent:main:main" : { sessionId : "session-original" } ,
207+ } ) ;
208+
209+ await sendExecApprovalFollowup ( {
210+ approvalId : "req-denied-same" ,
211+ sessionKey : "agent:main:main" ,
212+ expectedSessionId : "session-original" ,
213+ sessionStore,
214+ direct : true ,
215+ turnSourceChannel : "telegram" ,
216+ turnSourceTo : "-100123" ,
217+ resultText : "Exec denied (gateway id=req-denied-same, user-denied): uname -a" ,
218+ } ) ;
219+
220+ expect ( sendMessage ) . toHaveBeenCalled ( ) ;
221+ expect ( callGatewayTool ) . not . toHaveBeenCalled ( ) ;
222+ } ) ;
223+
224+ it ( "drops a non-denied direct fallback when the session key was rebound" , async ( ) => {
225+ const sessionStore = writeTempSessionStore ( {
226+ "agent:main:main" : { sessionId : "session-after-reset" } ,
227+ } ) ;
228+
229+ const result = await sendExecApprovalFollowup ( {
230+ approvalId : "req-finished-rebound" ,
231+ sessionKey : "agent:main:main" ,
232+ expectedSessionId : "session-original" ,
233+ sessionStore,
234+ direct : true ,
235+ turnSourceChannel : "telegram" ,
236+ turnSourceTo : "-100123" ,
237+ resultText : "Exec finished (gateway id=req-finished-rebound, code 0)\nok" ,
238+ } ) ;
239+
240+ expect ( result ) . toBe ( false ) ;
241+ expect ( sendMessage ) . not . toHaveBeenCalled ( ) ;
242+ expect ( callGatewayTool ) . not . toHaveBeenCalled ( ) ;
243+ } ) ;
244+
121245 it ( "routes denied followups through the originating main session" , async ( ) => {
122246 await sendExecApprovalFollowup ( {
123247 approvalId : "req-denied-main" ,
0 commit comments