@@ -308,6 +308,139 @@ describe("broker pull bridge semi-integration", () => {
308308 expect ( valid ) . toBe ( true ) ;
309309 } ) ;
310310
311+ it ( "acks poison messages with decryption failures (wrong keys)" , async ( ) => {
312+ await sodium . ready ;
313+
314+ let pullCount = 0 ;
315+ let ackPayload = null ;
316+
317+ // Generate valid encryption with mismatched keys to trigger "incorrect key pair"
318+ const wrongBoxKeypair = sodium . crypto_box_keypair ( ) ;
319+ const serverBoxKeypair = sodium . crypto_box_seed_keypair ( new Uint8Array ( Buffer . alloc ( 32 , 11 ) ) ) ;
320+ const brokerSignKeypair = sodium . crypto_sign_seed_keypair ( new Uint8Array ( Buffer . alloc ( 32 , 15 ) ) ) ;
321+
322+ const payload = JSON . stringify ( { source : "slack" , type : "message" , payload : { text : "test" } , broker_timestamp : 123 } ) ;
323+ // Encrypt with WRONG key (wrongBoxKeypair) but bridge will try to decrypt with serverBoxKeypair
324+ const encrypted = sodium . crypto_box_seal ( new TextEncoder ( ) . encode ( payload ) , wrongBoxKeypair . publicKey ) ;
325+ const encryptedB64 = toBase64 ( encrypted ) ;
326+
327+ const broker = createServer ( async ( req , res ) => {
328+ if ( req . method === "POST" && req . url === "/api/inbox/pull" ) {
329+ pullCount += 1 ;
330+ const brokerTimestamp = Math . floor ( Date . now ( ) / 1000 ) ;
331+
332+ const canonical = canonicalizeEnvelope ( "T123BROKER" , brokerTimestamp , encryptedB64 ) ;
333+ const signature = sodium . crypto_sign_detached ( canonical , brokerSignKeypair . privateKey ) ;
334+
335+ const messages = pullCount === 1
336+ ? [ {
337+ message_id : "m-decrypt-fail-1" ,
338+ workspace_id : "T123BROKER" ,
339+ encrypted : encryptedB64 ,
340+ broker_timestamp : brokerTimestamp ,
341+ broker_signature : toBase64 ( signature ) ,
342+ } ]
343+ : [ ] ;
344+
345+ res . writeHead ( 200 , { "Content-Type" : "application/json" } ) ;
346+ res . end ( JSON . stringify ( { ok : true , messages } ) ) ;
347+ return ;
348+ }
349+
350+ if ( req . method === "POST" && req . url === "/api/inbox/ack" ) {
351+ let raw = "" ;
352+ for await ( const chunk of req ) raw += chunk ;
353+ ackPayload = JSON . parse ( raw ) ;
354+
355+ res . writeHead ( 200 , { "Content-Type" : "application/json" } ) ;
356+ res . end ( JSON . stringify ( { ok : true , acked : ackPayload . message_ids ?. length ?? 0 } ) ) ;
357+ return ;
358+ }
359+
360+ if ( req . method === "POST" && req . url === "/api/send" ) {
361+ res . writeHead ( 200 , { "Content-Type" : "application/json" } ) ;
362+ res . end ( JSON . stringify ( { ok : true , ts : "1234.5678" } ) ) ;
363+ return ;
364+ }
365+
366+ res . writeHead ( 404 , { "Content-Type" : "application/json" } ) ;
367+ res . end ( JSON . stringify ( { ok : false , error : "not found" } ) ) ;
368+ } ) ;
369+
370+ await new Promise ( ( resolve ) => broker . listen ( 0 , "127.0.0.1" , resolve ) ) ;
371+ servers . push ( broker ) ;
372+
373+ const address = broker . address ( ) ;
374+ if ( ! address || typeof address === "string" ) {
375+ throw new Error ( "failed to get broker test server address" ) ;
376+ }
377+ const brokerUrl = `http://127.0.0.1:${ address . port } ` ;
378+
379+ const testFileDir = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
380+ const repoRoot = path . dirname ( testFileDir ) ;
381+ const bridgePath = path . join ( repoRoot , "slack-bridge" , "broker-bridge.mjs" ) ;
382+ const bridgeCwd = path . join ( repoRoot , "slack-bridge" ) ;
383+
384+ let bridgeStdout = "" ;
385+ let bridgeStderr = "" ;
386+ let bridgeExit = null ;
387+
388+ const bridge = spawn ( "node" , [ bridgePath ] , {
389+ cwd : bridgeCwd ,
390+ env : {
391+ ...cleanEnv ( ) ,
392+ SLACK_BROKER_URL : brokerUrl ,
393+ SLACK_BROKER_WORKSPACE_ID : "T123BROKER" ,
394+ SLACK_BROKER_SERVER_PRIVATE_KEY : toBase64 ( serverBoxKeypair . privateKey ) ,
395+ SLACK_BROKER_SERVER_PUBLIC_KEY : toBase64 ( serverBoxKeypair . publicKey ) ,
396+ SLACK_BROKER_SERVER_SIGNING_PRIVATE_KEY : b64 ( 32 , 13 ) ,
397+ SLACK_BROKER_PUBLIC_KEY : b64 ( 32 , 14 ) ,
398+ SLACK_BROKER_SIGNING_PUBLIC_KEY : toBase64 ( brokerSignKeypair . publicKey ) ,
399+ SLACK_BROKER_ACCESS_TOKEN : "test-broker-token" ,
400+ SLACK_ALLOWED_USERS : "U_ALLOWED" ,
401+ SLACK_BROKER_POLL_INTERVAL_MS : "50" ,
402+ BRIDGE_API_PORT : "0" ,
403+ } ,
404+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
405+ } ) ;
406+
407+ bridge . stdout . on ( "data" , ( chunk ) => {
408+ bridgeStdout += chunk . toString ( ) ;
409+ } ) ;
410+ bridge . stderr . on ( "data" , ( chunk ) => {
411+ bridgeStderr += chunk . toString ( ) ;
412+ } ) ;
413+ const bridgeExited = new Promise ( ( _ , reject ) => {
414+ bridge . on ( "error" , ( err ) => {
415+ if ( ackPayload !== null ) return ;
416+ reject ( new Error ( `bridge spawn error: ${ err . message } ; stdout=${ bridgeStdout } ; stderr=${ bridgeStderr } ` ) ) ;
417+ } ) ;
418+ bridge . on ( "exit" , ( code , signal ) => {
419+ bridgeExit = { code, signal } ;
420+ if ( ackPayload !== null ) return ;
421+ reject ( new Error ( `bridge exited early: code=${ code } signal=${ signal } ; stdout=${ bridgeStdout } ; stderr=${ bridgeStderr } ` ) ) ;
422+ } ) ;
423+ } ) ;
424+
425+ children . push ( bridge ) ;
426+
427+ const ackWait = waitFor (
428+ ( ) => ackPayload !== null ,
429+ 10_000 ,
430+ 50 ,
431+ `timeout waiting for ack; pullCount=${ pullCount } ; exit=${ JSON . stringify ( bridgeExit ) } ; stdout=${ bridgeStdout } ; stderr=${ bridgeStderr } ` ,
432+ ) ;
433+
434+ await Promise . race ( [ ackWait , bridgeExited ] ) ;
435+
436+ expect ( ackPayload . workspace_id ) . toBe ( "T123BROKER" ) ;
437+ expect ( ackPayload . protocol_version ) . toBe ( "2026-02-1" ) ;
438+ expect ( ackPayload . message_ids ) . toContain ( "m-decrypt-fail-1" ) ;
439+
440+ // Verify that the bridge logged a decryption error before acking
441+ expect ( bridgeStderr ) . toContain ( "failed to decrypt" ) ;
442+ } ) ;
443+
311444 it ( "forwards user messages to agent in fire-and-forget mode without get_message/turn_end RPCs" , async ( ) => {
312445 await sodium . ready ;
313446
0 commit comments