Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 69 additions & 27 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,20 @@ function getAccountsBackupRecoveryCandidates(path: string): string[] {
return candidates;
}

function normalizeStorageComparisonPath(path: string): string {
const resolved = resolvePath(path);
if (process.platform !== "win32") {
return resolved;
}
return resolved.replaceAll("\\", "/").toLowerCase();
}

function areEquivalentStoragePaths(left: string, right: string): boolean {
return (
normalizeStorageComparisonPath(left) === normalizeStorageComparisonPath(right)
);
}
Comment on lines +384 to +396
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 symlink aliases can fool path equivalence on posix

normalizeStorageComparisonPath calls resolvePath (which uses path.resolve) — that resolves . and .. components but does not dereference symlinks. if the stored transaction path was captured through a symlink (~/.codex/multi-auth → /Users/foo/.codex/multi-auth) and the active path comes in as the real path (or vice versa), areEquivalentStoragePaths returns false and exportAccounts throws a spurious mismatch error. on windows token storage paths this is less common, but on macos /var/private/var symlinks are a well-known trap. using fs.realpathSync (with a try/catch for missing paths) would make the comparison robust. worth at least a code comment noting the symlink caveat.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/storage.ts
Line: 384-396

Comment:
**symlink aliases can fool path equivalence on posix**

`normalizeStorageComparisonPath` calls `resolvePath` (which uses `path.resolve`) — that resolves `.` and `..` components but does **not** dereference symlinks. if the stored transaction path was captured through a symlink (`~/.codex/multi-auth → /Users/foo/.codex/multi-auth`) and the active path comes in as the real path (or vice versa), `areEquivalentStoragePaths` returns `false` and `exportAccounts` throws a spurious mismatch error. on windows token storage paths this is less common, but on macos `/var``/private/var` symlinks are a well-known trap. using `fs.realpathSync` (with a try/catch for missing paths) would make the comparison robust. worth at least a code comment noting the symlink caveat.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex


async function getAccountsBackupRecoveryCandidatesWithDiscovery(
path: string,
): Promise<string[]> {
Expand Down Expand Up @@ -813,8 +827,13 @@ function latestValidSnapshot(
snapshots: BackupSnapshotMetadata[],
): BackupSnapshotMetadata | undefined {
return snapshots
.filter((snapshot) => snapshot.valid)
.sort((left, right) => (right.mtimeMs ?? 0) - (left.mtimeMs ?? 0))[0];
.map((snapshot, index) => ({ snapshot, index }))
.filter(({ snapshot }) => snapshot.valid)
.sort(
(left, right) =>
(right.snapshot.mtimeMs ?? 0) - (left.snapshot.mtimeMs ?? 0) ||
left.index - right.index,
)[0]?.snapshot;
}

function buildMetadataSection(
Expand Down Expand Up @@ -1761,8 +1780,9 @@ async function loadAccountsFromJournal(

async function loadAccountsInternal(
persistMigration: ((storage: AccountStorageV3) => Promise<void>) | null,
storagePath = getStoragePath(),
): Promise<AccountStorageV3 | null> {
const path = getStoragePath();
const path = storagePath;
const resetMarkerPath = getIntentionalResetMarkerPath(path);
await cleanupStaleRotatingBackupArtifacts(path);
const migratedLegacyStorage = persistMigration
Expand Down Expand Up @@ -1926,8 +1946,11 @@ async function loadAccountsInternal(
}
}

async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
const path = getStoragePath();
async function saveAccountsUnlocked(
storage: AccountStorageV3,
storagePath = getStoragePath(),
): Promise<void> {
const path = storagePath;
const resetMarkerPath = getIntentionalResetMarkerPath(path);
const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
const tempPath = `${path}.${uniqueSuffix}.tmp`;
Expand Down Expand Up @@ -2078,18 +2101,25 @@ export async function withAccountStorageTransaction<T>(
return withStorageLock(async () => {
const storagePath = getStoragePath();
const state = {
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
storagePath,
snapshot: await loadAccountsInternal(
(storage) => saveAccountsUnlocked(storage, storagePath),
storagePath,
),
active: true,
storagePath,
};
const current = state.snapshot;
const persist = async (storage: AccountStorageV3): Promise<void> => {
await saveAccountsUnlocked(storage);
await saveAccountsUnlocked(storage, storagePath);
state.snapshot = storage;
};
return transactionSnapshotContext.run(state, () =>
handler(current, persist),
);
return transactionSnapshotContext.run(state, async () => {
try {
return await handler(current, persist);
} finally {
state.active = false;
}
});
});
}

Expand All @@ -2105,9 +2135,12 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
return withStorageLock(async () => {
const storagePath = getStoragePath();
const state = {
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
storagePath,
snapshot: await loadAccountsInternal(
(storage) => saveAccountsUnlocked(storage, storagePath),
storagePath,
),
active: true,
storagePath,
};
const current = state.snapshot;
const persist = async (
Expand All @@ -2116,13 +2149,13 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
): Promise<void> => {
const previousAccounts = cloneAccountStorageForPersistence(state.snapshot);
const nextAccounts = cloneAccountStorageForPersistence(accountStorage);
await saveAccountsUnlocked(nextAccounts);
await saveAccountsUnlocked(nextAccounts, storagePath);
try {
await saveFlaggedAccountsUnlocked(flaggedStorage);
state.snapshot = nextAccounts;
} catch (error) {
try {
await saveAccountsUnlocked(previousAccounts);
await saveAccountsUnlocked(previousAccounts, storagePath);
state.snapshot = previousAccounts;
} catch (rollbackError) {
const combinedError = new AggregateError(
Expand All @@ -2141,9 +2174,13 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
throw error;
}
};
return transactionSnapshotContext.run(state, () =>
handler(current, persist),
);
return transactionSnapshotContext.run(state, async () => {
try {
return await handler(current, persist);
} finally {
state.active = false;
}
});
});
}

Expand Down Expand Up @@ -2477,22 +2514,27 @@ export async function exportAccounts(
beforeCommit?: (resolvedPath: string) => Promise<void> | void,
): Promise<void> {
const resolvedPath = resolvePath(filePath);
const currentStoragePath = getStoragePath();
const activeStoragePath = getStoragePath();

if (!force && existsSync(resolvedPath)) {
throw new Error(`File already exists: ${resolvedPath}`);
}

const transactionState = transactionSnapshotContext.getStore();
const storage =
if (
transactionState?.active &&
transactionState.storagePath === currentStoragePath
? transactionState.snapshot
: transactionState?.active
? await loadAccountsInternal(saveAccountsUnlocked)
: await withAccountStorageTransaction((current) =>
Promise.resolve(current),
);
!areEquivalentStoragePaths(transactionState.storagePath, activeStoragePath)
) {
throw new Error(
`Export blocked by storage path mismatch: transaction path is ` +
`${transactionState.storagePath}, active path is ${activeStoragePath}`,
);
}
const storage = transactionState?.active
? transactionState.snapshot
: await withAccountStorageTransaction((current) =>
Promise.resolve(current),
);
if (!storage || storage.accounts.length === 0) {
throw new Error("No accounts to export");
}
Expand Down
Loading