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
6 changes: 5 additions & 1 deletion docs/reference/storage-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ When project-scoped behavior is enabled:

- `~/.codex/multi-auth/projects/<project-key>/openai-codex-accounts.json`

`<project-key>` is derived from normalized project path + short hash.
`<project-key>` is derived from project identity + short hash.

- Standard repositories: identity is the project root path.
- Linked Git worktrees: identity is the shared repository root, so all worktrees for the same repo share one account pool.
- Non-Git directories: identity falls back to the detected project path.

---

Expand Down
1 change: 1 addition & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ codex auth login
| Browser opens during login | Expected OAuth behavior | Complete auth and return to terminal |
| `codex auth` unrecognized | Wrapper command path conflict | Run `where codex`, then `codex multi auth status` |
| Switch says success but wrong account in Codex | Stale Codex auth state sync | Run `codex auth switch <index>`, restart `codex` session |
| Opening a PR worktree asks for login again | Worktree was using a different legacy path key | Run `codex auth list` once in the worktree to trigger migration into repo-shared storage |
| `missing field id_token` | Stale auth state payload | Re-login account with `codex auth login` |
| `refresh_token_reused` | Token pair already rotated | Re-login that account |
| `token_expired` | Token no longer valid | Re-login that account |
Expand Down
8 changes: 8 additions & 0 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ They are not canonical and should not be used for new setup.

See [reference/storage-paths.md](reference/storage-paths.md).

### Worktree Storage Migration

If you used `perProjectAccounts=true` before worktree identity sharing was added, older worktree-keyed account files are migrated automatically on first load:

- Legacy worktree storage is merged into the canonical repo-shared project file.
- Legacy files are removed only after a successful canonical write.
- If canonical persistence fails, legacy files are retained to avoid data loss.

---

## Common Upgrade Problems
Expand Down
157 changes: 126 additions & 31 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import { ACCOUNT_LIMITS } from "./constants.js";
import { createLogger } from "./logger.js";
import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js";
import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js";
import { getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, findProjectRoot, resolvePath } from "./storage/paths.js";
import {
getConfigDir,
getProjectConfigDir,
getProjectGlobalConfigDir,
findProjectRoot,
resolvePath,
resolveProjectStorageIdentityRoot,
} from "./storage/paths.js";
import {
migrateV1ToV3,
type CooldownReason,
Expand Down Expand Up @@ -138,6 +145,7 @@ async function ensureGitignore(storagePath: string): Promise<void> {

let currentStoragePath: string | null = null;
let currentLegacyProjectStoragePath: string | null = null;
let currentLegacyWorktreeStoragePath: string | null = null;
let currentProjectRoot: string | null = null;

export function setStorageBackupEnabled(enabled: boolean): void {
Expand Down Expand Up @@ -172,25 +180,35 @@ export function setStoragePath(projectPath: string | null): void {
if (!projectPath) {
currentStoragePath = null;
currentLegacyProjectStoragePath = null;
currentLegacyWorktreeStoragePath = null;
currentProjectRoot = null;
return;
}

const projectRoot = findProjectRoot(projectPath);
if (projectRoot) {
currentProjectRoot = projectRoot;
currentStoragePath = join(getProjectGlobalConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
const identityRoot = resolveProjectStorageIdentityRoot(projectRoot);
currentStoragePath = join(getProjectGlobalConfigDir(identityRoot), ACCOUNTS_FILE_NAME);
currentLegacyProjectStoragePath = join(getProjectConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
const previousWorktreeScopedPath = join(
getProjectGlobalConfigDir(projectRoot),
ACCOUNTS_FILE_NAME,
);
currentLegacyWorktreeStoragePath =
previousWorktreeScopedPath !== currentStoragePath ? previousWorktreeScopedPath : null;
} else {
currentStoragePath = null;
currentLegacyProjectStoragePath = null;
currentLegacyWorktreeStoragePath = null;
currentProjectRoot = null;
}
}

export function setStoragePathDirect(path: string | null): void {
currentStoragePath = path;
currentLegacyProjectStoragePath = null;
currentLegacyWorktreeStoragePath = null;
currentProjectRoot = null;
}

Expand All @@ -216,52 +234,127 @@ function getLegacyFlaggedAccountsPath(): string {
async function migrateLegacyProjectStorageIfNeeded(
persist: (storage: AccountStorageV3) => Promise<void> = saveAccounts,
): Promise<AccountStorageV3 | null> {
if (
!currentStoragePath ||
!currentLegacyProjectStoragePath ||
currentLegacyProjectStoragePath === currentStoragePath ||
!existsSync(currentLegacyProjectStoragePath)
) {
if (!currentStoragePath) {
return null;
}

try {
const legacyContent = await fs.readFile(currentLegacyProjectStoragePath, "utf-8");
const legacyData = JSON.parse(legacyContent) as unknown;
const normalized = normalizeAccountStorage(legacyData);
if (!normalized) return null;
const candidatePaths = [currentLegacyWorktreeStoragePath, currentLegacyProjectStoragePath]
.filter(
(path): path is string => typeof path === "string" && path.length > 0 && path !== currentStoragePath,
)
.filter((path, index, all) => all.indexOf(path) === index);

if (candidatePaths.length === 0) {
return null;
}

const existingCandidatePaths = candidatePaths.filter((legacyPath) => existsSync(legacyPath));
if (existingCandidatePaths.length === 0) {
return null;
}

let targetStorage = await loadNormalizedStorageFromPath(currentStoragePath, "current account storage");
let migrated = false;

for (const legacyPath of existingCandidatePaths) {
const legacyStorage = await loadNormalizedStorageFromPath(legacyPath, "legacy account storage");
if (!legacyStorage) {
continue;
}

const mergedStorage = mergeStorageForMigration(targetStorage, legacyStorage);
const fallbackStorage = targetStorage ?? legacyStorage;

try {
await persist(mergedStorage);
targetStorage = mergedStorage;
migrated = true;
} catch (error) {
targetStorage = fallbackStorage;
log.warn("Failed to persist migrated account storage", {
from: legacyPath,
to: currentStoragePath,
error: String(error),
});
continue;
}

await persist(normalized);
try {
await fs.unlink(currentLegacyProjectStoragePath);
log.info("Removed legacy project account storage file after migration", {
path: currentLegacyProjectStoragePath,
await fs.unlink(legacyPath);
log.info("Removed legacy account storage file after migration", {
path: legacyPath,
});
} catch (unlinkError) {
const code = (unlinkError as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.warn("Failed to remove legacy project account storage file after migration", {
path: currentLegacyProjectStoragePath,
log.warn("Failed to remove legacy account storage file after migration", {
path: legacyPath,
error: String(unlinkError),
});
}
}

log.info("Migrated legacy project account storage", {
from: currentLegacyProjectStoragePath,
from: legacyPath,
to: currentStoragePath,
accounts: normalized.accounts.length,
accounts: mergedStorage.accounts.length,
});
}

if (migrated) {
return targetStorage;
}
if (targetStorage && !existsSync(currentStoragePath)) {
return targetStorage;
}
return null;
}

async function loadNormalizedStorageFromPath(
path: string,
label: string,
): Promise<AccountStorageV3 | null> {
try {
const { normalized, schemaErrors } = await loadAccountsFromPath(path);
if (schemaErrors.length > 0) {
log.warn(`${label} schema validation warnings`, {
path,
errors: schemaErrors.slice(0, 5),
});
}
return normalized;
} catch (error) {
log.warn("Failed to migrate legacy project account storage", {
from: currentLegacyProjectStoragePath,
to: currentStoragePath,
error: String(error),
});
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.warn(`Failed to load ${label}`, {
path,
error: String(error),
});
}
return null;
}
}

function mergeStorageForMigration(
current: AccountStorageV3 | null,
incoming: AccountStorageV3,
): AccountStorageV3 {
if (!current) {
return incoming;
}

const merged = normalizeAccountStorage({
version: 3,
activeIndex: current.activeIndex,
activeIndexByFamily: current.activeIndexByFamily,
accounts: [...current.accounts, ...incoming.accounts],
});
if (!merged) {
return current;
}
return merged;
}

function selectNewestAccount<T extends AccountLike>(
current: T | undefined,
candidate: T,
Expand Down Expand Up @@ -573,8 +666,12 @@ async function loadAccountsFromJournal(path: string): Promise<AccountStorageV3 |
async function loadAccountsInternal(
persistMigration: ((storage: AccountStorageV3) => Promise<void>) | null,
): Promise<AccountStorageV3 | null> {
const path = getStoragePath();
const migratedLegacyStorage = persistMigration
? await migrateLegacyProjectStorageIfNeeded(persistMigration)
: null;

try {
const path = getStoragePath();
const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path);
if (schemaErrors.length > 0) {
log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) });
Expand All @@ -593,10 +690,8 @@ async function loadAccountsInternal(
return normalized;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
const path = getStoragePath();
if (code === "ENOENT" && persistMigration) {
const migrated = await migrateLegacyProjectStorageIfNeeded(persistMigration);
if (migrated) return migrated;
if (code === "ENOENT" && migratedLegacyStorage) {
return migratedLegacyStorage;
}

const recoveredFromWal = await loadAccountsFromJournal(path);
Expand Down
Loading