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
3 changes: 3 additions & 0 deletions lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DESTRUCTIVE_ACTION_COPY } from "./destructive-actions.js";
import type { AccountIdSource } from "./types.js";
import {
type AccountStatus,
type AuthMenuReadOnlyRow,
isTTY,
showAccountDetails,
showAuthMenu,
Expand Down Expand Up @@ -94,6 +95,7 @@ export interface ExistingAccountInfo {

export interface LoginMenuOptions {
flaggedCount?: number;
healthSummary?: AuthMenuReadOnlyRow;
statusMessage?: string | (() => string | undefined);
}

Expand Down Expand Up @@ -270,6 +272,7 @@ export async function promptLoginMode(
while (true) {
const action = await showAuthMenu(existingAccounts, {
flaggedCount: options.flaggedCount ?? 0,
healthSummary: options.healthSummary,
statusMessage: options.statusMessage,
});

Expand Down
324 changes: 323 additions & 1 deletion lib/codex-manager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { promises as fs, existsSync } from "node:fs";
Expand Down Expand Up @@ -90,6 +91,10 @@ import {
getCodexCliConfigPath,
loadCodexCliState,
} from "./codex-cli/state.js";
import {
getLastCodexCliSyncRun,
getLatestCodexCliSyncRollbackPlan,
} from "./codex-cli/sync.js";
import { setCodexCliActiveSelection } from "./codex-cli/writer.js";
import { ANSI } from "./ui/ansi.js";
import { confirm } from "./ui/confirm.js";
Expand Down Expand Up @@ -3350,6 +3355,17 @@ interface DoctorFixAction {
message: string;
}

interface DashboardHealthSummary {
label: string;
hint?: string;
}

interface DoctorReadOnlySummary {
severity: DoctorSeverity;
label: string;
hint: string;
}

function hasPlaceholderEmail(value: string | undefined): boolean {
if (!value) return false;
const email = value.trim().toLowerCase();
Expand Down Expand Up @@ -3392,6 +3408,14 @@ function getDoctorRefreshTokenKey(
return trimmed || undefined;
}

function getReadOnlyDoctorRefreshTokenFingerprint(
refreshToken: unknown,
): string | undefined {
const token = getDoctorRefreshTokenKey(refreshToken);
if (!token) return undefined;
return createHash("sha256").update(token).digest("hex").slice(0, 12);
}

function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } {
let changed = false;
const actions: DoctorFixAction[] = [];
Expand Down Expand Up @@ -3925,6 +3949,300 @@ async function runDoctor(args: string[]): Promise<number> {
return summary.error > 0 ? 1 : 0;
}

function formatCountNoun(
count: number,
singular: string,
plural = `${singular}s`,
): string {
return `${count} ${count === 1 ? singular : plural}`;
}

function summarizeLatestCodexCliSyncState(): {
label: string;
hint: string;
} {
const latestRun = getLastCodexCliSyncRun();
if (!latestRun) {
return {
label: "none",
hint: "No Codex CLI sync result recorded yet.",
};
}

const outcomeLabel =
latestRun.outcome === "changed"
? "changed"
: latestRun.outcome === "noop"
? "noop"
: latestRun.outcome === "disabled"
? "disabled"
: latestRun.outcome === "unavailable"
? "unavailable"
: "error";
const when = formatRelativeDateShort(latestRun.runAt);
const counts = [
latestRun.summary.addedAccountCount > 0
? `add ${latestRun.summary.addedAccountCount}`
: null,
latestRun.summary.updatedAccountCount > 0
? `update ${latestRun.summary.updatedAccountCount}`
: null,
latestRun.summary.destinationOnlyPreservedCount > 0
? `preserve ${latestRun.summary.destinationOnlyPreservedCount}`
: null,
latestRun.summary.selectionChanged ? "selection changed" : null,
].filter((value): value is string => value !== null);
const hintParts = [
`Latest sync ${outcomeLabel}${when ? ` ${when}` : ""}`,
counts.length > 0 ? counts.join(" | ") : "no account deltas recorded",
latestRun.message?.trim() || undefined,
].filter((value): value is string => Boolean(value));

return {
label: `${outcomeLabel}${when ? ` ${when}` : ""}`,
hint: hintParts.join(" | "),
};
}
Comment on lines +3960 to +4005
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# check if getLastCodexCliSyncRun does synchronous file I/O
ast-grep --pattern 'function getLastCodexCliSyncRun($$$) {
  $$$
}'

# also check for readFileSync or other sync patterns
rg -n 'getLastCodexCliSyncRun|readFileSync' --type=ts lib/codex-cli/sync.ts

Repository: ndycode/codex-multi-auth

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Get implementation of getLastCodexCliSyncRun
sed -n '320,360p' lib/codex-cli/sync.ts

# Check what data structure stores the sync run and how it's populated
rg -n 'latestCodexCliSyncRun|lastCodexCliSyncRun' lib/codex-cli/sync.ts -A 2 -B 2

# Look for readFileSync or other blocking file operations in the file
rg -n 'readFileSync|readSync|fs\.' lib/codex-cli/sync.ts

Repository: ndycode/codex-multi-auth

Length of output: 2919


🏁 Script executed:

#!/bin/bash
# Find readLatestSyncHistorySync implementation
rg -n 'readLatestSyncHistorySync' lib/codex-cli/sync.ts -A 15

# Also check where it's called from
rg -n 'readLatestSyncHistorySync' lib/

Repository: ndycode/codex-multi-auth

Length of output: 1497


🏁 Script executed:

#!/bin/bash
# Get implementation of readLatestSyncHistorySync
sed -n '259,290p' lib/sync-history.ts

# Also check for any readFileSync calls in sync-history.ts
rg -n 'readFileSync|readSync|fs\.read' lib/sync-history.ts

Repository: ndycode/codex-multi-auth

Length of output: 1473


🏁 Script executed:

#!/bin/bash
# Check for tests covering getLastCodexCliSyncRun
rg -n 'getLastCodexCliSyncRun' test/ 

# Check for tests of readLatestSyncHistorySync
rg -n 'readLatestSyncHistorySync' test/

# Check error handling for windows file scenarios
sed -n '259,280p' lib/sync-history.ts

# Look at the coding guidelines context - check for EBUSY handling in sync code
rg -n 'EBUSY|EAGAIN' lib/codex-cli/sync.ts lib/sync-history.ts

Repository: ndycode/codex-multi-auth

Length of output: 3215


🏁 Script executed:

#!/bin/bash
# Check if this function is new in the change or existing
git log --oneline -1 lib/codex-manager.ts

# Check the context around line 3960-4005 in git to see if this is new code
git show --stat lib/codex-manager.ts | head -20

# Look for any tests added for summarizeLatestCodexCliSyncState specifically
rg -n 'summarizeLatestCodexCliSyncState' test/

Repository: ndycode/codex-multi-auth

Length of output: 457


🏁 Script executed:

#!/bin/bash
# Check if summarizeLatestCodexCliSyncState has test coverage
rg -n 'summarizeLatestCodexCliSyncState' test/

# Check if the function is directly tested or only used in integration tests
rg -B5 -A10 'summarizeLatestCodexCliSyncState' lib/codex-manager.ts | head -40

# Look for windows-specific file locking tests in sync-history
rg -n 'windows|EBUSY' test/sync-history.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 808


add test coverage for summarizeLatestCodexCliSyncState and retry on file lock errors.

blocking file i/o confirmed: summarizeLatestCodexCliSyncState() calls getLastCodexCliSyncRun() which calls readLatestSyncHistorySync() at lib/sync-history.ts:261 using readFileSync(). in-memory cache at lib/codex-cli/sync.ts:153 prevents repeated blocks after first call, and fire-and-forget async load at lib/codex-cli/sync.ts:330 refills it. however, no regression tests exist for summarizeLatestCodexCliSyncState() itself, and readLatestSyncHistorySync() doesn't retry on EBUSY despite windows file lock risks—compare to RETRYABLE_REMOVE_CODES at lib/sync-history.ts:13. add vitest coverage for this function and implement EBUSY retry logic in readLatestSyncHistorySync().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 3960 - 4005, The test suite lacks coverage
for summarizeLatestCodexCliSyncState and the file-read path doesn't retry on
Windows EBUSY errors; add a Vitest unit test exercising
summarizeLatestCodexCliSyncState (covering null latest run, various outcome
labels, counts, and message trimming) and modify readLatestSyncHistorySync to
retry on EBUSY similar to the existing retryable logic (use the same
retry/backoff strategy and reuse the RETRYABLE_REMOVE_CODES pattern), ensuring
getLastCodexCliSyncRun/summarizeLatestCodexCliSyncState callpaths are exercised
and the in-memory cache behavior remains unchanged.


function summarizeReadOnlyDoctorState(
storage: AccountStorageV3 | null,
): DoctorReadOnlySummary {
if (!storage || storage.accounts.length === 0) {
return {
severity: "warn",
label: "setup pending",
hint: "No accounts are configured yet.",
};
}

const warnings: string[] = [];
const errors: string[] = [];
const rawActiveCandidate =
storage.activeIndexByFamily?.codex ?? storage.activeIndex;
const normalizedActiveCandidate = Number.isFinite(rawActiveCandidate)
? Math.trunc(rawActiveCandidate)
: Number.NaN;
const activeExists =
normalizedActiveCandidate >= 0 &&
normalizedActiveCandidate < storage.accounts.length;
if (!activeExists) {
errors.push("active index is out of range");
}

const disabledCount = storage.accounts.filter(
(account) => account.enabled === false,
).length;
if (disabledCount >= storage.accounts.length) {
errors.push("all accounts are disabled");
} else if (disabledCount > 0) {
warnings.push(`${disabledCount} disabled`);
}

const seenRefreshTokens = new Set<string>();
let duplicateTokenCount = 0;
let placeholderEmailCount = 0;
let likelyInvalidRefreshTokenCount = 0;
for (const account of storage.accounts) {
const token = getReadOnlyDoctorRefreshTokenFingerprint(
account.refreshToken,
);
if (token) {
if (seenRefreshTokens.has(token)) {
duplicateTokenCount += 1;
} else {
seenRefreshTokens.add(token);
}
}
if (hasPlaceholderEmail(account.email)) {
placeholderEmailCount += 1;
}
if (hasLikelyInvalidRefreshToken(account.refreshToken)) {
likelyInvalidRefreshTokenCount += 1;
}
}
if (duplicateTokenCount > 0) {
warnings.push(formatCountNoun(duplicateTokenCount, "duplicate token"));
}
if (placeholderEmailCount > 0) {
warnings.push(formatCountNoun(placeholderEmailCount, "placeholder email"));
}
if (likelyInvalidRefreshTokenCount > 0) {
warnings.push(
formatCountNoun(likelyInvalidRefreshTokenCount, "invalid refresh token"),
);
}

if (errors.length > 0) {
return {
severity: "error",
label: formatCountNoun(errors.length, "error"),
hint: errors.concat(warnings).join(" | "),
};
}
if (warnings.length > 0) {
return {
severity: "warn",
label: formatCountNoun(warnings.length, "warning"),
hint: warnings.join(" | "),
};
}
return {
severity: "ok",
label: "ok",
hint: "No read-only doctor issues detected.",
};
}

function logLoginMenuHealthSummaryWarning(
context: string,
error: unknown,
): void {
const errorLabel = getRedactedFilesystemErrorLabel(error);
log.warn(`${context} [${errorLabel}]`);
}

function formatLoginMenuRollbackHint(
rollbackPlan: Awaited<ReturnType<typeof getLatestCodexCliSyncRollbackPlan>>,
rollbackPlanLoadFailed = false,
): string {
if (rollbackPlanLoadFailed) {
return "rollback state unavailable";
}
if (rollbackPlan.status === "ready") {
const accountCount = rollbackPlan.accountCount;
if (
typeof accountCount === "number" &&
Number.isFinite(accountCount) &&
accountCount >= 0
) {
return `checkpoint ready for ${Math.trunc(accountCount)} account(s)`;
}
return "checkpoint ready";
}
const reason = collapseWhitespace(rollbackPlan.reason);
if (reason.toLowerCase().includes("unavailable")) {
return reason;
}
return rollbackPlan.snapshot ? "checkpoint unavailable" : "no rollback checkpoint available";
}

async function buildLoginMenuHealthSummary(
storage: AccountStorageV3,
): Promise<DashboardHealthSummary | null> {
if (storage.accounts.length === 0) {
return null;
}
const enabledCount = storage.accounts.filter(
(account) => account.enabled !== false,
).length;
const disabledCount = storage.accounts.length - enabledCount;
let actionableRestores: Awaited<
ReturnType<typeof getActionableNamedBackupRestores>
> = {
assessments: [],
allAssessments: [],
totalBackups: 0,
};
let rollbackPlan: Awaited<
ReturnType<typeof getLatestCodexCliSyncRollbackPlan>
> = {
status: "unavailable",
reason: "rollback state unavailable",
snapshot: null,
};
let rollbackPlanLoadFailed = false;
let restoreStateUnavailable = false;
let syncSummary = {
label: "unknown",
hint: "Sync state unavailable.",
};
let doctorSummary: DoctorReadOnlySummary = {
severity: "warn",
label: "unknown",
hint: "Doctor state unavailable.",
};
const [restoresResult, rollbackResult] = await Promise.allSettled([
getActionableNamedBackupRestores({
currentStorage: storage,
}),
getLatestCodexCliSyncRollbackPlan(),
]);
if (restoresResult.status === "fulfilled") {
actionableRestores = restoresResult.value;
} else {
restoreStateUnavailable = true;
logLoginMenuHealthSummaryWarning(
"Failed to load login menu restore health summary state",
restoresResult.reason,
);
}
if (rollbackResult.status === "fulfilled") {
rollbackPlan = rollbackResult.value;
} else {
rollbackPlanLoadFailed = true;
logLoginMenuHealthSummaryWarning(
"Failed to load login menu rollback health summary state",
rollbackResult.reason,
);
}
try {
syncSummary = summarizeLatestCodexCliSyncState();
} catch (error) {
logLoginMenuHealthSummaryWarning(
"Failed to summarize login menu sync state",
error,
);
}
try {
doctorSummary = summarizeReadOnlyDoctorState(storage);
} catch (error) {
logLoginMenuHealthSummaryWarning(
"Failed to summarize login menu doctor state",
error,
);
}
const rollbackHint = formatLoginMenuRollbackHint(
rollbackPlan,
rollbackPlanLoadFailed,
);
const rollbackUnavailable =
rollbackPlanLoadFailed ||
rollbackPlan.snapshot !== null ||
collapseWhitespace(rollbackPlan.reason).toLowerCase().includes("unavailable");
const restoreLabel =
restoreStateUnavailable
? "unavailable"
: actionableRestores.totalBackups > 0
? `${actionableRestores.assessments.length}/${actionableRestores.totalBackups} ready`
: "none";
const rollbackLabel =
rollbackPlan.status === "ready"
? "ready"
: rollbackUnavailable
? "unavailable"
: "none";
const accountLabel =
disabledCount > 0
? `${enabledCount}/${storage.accounts.length} enabled`
: `${storage.accounts.length} active`;
const hintParts = [
`Accounts: ${enabledCount} enabled / ${disabledCount} disabled / ${storage.accounts.length} total`,
`Sync: ${syncSummary.hint}`,
restoreStateUnavailable
? "Restore backups: state unavailable"
: `Restore backups: ${actionableRestores.assessments.length} actionable of ${actionableRestores.totalBackups} total`,
`Rollback: ${rollbackHint}`,
`Doctor: ${doctorSummary.hint}`,
];

return {
label:
`Pool ${accountLabel} | Sync ${syncSummary.label} | Restore ${restoreLabel} | ` +
`Rollback ${rollbackLabel} | Doctor ${doctorSummary.label}`,
hint: hintParts.join(" | "),
Comment on lines +4104 to +4242
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

don't collapse unreadable restore/rollback state into none.

getLatestCodexCliSyncRollbackPlan() already returns status: "unavailable" with a reason when rollback history cannot be read, but this path still renders rollback none. the restore catch path does the same thing by falling back to zero counts. on windows ebusy/eacces, the health row will tell users there is nothing to restore or roll back when the state is actually unknown. please carry an explicit unavailable state through the label and hint, and add a vitest regression for the unreadable-filesystem branch. lib/codex-manager.ts:4104-4215, lib/codex-cli/sync.ts:567-587. As per coding guidelines, focus on auth rotation, windows filesystem io, and concurrency.

possible fix
 function formatLoginMenuRollbackHint(
 	rollbackPlan: Awaited<ReturnType<typeof getLatestCodexCliSyncRollbackPlan>>,
 	rollbackPlanLoadFailed = false,
 ): string {
 	if (rollbackPlanLoadFailed) {
 		return "rollback state unavailable";
 	}
 	if (rollbackPlan.status === "ready") {
 		const accountCount = rollbackPlan.accountCount;
 		if (
 			typeof accountCount === "number" &&
 			Number.isFinite(accountCount) &&
 			accountCount >= 0
 		) {
 			return `checkpoint ready for ${Math.trunc(accountCount)} account(s)`;
 		}
 		return "checkpoint ready";
 	}
-	return rollbackPlan.snapshot ? "checkpoint unavailable" : "no rollback checkpoint available";
+	const reason = collapseWhitespace(rollbackPlan.reason);
+	if (reason.toLowerCase().includes("unavailable")) {
+		return reason;
+	}
+	return rollbackPlan.snapshot ? "checkpoint unavailable" : "no rollback checkpoint available";
 }
 
 async function buildLoginMenuHealthSummary(
 	storage: AccountStorageV3,
 ): Promise<DashboardHealthSummary | null> {
@@
 	let rollbackPlanLoadFailed = false;
+	let restoreStateUnavailable = false;
@@
 	try {
 		actionableRestores = await getActionableNamedBackupRestores({
 			currentStorage: storage,
 		});
 	} catch (error) {
+		restoreStateUnavailable = true;
 		logLoginMenuHealthSummaryWarning(
-			"Failed to load login menu restore health summary state",
+			"Failed to load login menu restore health summary state",
 			error,
 		);
 	}
@@
+	const rollbackHint = formatLoginMenuRollbackHint(
+		rollbackPlan,
+		rollbackPlanLoadFailed,
+	);
 	const restoreLabel =
-		actionableRestores.totalBackups > 0
+		restoreStateUnavailable
+			? "unavailable"
+			: actionableRestores.totalBackups > 0
 			? `${actionableRestores.assessments.length}/${actionableRestores.totalBackups} ready`
 			: "none";
-	const rollbackLabel = rollbackPlan.status === "ready" ? "ready" : "none";
+	const rollbackLabel =
+		rollbackPlan.status === "ready"
+			? "ready"
+			: rollbackHint.toLowerCase().includes("unavailable")
+				? "unavailable"
+				: "none";
@@
-		`Rollback: ${formatLoginMenuRollbackHint(rollbackPlan, rollbackPlanLoadFailed)}`,
+		`Rollback: ${rollbackHint}`,

};
}

async function handleManageAction(
storage: AccountStorageV3,
menuResult: Awaited<ReturnType<typeof promptLoginMode>>,
Expand Down Expand Up @@ -4061,12 +4379,16 @@ async function runAuthLogin(): Promise<number> {
});
}
}
const flaggedStorage = await loadFlaggedAccounts();
const [flaggedStorage, healthSummary] = await Promise.all([
loadFlaggedAccounts(),
buildLoginMenuHealthSummary(currentStorage),
]);

const menuResult = await promptLoginMode(
toExistingAccountInfo(currentStorage, quotaCache, displaySettings),
{
flaggedCount: flaggedStorage.accounts.length,
healthSummary: healthSummary ?? undefined,
statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined,
},
);
Expand Down
Loading