@@ -27,10 +27,11 @@ import {
2727 listJSONFiles ,
2828 paginatedFileSystemQuery ,
2929 readJSON ,
30+ writeExclusive ,
3031 writeJSON ,
3132} from '../fs.js' ;
3233import { filterEventData } from './filters.js' ;
33- import { getObjectCreatedAt , monotonicUlid } from './helpers.js' ;
34+ import { getObjectCreatedAt , hashToken , monotonicUlid } from './helpers.js' ;
3435import { deleteAllHooksForRun } from './hooks-storage.js' ;
3536import { handleLegacyEvent } from './legacy.js' ;
3637
@@ -577,20 +578,24 @@ export function createEventsStorage(basedir: string): Storage['events'] {
577578 isWebhook ?: boolean ;
578579 } ;
579580
580- // Check for duplicate token before creating hook
581- const hooksDir = path . join ( basedir , 'hooks' ) ;
582- const hookFiles = await listJSONFiles ( hooksDir ) ;
583- let hasConflict = false ;
584- for ( const file of hookFiles ) {
585- const existingHookPath = path . join ( hooksDir , `${ file } .json` ) ;
586- const existingHook = await readJSON ( existingHookPath , HookSchema ) ;
587- if ( existingHook && existingHook . token === hookData . token ) {
588- hasConflict = true ;
589- break ;
590- }
591- }
581+ // Atomically claim the token using an exclusive-create constraint file.
582+ // This avoids the TOCTOU race of the previous read-all-then-check approach.
583+ const constraintPath = path . join (
584+ basedir ,
585+ 'hooks' ,
586+ 'tokens' ,
587+ `${ hashToken ( hookData . token ) } .json`
588+ ) ;
589+ const tokenClaimed = await writeExclusive (
590+ constraintPath ,
591+ JSON . stringify ( {
592+ token : hookData . token ,
593+ hookId : data . correlationId ,
594+ runId : effectiveRunId ,
595+ } )
596+ ) ;
592597
593- if ( hasConflict ) {
598+ if ( ! tokenClaimed ) {
594599 // Create hook_conflict event instead of hook_created
595600 // This allows the workflow to continue and fail gracefully when the hook is awaited
596601 const conflictEvent : Event = {
@@ -647,12 +652,23 @@ export function createEventsStorage(basedir: string): Storage['events'] {
647652 ) ;
648653 await writeJSON ( hookPath , hook ) ;
649654 } else if ( data . eventType === 'hook_disposed' ) {
650- // Delete the hook when disposed
655+ // Read the hook to get its token before deleting
651656 const hookPath = path . join (
652657 basedir ,
653658 'hooks' ,
654659 `${ data . correlationId } .json`
655660 ) ;
661+ const existingHook = await readJSON ( hookPath , HookSchema ) ;
662+ if ( existingHook ) {
663+ // Delete the token constraint file to free up the token for reuse
664+ const disposedConstraintPath = path . join (
665+ basedir ,
666+ 'hooks' ,
667+ 'tokens' ,
668+ `${ hashToken ( existingHook . token ) } .json`
669+ ) ;
670+ await deleteJSON ( disposedConstraintPath ) ;
671+ }
656672 await deleteJSON ( hookPath ) ;
657673 } else if ( data . eventType === 'wait_created' && 'eventData' in data ) {
658674 // wait_created: Creates wait entity with status 'waiting'
0 commit comments