diff --git a/src/codex-auth/codex-config-symlink.ts b/src/codex-auth/codex-config-symlink.ts index 7857677d..1fb3fc61 100644 --- a/src/codex-auth/codex-config-symlink.ts +++ b/src/codex-auth/codex-config-symlink.ts @@ -94,7 +94,7 @@ export function ensureSharedConfigSymlink( target: targetPath, }); } catch (err) { - copySharedConfigFallback(targetPath, linkPath, err); + handleSymlinkFailure(targetPath, linkPath, err); } } @@ -106,9 +106,30 @@ function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): bo } } -function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void { - fs.copyFileSync(targetPath, linkPath); - fs.chmodSync(linkPath, 0o600); +function handleSymlinkFailure(targetPath: string, linkPath: string, err: unknown): void { + const code = (err as NodeJS.ErrnoException | null)?.code; + if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOSYS') { + throw err; + } + + const tmpPath = path.join( + path.dirname(linkPath), + `.config.toml.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); + + try { + fs.copyFileSync(targetPath, tmpPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(tmpPath, 0o600); + fs.renameSync(tmpPath, linkPath); + } catch (copyErr) { + try { + fs.rmSync(tmpPath, { force: true }); + } catch { + // best effort cleanup + } + throw copyErr; + } + process.stderr.write( `[!] codex-auth: symlink unavailable; copied shared config.toml to ${linkPath}. ` + `Config edits won't propagate automatically.\n` diff --git a/tests/unit/codex-auth/codex-config-symlink.test.ts b/tests/unit/codex-auth/codex-config-symlink.test.ts index fc94a188..03206fac 100644 --- a/tests/unit/codex-auth/codex-config-symlink.test.ts +++ b/tests/unit/codex-auth/codex-config-symlink.test.ts @@ -143,6 +143,19 @@ describe('ensureSharedConfigSymlink', () => { expect(stderrChunks.join('')).toContain('symlink unavailable'); }); + + it('rethrows symlink errors that are not fallback-safe', () => { + fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 }); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated race'), { code: 'EEXIST' }); + }); + + try { + expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).toThrow(/simulated race/); + } finally { + symlinkSpy.mockRestore(); + } + }); it('preserves edited fallback copies on later repair attempts', () => { fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 }); const linkPath = path.join(profileDir, 'config.toml');