Skip to content
Open
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: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,9 @@ codex auth doctor --json

## Release Notes

- Current stable: [docs/releases/v1.2.0.md](docs/releases/v1.2.0.md)
- Previous stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md)
- Earlier stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md)
- Current stable: [docs/releases/v1.1.10.md](docs/releases/v1.1.10.md)
- Previous stable: [docs/releases/v0.1.9.md](docs/releases/v0.1.9.md)
- Earlier stable: [docs/releases/v0.1.8.md](docs/releases/v0.1.8.md)
- Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md)

## License
Expand Down
8 changes: 4 additions & 4 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ Public documentation for `codex-multi-auth`.
| [troubleshooting.md](troubleshooting.md) | Recovery playbooks for install, login, switching, and stale state |
| [privacy.md](privacy.md) | Data handling and local storage behavior |
| [upgrade.md](upgrade.md) | Migration from legacy package and path history |
| [releases/v1.2.0.md](releases/v1.2.0.md) | Stable release notes |
| [releases/v1.1.10.md](releases/v1.1.10.md) | Previous stable release notes |
| [releases/v0.1.9.md](releases/v0.1.9.md) | Earlier stable release notes |
| [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes |
| [releases/v0.1.9.md](releases/v0.1.9.md) | Previous stable release notes |
| [releases/v0.1.8.md](releases/v0.1.8.md) | Earlier stable release notes |
| [releases/v0.1.7.md](releases/v0.1.7.md) | Archived stable release notes |
| [releases/v0.1.6.md](releases/v0.1.6.md) | Archived stable release notes |
| [releases/v0.1.5.md](releases/v0.1.5.md) | Archived stable release notes |
Expand All @@ -45,7 +45,7 @@ Public documentation for `codex-multi-auth`.
| [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths |
| [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract |
| [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics |
| [releases/v1.2.0.md](releases/v1.2.0.md) | Current stable release notes |
| [releases/v1.1.10.md](releases/v1.1.10.md) | Current stable release notes |
| [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference |
| [User Guides release notes](#user-guides) | Stable, previous, and archived release notes |
| [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history |
Expand Down
68 changes: 64 additions & 4 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1926,6 +1926,68 @@ async function loadAccountsInternal(
}
}

async function loadAccountsForExport(): Promise<AccountStorageV3 | null> {
const path = getStoragePath();
const resetMarkerPath = getIntentionalResetMarkerPath(path);
await cleanupStaleRotatingBackupArtifacts(path);
const migratedLegacyStorage =
await migrateLegacyProjectStorageIfNeeded(saveAccountsUnlocked);

if (existsSync(resetMarkerPath)) {
return createEmptyStorageWithMetadata(false, "intentional-reset");
}
if (!existsSync(path)) {
if (migratedLegacyStorage) {
return migratedLegacyStorage;
}
const recoveredFromWal = await loadAccountsFromJournal(path);
if (recoveredFromWal) {
return recoveredFromWal;
}
return createEmptyStorageWithMetadata(true, "missing-storage");
}

try {
const { normalized, storedVersion, schemaErrors } =
await loadAccountsFromPath(path);
if (schemaErrors.length > 0) {
log.warn("Account storage schema validation warnings", {
errors: schemaErrors.slice(0, 5),
});
}
if (normalized && storedVersion !== normalized.version) {
log.info("Migrating account storage to v3", {
from: storedVersion,
to: normalized.version,
});
try {
await saveAccountsUnlocked(normalized);
} catch (saveError) {
log.warn("Failed to persist migrated storage", {
error: String(saveError),
});
}
}
if (existsSync(resetMarkerPath)) {
return createEmptyStorageWithMetadata(false, "intentional-reset");
}
return normalized;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (existsSync(resetMarkerPath)) {
return createEmptyStorageWithMetadata(false, "intentional-reset");
}
const recoveredFromWal = await loadAccountsFromJournal(path);
if (recoveredFromWal) {
return recoveredFromWal;
}
if (code === "ENOENT") {
return migratedLegacyStorage;
}
throw error;
}
}

async function saveAccountsUnlocked(storage: AccountStorageV3): Promise<void> {
const path = getStoragePath();
const resetMarkerPath = getIntentionalResetMarkerPath(path);
Expand Down Expand Up @@ -2489,10 +2551,8 @@ export async function exportAccounts(
transactionState.storagePath === currentStoragePath
? transactionState.snapshot
: transactionState?.active
? await loadAccountsInternal(saveAccountsUnlocked)
: await withAccountStorageTransaction((current) =>
Promise.resolve(current),
);
? await loadAccountsForExport()
: await withStorageLock(loadAccountsForExport);
if (!storage || storage.accounts.length === 0) {
throw new Error("No accounts to export");
}
Expand Down
162 changes: 161 additions & 1 deletion test/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { existsSync, promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
Expand Down Expand Up @@ -949,13 +950,172 @@ describe("storage", () => {
});

it("should fail export when no accounts exist", async () => {
const { exportAccounts } = await import("../lib/storage.js");
setStoragePathDirect(testStoragePath);
await expect(exportAccounts(exportPath)).rejects.toThrow(
/No accounts to export/,
);
});

it("should fail export when only backup storage exists", async () => {
const backupPath = `${testStoragePath}.bak`;
await fs.writeFile(
backupPath,
JSON.stringify({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "backup-only",
refreshToken: "backup-refresh",
addedAt: 1,
lastUsed: 1,
},
],
}),
"utf-8",
);

setStoragePathDirect(testStoragePath);
await expect(exportAccounts(exportPath)).rejects.toThrow(
/No accounts to export/,
);
expect(existsSync(exportPath)).toBe(false);
});

it("should export from WAL recovery when primary storage is missing", async () => {
const walPath = `${testStoragePath}.wal`;
const storage = {
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "wal-only",
refreshToken: "wal-refresh",
addedAt: 1,
lastUsed: 1,
},
],
};
const content = JSON.stringify(storage, null, 2);
const checksum = createHash("sha256").update(content).digest("hex");
await fs.writeFile(
walPath,
JSON.stringify({
version: 1,
createdAt: 1,
path: testStoragePath,
checksum,
content,
}),
"utf-8",
);

await exportAccounts(exportPath);

const exported = JSON.parse(await fs.readFile(exportPath, "utf-8")) as {
accounts: Array<{ accountId: string; refreshToken: string }>;
};
expect(exported.accounts).toEqual([
expect.objectContaining({
accountId: "wal-only",
refreshToken: "wal-refresh",
}),
]);
});

it("should export from migrated legacy project storage when primary storage is missing", async () => {
const fakeHome = join(testWorkDir, "legacy-home");
const projectDir = join(testWorkDir, "legacy-project");
const legacyProjectConfigDir = join(projectDir, ".codex");
const legacyStoragePath = join(
legacyProjectConfigDir,
"openai-codex-accounts.json",
);
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
try {
await fs.mkdir(fakeHome, { recursive: true });
await fs.mkdir(join(projectDir, ".git"), { recursive: true });
await fs.mkdir(legacyProjectConfigDir, { recursive: true });
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;
setStoragePath(projectDir);
await fs.writeFile(
legacyStoragePath,
JSON.stringify({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "legacy-only",
refreshToken: "legacy-refresh",
addedAt: 1,
lastUsed: 1,
},
],
}),
"utf-8",
);

await exportAccounts(exportPath);

const exported = JSON.parse(await fs.readFile(exportPath, "utf-8")) as {
accounts: Array<{ accountId: string; refreshToken: string }>;
};
expect(exported.accounts).toEqual([
expect.objectContaining({
accountId: "legacy-only",
refreshToken: "legacy-refresh",
}),
]);
expect(existsSync(legacyStoragePath)).toBe(false);
expect(existsSync(getStoragePath())).toBe(true);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
setStoragePathDirect(testStoragePath);
}
});

it("should fail export from backup-only storage on a different path during an active transaction", async () => {
const alternateStoragePath = join(
testWorkDir,
`accounts-alt-${Math.random().toString(36).slice(2)}.json`,
);
const alternateBackupPath = `${alternateStoragePath}.bak`;
await fs.writeFile(
alternateBackupPath,
JSON.stringify({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "backup-only",
refreshToken: "backup-refresh",
addedAt: 1,
lastUsed: 1,
},
],
}),
"utf-8",
);

await withAccountStorageTransaction(async () => {
setStoragePathDirect(alternateStoragePath);
try {
await expect(exportAccounts(exportPath)).rejects.toThrow(
/No accounts to export/,
);
} finally {
setStoragePathDirect(testStoragePath);
}
});

expect(existsSync(exportPath)).toBe(false);
});

it("should fail import when file does not exist", async () => {
const { importAccounts } = await import("../lib/storage.js");
const nonexistentPath = join(testWorkDir, "nonexistent-file.json");
Expand Down