@@ -1080,6 +1080,66 @@ describe("codex-multi-auth sync", () => {
10801080 }
10811081 } ) ;
10821082
1083+ it ( "warns when stale sync temp scrubbing hits an unexpected truncate error" , async ( ) => {
1084+ const rootDir = join ( process . cwd ( ) , ".tmp-codex-multi-auth" ) ;
1085+ const fakeHome = await fs . promises . mkdtemp ( join ( os . tmpdir ( ) , "codex-sync-home-" ) ) ;
1086+ process . env . CODEX_MULTI_AUTH_DIR = rootDir ;
1087+ process . env . HOME = fakeHome ;
1088+ process . env . USERPROFILE = fakeHome ;
1089+ const globalPath = join ( rootDir , "openai-codex-accounts.json" ) ;
1090+ const tempRoot = join ( fakeHome , ".opencode" , "tmp" ) ;
1091+ const staleDir = join ( tempRoot , "oc-chatgpt-multi-auth-sync-stale-scrub-warning" ) ;
1092+ const staleFile = join ( staleDir , "accounts.json" ) ;
1093+ mockExistsSync . mockImplementation ( ( candidate ) => String ( candidate ) === globalPath ) ;
1094+ mockSourceStorageFile (
1095+ globalPath ,
1096+ JSON . stringify ( {
1097+ version : 3 ,
1098+ activeIndex : 0 ,
1099+ activeIndexByFamily : { } ,
1100+ accounts : [ { accountId : "org-source" , organizationId : "org-source" , refreshToken : "rt-source" , addedAt : 1 , lastUsed : 1 } ] ,
1101+ } ) ,
1102+ ) ;
1103+
1104+ const originalRm = fs . promises . rm . bind ( fs . promises ) ;
1105+ const rmSpy = vi . spyOn ( fs . promises , "rm" ) . mockImplementation ( async ( path , options ) => {
1106+ if ( String ( path ) === staleDir ) {
1107+ throw Object . assign ( new Error ( "still locked" ) , { code : "EBUSY" } ) ;
1108+ }
1109+ return originalRm ( path , options as never ) ;
1110+ } ) ;
1111+ const truncateSpy = vi . spyOn ( fs . promises , "truncate" ) . mockImplementation ( async ( path , len ) => {
1112+ if ( String ( path ) === staleFile ) {
1113+ throw Object . assign ( new Error ( "disk io failed" ) , { code : "EIO" } ) ;
1114+ }
1115+ return undefined ;
1116+ } ) ;
1117+ const loggerModule = await import ( "../lib/logger.js" ) ;
1118+
1119+ try {
1120+ await fs . promises . mkdir ( staleDir , { recursive : true } ) ;
1121+ await fs . promises . writeFile ( staleFile , "sensitive-refresh-token" , "utf8" ) ;
1122+ const oldTime = new Date ( Date . now ( ) - ( 15 * 60 * 1000 ) ) ;
1123+ await fs . promises . utimes ( staleDir , oldTime , oldTime ) ;
1124+ await fs . promises . utimes ( staleFile , oldTime , oldTime ) ;
1125+
1126+ const { previewSyncFromCodexMultiAuth } = await import ( "../lib/codex-multi-auth-sync.js" ) ;
1127+ await expect ( previewSyncFromCodexMultiAuth ( process . cwd ( ) ) ) . resolves . toMatchObject ( {
1128+ rootDir,
1129+ accountsPath : globalPath ,
1130+ scope : "global" ,
1131+ } ) ;
1132+
1133+ expect ( vi . mocked ( loggerModule . logWarn ) ) . toHaveBeenCalledWith (
1134+ expect . stringContaining ( `Failed to scrub stale codex sync temp file ${ staleFile } : disk io failed` ) ,
1135+ ) ;
1136+ } finally {
1137+ rmSpy . mockRestore ( ) ;
1138+ truncateSpy . mockRestore ( ) ;
1139+ await fs . promises . rm ( fakeHome , { recursive : true , force : true } ) ;
1140+ }
1141+ } ) ;
1142+
10831143 it ( "skips source accounts whose emails already exist locally during sync" , async ( ) => {
10841144 const rootDir = join ( process . cwd ( ) , ".tmp-codex-multi-auth" ) ;
10851145 process . env . CODEX_MULTI_AUTH_DIR = rootDir ;
@@ -1657,14 +1717,17 @@ describe("codex-multi-auth sync", () => {
16571717 expect ( persist ) . not . toHaveBeenCalled ( ) ;
16581718 } ) ;
16591719
1660- it ( "writes overlap cleanup backups via a temp file before rename " , async ( ) => {
1720+ it ( "writes overlap cleanup backups via a temp file and retries transient EACCES renames " , async ( ) => {
16611721 const storageModule = await import ( "../lib/storage.js" ) ;
16621722 vi . mocked ( storageModule . withAccountStorageTransaction ) . mockImplementationOnce ( async ( handler ) =>
16631723 handler ( defaultTransactionalStorage ( ) , vi . fn ( async ( ) => { } ) ) ,
16641724 ) ;
16651725 const mkdirSpy = vi . spyOn ( fs . promises , "mkdir" ) . mockResolvedValue ( undefined ) ;
16661726 const writeSpy = vi . spyOn ( fs . promises , "writeFile" ) . mockResolvedValue ( undefined ) ;
1667- const renameSpy = vi . spyOn ( fs . promises , "rename" ) . mockResolvedValue ( undefined ) ;
1727+ const renameSpy = vi
1728+ . spyOn ( fs . promises , "rename" )
1729+ . mockRejectedValueOnce ( Object . assign ( new Error ( "rename locked" ) , { code : "EACCES" } ) )
1730+ . mockResolvedValueOnce ( undefined ) ;
16681731 const unlinkSpy = vi . spyOn ( fs . promises , "unlink" ) . mockResolvedValue ( undefined ) ;
16691732
16701733 try {
@@ -1675,6 +1738,7 @@ describe("codex-multi-auth sync", () => {
16751738 const tempBackupPath = writeSpy . mock . calls [ 0 ] ?. [ 0 ] ;
16761739 expect ( String ( tempBackupPath ) ) . toMatch ( / ^ \/ t m p \/ o v e r l a p - c l e a n u p - b a c k u p \. j s o n \. \d + \. [ a - z 0 - 9 ] + \. t m p $ / ) ;
16771740 expect ( renameSpy ) . toHaveBeenCalledWith ( tempBackupPath , "/tmp/overlap-cleanup-backup.json" ) ;
1741+ expect ( renameSpy ) . toHaveBeenCalledTimes ( 2 ) ;
16781742 expect ( unlinkSpy ) . not . toHaveBeenCalled ( ) ;
16791743 } finally {
16801744 mkdirSpy . mockRestore ( ) ;
0 commit comments