Skip to content

Commit 463984b

Browse files
committed
fix(auth): finish docs parity and isolate storage path state
1 parent 1c88c10 commit 463984b

6 files changed

Lines changed: 138 additions & 38 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,21 @@ codex auth fix --dry-run
109109
codex auth doctor --fix
110110
```
111111

112+
If the shell should not launch a browser, use the manual callback flow:
113+
114+
```bash
115+
codex auth login --manual
116+
CODEX_AUTH_NO_BROWSER=1 codex auth login
117+
```
118+
119+
In non-TTY/manual shells, provide the full redirect URL on stdin instead of waiting for a browser callback:
120+
121+
```bash
122+
echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual
123+
```
124+
125+
No new npm scripts or storage migration steps are required for this login-flow update.
126+
112127
---
113128

114129
## Command Toolkit
@@ -234,6 +249,7 @@ codex auth login
234249
- `codex auth` unrecognized: run `where codex`, then follow `docs/troubleshooting.md` for routing fallback commands
235250
- Switch succeeds but wrong account appears active: run `codex auth switch <index>`, then restart session
236251
- OAuth callback on port `1455` fails: free the port and re-run `codex auth login`
252+
- Browser launch is blocked or you are in a headless shell: re-run `codex auth login --manual` or set `CODEX_AUTH_NO_BROWSER=1`
237253
- `missing field id_token` / `token_expired` / `refresh_token_reused`: re-login affected account
238254

239255
</details>

docs/features.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ User-facing capability map for `codex-multi-auth`.
5454
| Quick switch and search hotkeys | Faster navigation in the dashboard |
5555
| Account action hotkeys | Per-account set, refresh, toggle, and delete shortcuts |
5656
| In-dashboard settings hub | Runtime and display tuning without editing files directly |
57-
| Browser-first OAuth with manual fallback | Works in normal and constrained terminal environments |
57+
| Browser-first OAuth with manual fallback | `codex auth login` stays browser-first, while `--manual`, `--no-browser`, and `CODEX_AUTH_NO_BROWSER=1` keep login usable in browser-restricted shells |
58+
59+
Manual/non-TTY login accepts the full callback URL on stdin, so automation and host-managed shells can complete auth without relying on a local browser handoff.
5860

5961
---
6062

docs/getting-started.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ codex auth list
5656
codex auth check
5757
```
5858

59+
If browser launch is blocked or you want to handle the callback manually:
60+
61+
```bash
62+
codex auth login --manual
63+
CODEX_AUTH_NO_BROWSER=1 codex auth login
64+
```
65+
66+
In non-TTY/manual shells, provide the full redirect URL on stdin:
67+
68+
```bash
69+
echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual
70+
```
71+
72+
`codex auth login` remains browser-first by default. No new npm scripts or storage migration steps are required for this auth-flow update.
73+
5974
---
6075

6176
## Add More Accounts
@@ -111,6 +126,7 @@ If the OAuth callback on port `1455` fails:
111126

112127
- stop the process using port `1455`
113128
- rerun `codex auth login`
129+
- if browser launch is unavailable, rerun `codex auth login --manual`
114130

115131
If account state looks stale:
116132

docs/reference/commands.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ Compatibility aliases are supported:
5656

5757
---
5858

59+
## Upgrade Notes
60+
61+
- `codex auth login` remains browser-first by default.
62+
- `codex auth login --manual` and `codex auth login --no-browser` force the manual callback flow instead of launching a browser.
63+
- `CODEX_AUTH_NO_BROWSER=1` suppresses browser launch for automation/headless sessions. False-like values such as `0` and `false` do not disable browser launch by themselves.
64+
- In non-TTY/manual shells, pass the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`.
65+
- No new npm scripts or storage migration steps were introduced for this auth-flow update.
66+
67+
---
68+
5969
## Compatibility and Non-TTY Behavior
6070

6171
- `codex` remains the primary wrapper entrypoint. It routes `codex auth ...` and the compatibility aliases to the multi-auth runtime, and forwards every other command to the official `@openai/codex` CLI.

docs/upgrade.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ codex auth forecast --live --model gpt-5-codex
4949

5050
---
5151

52+
## Login Flow Upgrade Notes
53+
54+
- `codex auth login` remains the default browser-first path.
55+
- `codex auth login --manual` and `codex auth login --no-browser` force manual callback handling for browser-restricted shells.
56+
- `CODEX_AUTH_NO_BROWSER=1` suppresses browser launch for automation/headless sessions. False-like values such as `0` and `false` no longer force manual mode.
57+
- In non-TTY/manual shells, provide the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`.
58+
- No new npm scripts, storage migrations, or extra upgrade steps were introduced for this auth-flow change.
59+
60+
For the full command/behavior reference, see [reference/commands.md](reference/commands.md).
61+
62+
---
63+
5264
## Configuration Upgrade Notes
5365

5466
During upgrades, runtime config source precedence is:

lib/storage.ts

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ export function formatStorageErrorHint(error: unknown, path: string): string {
167167
let storageMutex: Promise<void> = Promise.resolve();
168168
const transactionSnapshotContext = new AsyncLocalStorage<{
169169
snapshot: AccountStorageV3 | null;
170+
storagePath: string;
170171
active: boolean;
171172
}>();
172173

@@ -225,11 +226,12 @@ function looksLikeSyntheticFixtureStorage(
225226
}
226227

227228
async function ensureGitignore(storagePath: string): Promise<void> {
228-
if (!currentStoragePath) return;
229+
const state = getStoragePathState();
230+
if (!state.currentStoragePath) return;
229231

230232
const configDir = dirname(storagePath);
231233
const inferredProjectRoot = dirname(configDir);
232-
const candidateRoots = [currentProjectRoot, inferredProjectRoot].filter(
234+
const candidateRoots = [state.currentProjectRoot, inferredProjectRoot].filter(
233235
(root): root is string => typeof root === "string" && root.length > 0,
234236
);
235237
const projectRoot = candidateRoots.find((root) =>
@@ -262,10 +264,30 @@ async function ensureGitignore(storagePath: string): Promise<void> {
262264
}
263265
}
264266

265-
let currentStoragePath: string | null = null;
266-
let currentLegacyProjectStoragePath: string | null = null;
267-
let currentLegacyWorktreeStoragePath: string | null = null;
268-
let currentProjectRoot: string | null = null;
267+
type StoragePathState = {
268+
currentStoragePath: string | null;
269+
currentLegacyProjectStoragePath: string | null;
270+
currentLegacyWorktreeStoragePath: string | null;
271+
currentProjectRoot: string | null;
272+
};
273+
274+
let currentStorageState: StoragePathState = {
275+
currentStoragePath: null,
276+
currentLegacyProjectStoragePath: null,
277+
currentLegacyWorktreeStoragePath: null,
278+
currentProjectRoot: null,
279+
};
280+
281+
const storagePathStateContext = new AsyncLocalStorage<StoragePathState>();
282+
283+
function getStoragePathState(): StoragePathState {
284+
return storagePathStateContext.getStore() ?? currentStorageState;
285+
}
286+
287+
function setStoragePathState(state: StoragePathState): void {
288+
currentStorageState = state;
289+
storagePathStateContext.enterWith(state);
290+
}
269291

270292
export function setStorageBackupEnabled(enabled: boolean): void {
271293
storageBackupEnabled = enabled;
@@ -754,55 +776,67 @@ export function getLastAccountsSaveTimestamp(): number {
754776

755777
export function setStoragePath(projectPath: string | null): void {
756778
if (!projectPath) {
757-
currentStoragePath = null;
758-
currentLegacyProjectStoragePath = null;
759-
currentLegacyWorktreeStoragePath = null;
760-
currentProjectRoot = null;
779+
setStoragePathState({
780+
currentStoragePath: null,
781+
currentLegacyProjectStoragePath: null,
782+
currentLegacyWorktreeStoragePath: null,
783+
currentProjectRoot: null,
784+
});
761785
return;
762786
}
763787

764788
const projectRoot = findProjectRoot(projectPath);
765789
if (projectRoot) {
766-
currentProjectRoot = projectRoot;
767790
const identityRoot = resolveProjectStorageIdentityRoot(projectRoot);
768-
currentStoragePath = join(
791+
const currentStoragePath = join(
769792
getProjectGlobalConfigDir(identityRoot),
770793
ACCOUNTS_FILE_NAME,
771794
);
772-
currentLegacyProjectStoragePath = join(
795+
const currentLegacyProjectStoragePath = join(
773796
getProjectConfigDir(projectRoot),
774797
ACCOUNTS_FILE_NAME,
775798
);
776799
const previousWorktreeScopedPath = join(
777800
getProjectGlobalConfigDir(projectRoot),
778801
ACCOUNTS_FILE_NAME,
779802
);
780-
currentLegacyWorktreeStoragePath =
803+
const currentLegacyWorktreeStoragePath =
781804
previousWorktreeScopedPath !== currentStoragePath
782805
? previousWorktreeScopedPath
783806
: null;
807+
setStoragePathState({
808+
currentStoragePath,
809+
currentLegacyProjectStoragePath,
810+
currentLegacyWorktreeStoragePath,
811+
currentProjectRoot: projectRoot,
812+
});
784813
} else {
785-
currentStoragePath = null;
786-
currentLegacyProjectStoragePath = null;
787-
currentLegacyWorktreeStoragePath = null;
788-
currentProjectRoot = null;
814+
setStoragePathState({
815+
currentStoragePath: null,
816+
currentLegacyProjectStoragePath: null,
817+
currentLegacyWorktreeStoragePath: null,
818+
currentProjectRoot: null,
819+
});
789820
}
790821
}
791822

792823
export function setStoragePathDirect(path: string | null): void {
793-
currentStoragePath = path;
794-
currentLegacyProjectStoragePath = null;
795-
currentLegacyWorktreeStoragePath = null;
796-
currentProjectRoot = null;
824+
setStoragePathState({
825+
currentStoragePath: path,
826+
currentLegacyProjectStoragePath: null,
827+
currentLegacyWorktreeStoragePath: null,
828+
currentProjectRoot: null,
829+
});
797830
}
798831

799832
/**
800833
* Returns the file path for the account storage JSON file.
801834
* @returns Absolute path to the accounts.json file
802835
*/
803836
export function getStoragePath(): string {
804-
if (currentStoragePath) {
805-
return currentStoragePath;
837+
const state = getStoragePathState();
838+
if (state.currentStoragePath) {
839+
return state.currentStoragePath;
806840
}
807841
return join(getConfigDir(), ACCOUNTS_FILE_NAME);
808842
}
@@ -836,19 +870,20 @@ function getLegacyFlaggedAccountsPath(): string {
836870
async function migrateLegacyProjectStorageIfNeeded(
837871
persist: (storage: AccountStorageV3) => Promise<void> = saveAccounts,
838872
): Promise<AccountStorageV3 | null> {
839-
if (!currentStoragePath) {
873+
const state = getStoragePathState();
874+
if (!state.currentStoragePath) {
840875
return null;
841876
}
842877

843878
const candidatePaths = [
844-
currentLegacyWorktreeStoragePath,
845-
currentLegacyProjectStoragePath,
879+
state.currentLegacyWorktreeStoragePath,
880+
state.currentLegacyProjectStoragePath,
846881
]
847882
.filter(
848883
(path): path is string =>
849884
typeof path === "string" &&
850885
path.length > 0 &&
851-
path !== currentStoragePath,
886+
path !== state.currentStoragePath,
852887
)
853888
.filter((path, index, all) => all.indexOf(path) === index);
854889

@@ -864,7 +899,7 @@ async function migrateLegacyProjectStorageIfNeeded(
864899
}
865900

866901
let targetStorage = await loadNormalizedStorageFromPath(
867-
currentStoragePath,
902+
state.currentStoragePath,
868903
"current account storage",
869904
);
870905
let migrated = false;
@@ -892,7 +927,7 @@ async function migrateLegacyProjectStorageIfNeeded(
892927
targetStorage = fallbackStorage;
893928
log.warn("Failed to persist migrated account storage", {
894929
from: legacyPath,
895-
to: currentStoragePath,
930+
to: state.currentStoragePath,
896931
error: String(error),
897932
});
898933
continue;
@@ -918,15 +953,15 @@ async function migrateLegacyProjectStorageIfNeeded(
918953

919954
log.info("Migrated legacy project account storage", {
920955
from: legacyPath,
921-
to: currentStoragePath,
956+
to: state.currentStoragePath,
922957
accounts: mergedStorage.accounts.length,
923958
});
924959
}
925960

926961
if (migrated) {
927962
return targetStorage;
928963
}
929-
if (targetStorage && !existsSync(currentStoragePath)) {
964+
if (targetStorage && !existsSync(state.currentStoragePath)) {
930965
return targetStorage;
931966
}
932967
return null;
@@ -1912,8 +1947,10 @@ export async function withAccountStorageTransaction<T>(
19121947
) => Promise<T>,
19131948
): Promise<T> {
19141949
return withStorageLock(async () => {
1950+
const storagePath = getStoragePath();
19151951
const state = {
19161952
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
1953+
storagePath,
19171954
active: true,
19181955
};
19191956
const current = state.snapshot;
@@ -1937,8 +1974,10 @@ export async function withAccountAndFlaggedStorageTransaction<T>(
19371974
) => Promise<T>,
19381975
): Promise<T> {
19391976
return withStorageLock(async () => {
1977+
const storagePath = getStoragePath();
19401978
const state = {
19411979
snapshot: await loadAccountsInternal(saveAccountsUnlocked),
1980+
storagePath,
19421981
active: true,
19431982
};
19441983
const current = state.snapshot;
@@ -2308,17 +2347,22 @@ export async function exportAccounts(
23082347
beforeCommit?: (resolvedPath: string) => Promise<void> | void,
23092348
): Promise<void> {
23102349
const resolvedPath = resolvePath(filePath);
2350+
const currentStoragePath = getStoragePath();
23112351

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

23162356
const transactionState = transactionSnapshotContext.getStore();
2317-
const storage = transactionState?.active
2318-
? transactionState.snapshot
2319-
: await withAccountStorageTransaction((current) =>
2320-
Promise.resolve(current),
2321-
);
2357+
const storage =
2358+
transactionState?.active &&
2359+
transactionState.storagePath === currentStoragePath
2360+
? transactionState.snapshot
2361+
: transactionState?.active
2362+
? await loadAccountsInternal(saveAccountsUnlocked)
2363+
: await withAccountStorageTransaction((current) =>
2364+
Promise.resolve(current),
2365+
);
23222366
if (!storage || storage.accounts.length === 0) {
23232367
throw new Error("No accounts to export");
23242368
}

0 commit comments

Comments
 (0)