diff --git a/devlog/_plan/260920_round2_followups/030_lane_r3.md b/devlog/_plan/260920_round2_followups/030_lane_r3.md new file mode 100644 index 00000000000..d38a851a40f --- /dev/null +++ b/devlog/_plan/260920_round2_followups/030_lane_r3.md @@ -0,0 +1,90 @@ +# R3 — the roster and login remainders + +Status: implemented, awaiting review. Scope was #5292 and the two #5261 remainders. + +## #5292 was already closed before this lane opened + +The plan's table says `gui/src/pages/Logs.tsx` restates the recovery-kind union with nine of +thirteen members. That was true when the table was written and stopped being true two hours +earlier: `555f0cacdf` (#5300, 18:41) replaced the copy with the durable roster, and the plan +commit landed at 20:48 from a snapshot taken before it. + +Current `dev` already has all of it. `Logs.tsx` imports `AttemptRecoveryKind` from +`src/usage/telemetry-contract.ts` and its label map closes with +`satisfies Record`, so a fourteenth kind is a typecheck failure +there rather than an "Unknown recovery reason". All ten catalogs carry all thirteen labels plus +the fallback, and `tests/usage/request-outcome-agreement.test.ts` holds both: the label map has +to cover every member of `ATTEMPT_RECOVERY_KIND_ROSTER`, and every key it names has to exist in +every catalog. Verified by reading the tree, not by rerunning the suite. + +Nothing was changed for it. The row is stale, not open. + +## #5261, remainder one: the two CLI logins that discarded the launch + +`src/oauth/login-cli.ts` called `void openUrl(...)` in both `handleOAuthLogin` and +`handleKeyLogin`. Each printed a URL, said it was opening a browser, and asked a question that +assumes it opened — indistinguishable from a login that is working. + +The part that made this more than a missing `console.warn`: `OAuthController.onAuth` returns +`void` and every one of the thirteen provider call sites invokes it as `ctrl.onAuth?.(...)` and +moves on. The launcher's answer therefore arrives after the flow has continued, and on a +callback-server provider `#waitForCallback` has already called `onManualCodeInput` by then. A +warning written at that moment lands on the line the user is typing on. + +Making `onAuth` awaitable would mean changing the controller contract and all thirteen call +sites, which is a much larger change than the defect deserves. Instead the launch reports itself +when it settles, and the two things that could collide with it wait on that report: the +manual-code prompt awaits it before asking, and the key login awaits it before it constructs a +reader at all. A polling provider that never prompts is still told before the login claims to +have worked. + +`BROWSER_LAUNCH_FAILED_HINT` in `src/cli/account-auth.ts` kept its ChatGPT-specific second line +and now derives its first from `BROWSER_LAUNCH_FAILED_NOTICE`, so the sentence has one home +across all three logins. + +The handlers took an optional deps object. The contract worth holding is an order, and an order +is only observable from something that records both events; spawning a launcher and attaching to +stdin to find that out would test the operating system. Production passes none of them. + +## #5261, remainder two: the roster that kept last-good rows silently + +`useCodexAccountPool` kept its rows after a failed read and also kept reporting `ready`. Keeping +the rows is right — blanking a populated pool because one 30s poll missed is its own defect — but +the surface then could not tell a list the server had just confirmed from one that predated a +failure. The reported shape: add an account, the read that would bring it over fails, and the +older accounts are on screen with the new one absent. + +`refreshFailed` sits beside `loadState` rather than inside it, for the same reason `refreshing` +already does. `loadState` answers what the surface can draw and a warm failure does not change +that answer; folding it in would mean either flashing the cold skeleton over good data or saying +nothing. A cold failure still replaces the surface with the error it already had, and the banner +only renders when rows survived, so an empty cold failure is never annotated instead of explained. + +## Verification + +Static review and hosted CI at the exact head. The lane ran no local suite, no individual test, +no typecheck, no build, no install, no `ocx`, and changed no credential or configuration — +recorded as NOT RUN. + +Checked by reading rather than running, because the ratchets are what a merge breaks: + +- No file this lane touches appears in `tests/fixtures/file-size-baseline.json`. The ten i18n + catalogs are in its `exempt` list. +- `tests/oauth/oauth-login-cli-browser-launch.test.ts` is registered in both + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. The gui + suite has no layout guard. +- The one new i18n key is in all ten catalogs, which `gui/tests/locale-parity.test.ts` and + `gui/tests/claude-desktop-locale.test.ts` both require. +- `CodexAccountLoadState` gained no member. `CodexAccountPoolController` gained one, and the + source-oracle roster in `gui/tests/codex-account-pool-controller.test.ts` names it. +- `CodexAccountPoolLoadStates` stopped restating the load-state union and derives it. + +## The GUI screenshot gate + +`enforce-target` requires a screenshot for a PR that touches `gui`. Producing one needs +`bun run build:gui` and a running proxy, both of which this lane is forbidden to do, so the pull +request says so and offers what can be checked instead: the rendered markup is asserted against a +mounted DOM in `gui/tests/codex-account-pool-stale-refresh.test.tsx` — the banner appears with +surviving rows, carries the catalog string, does not appear on a successful refresh, and does not +replace the cold error — and the new class reuses the existing `.pwi-auth-state` block with the +`--amber` pair already used elsewhere in the theme. diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index e9be8f96970..20e5532d1da 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -72,7 +72,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; + const { accounts, activeId, loadState, refreshFailed, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; // #3898: the native-main device reauth drives the dedicated namespace; a // completed flow refreshes the account list so the card leaves reauth state. const mainReauth = useMainDeviceReauth(apiBase, () => { void load(); }); @@ -476,6 +476,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban { void load(); }} /> diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 25af1eb49f9..051424184f5 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -3,7 +3,7 @@ import { IconLock, IconPause, IconPlay, IconPlus, IconRefresh, IconTicket } from import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl"; import QuotaBars from "./QuotaBars"; import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers"; -import type { CodexAccountEntry } from "./codex-account-pool-types"; +import type { CodexAccountEntry, CodexAccountLoadState } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; import type { MainDeviceReauthState } from "./use-main-device-reauth"; @@ -359,11 +359,13 @@ export function CodexAccountPoolActions(props: { export function CodexAccountPoolLoadStates({ t, loadState, + refreshFailed, accountsCount, onRetry, }: { t: TFn; - loadState: "loading" | "ready" | "error"; + loadState: CodexAccountLoadState; + refreshFailed: boolean; accountsCount: number; onRetry: () => void; }): ReactNode { @@ -419,5 +421,16 @@ export function CodexAccountPoolLoadStates({ ); } + // Rows survived a failed refresh, so they are still worth showing — but they are the ones from + // before it, and an account added since is simply not among them. A status rather than an alert: + // nothing on screen is wrong, it is just older than it looks. + if (refreshFailed && accountsCount > 0) { + return ( +
+ {t("codexAuth.accountsRefreshFailed")} + +
+ ); + } return null; } diff --git a/gui/src/components/codex-account-pool-types.ts b/gui/src/components/codex-account-pool-types.ts index 679350fa23f..0e16acca880 100644 --- a/gui/src/components/codex-account-pool-types.ts +++ b/gui/src/components/codex-account-pool-types.ts @@ -1 +1 @@ -export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; +export type { CodexAccountEntry, CodexAccountLoadState } from "../hooks/useCodexAccountPool"; diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index cb5fce92968..fe4cdaa155b 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -97,6 +97,11 @@ export interface CodexAccountPoolController { * `ready` during a refresh so rows survive; this is what makes that wait visible. */ refreshing: boolean; + /** + * The most recent account read failed. The rows it could not replace are still on screen, so + * this is the only thing that tells a surface they are no longer known to be current. + */ + refreshFailed: boolean; /** True until the first load attempt settles, whether it succeeds or fails. */ initialLoading: boolean; switchingId: string | null; @@ -160,6 +165,13 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou ); const [activeId, setActiveId] = useState(() => seed?.activeId ?? null); const [loadState, setLoadState] = useState(() => (seed != null ? "ready" : "loading")); + // Deliberately beside `loadState` rather than inside it. `loadState` answers what the surface + // can draw, and a warm refresh failure keeps the rows drawable — folding the failure in would + // mean either flashing the cold skeleton over good data or, as before, saying nothing at all. + // Saying nothing is the defect: the rows on screen are the ones from before the refresh, so an + // account the user has just added is simply absent while the older ones look current (#5261). + // `refreshing` already set the precedent that a fact about the read lives next to loadState. + const [refreshFailed, setRefreshFailed] = useState(false); const [switchingId, setSwitchingId] = useState(null); const [pauseUpdatingId, setPauseUpdatingId] = useState(null); const [priorityUpdatingId, setPriorityUpdatingId] = useState(null); @@ -277,6 +289,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou hasLoadedRef.current = true; // Progressive: paint account/quota boxes as soon as /accounts returns. setLoadState("ready"); + // Cleared here rather than at the settle below, because the rows it qualifies are + // painted here. Waiting for /active to finish would leave the just-replaced rows + // labelled as pre-refresh ones for as long as that read's budget allows. + setRefreshFailed(false); } return true; } catch { @@ -329,9 +345,11 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou }); return activeOk; } - // Cold failure only: after a successful load (including empty), keep rows and stay ready - // so a soft poll miss does not flash the skeleton / wipe the pool. + // A cold failure has nothing to show, so it replaces the surface. A warm one keeps its rows + // — flashing the skeleton on a soft poll miss is its own defect — and says so instead of + // continuing to present them as current. if (!hasLoadedRef.current) setLoadState("error"); + setRefreshFailed(true); return false; } finally { bounded.clear(); @@ -623,6 +641,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou activeId, loadState, refreshing: inflightCount > 0, + refreshFailed, initialLoading: !firstAttemptSettled, switchingId, pauseUpdatingId, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a46c0f89fa0..baf6776b8ee 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1670,6 +1670,7 @@ export const de: Record = { "accountPool.priorityUpdateFailed": "Die Auswahlreihenfolge für {email} konnte nicht gespeichert werden. Der zuletzt bestätigte Wert wird angezeigt.", "codexAuth.switched": "{email} ist für die nächste Anfrage ausgewählt", "codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.", + "codexAuth.accountsRefreshFailed": "Die letzte Kontoaktualisierung ist fehlgeschlagen. Unten stehen die zuletzt bestätigten Konten.", "codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.", "codexAuth.removeConfirm": "{id} entfernen?", "codexAuth.removeFailed": "Das Konto konnte nicht entfernt werden. Es wurde nichts geändert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 061019df6c3..0a67cb6b217 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2264,6 +2264,7 @@ export const en = { "codexAuth.switched": "{email} is selected for the next request", "codexAuth.loadFailed": "Codex account settings could not be loaded.", + "codexAuth.accountsRefreshFailed": "The latest account refresh failed. The accounts below are the last ones confirmed.", "codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.", "codexAuth.removeConfirm": "Remove {id}?", "codexAuth.removeFailed": "The account could not be removed. Nothing was changed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b2e00370208..8e705317f8e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2186,6 +2186,7 @@ export const fr: Record = { "accountPool.priorityUpdateFailed": "Impossible d’enregistrer l’ordre de sélection de {email}. La dernière valeur confirmée est affichée.", "codexAuth.switched": "{email} est sélectionné pour la prochaine requête", "codexAuth.loadFailed": "Impossible de charger les paramètres des comptes Codex.", + "codexAuth.accountsRefreshFailed": "La dernière actualisation des comptes a échoué. Les comptes ci-dessous sont les derniers confirmés.", "codexAuth.switchFailed": "Impossible de changer de compte. Votre sélection précédente reste inchangée.", "codexAuth.removeConfirm": "Supprimer {id} ?", "codexAuth.removeFailed": "Impossible de supprimer le compte. Aucune modification apportée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7ab8054d2ed..29dd05ef173 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2114,6 +2114,7 @@ export const ja: Record = { "accountPool.priorityUpdateFailed": "{email} の選択順序を保存できませんでした。最後に確認された値を表示しています。", "codexAuth.switched": "次のリクエストでは {email} を使用します", "codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。", + "codexAuth.accountsRefreshFailed": "最新のアカウント更新に失敗しました。以下は最後に確認されたアカウントです。", "codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。", "codexAuth.removeConfirm": "{id} を削除しますか?", "codexAuth.removeFailed": "アカウントを削除できませんでした。何も変更されていません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 31d5c4af19c..d965ca260db 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1706,6 +1706,7 @@ export const ko: Record = { "accountPool.priorityUpdateFailed": "{email}의 선택 순서를 저장하지 못했습니다. 마지막으로 확인된 값을 표시합니다.", "codexAuth.switched": "다음 요청에 {email}을(를) 사용합니다", "codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.", + "codexAuth.accountsRefreshFailed": "최신 계정 새로고침에 실패했습니다. 아래 목록은 마지막으로 확인된 계정입니다.", "codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.", "codexAuth.removeConfirm": "{id}을(를) 삭제하시겠습니까?", "codexAuth.removeFailed": "계정을 제거하지 못했습니다. 변경된 내용은 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9a0f4fc0920..63b27cc5eff 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2185,6 +2185,7 @@ export const ru: Record = { "accountPool.priorityUpdateFailed": "Не удалось сохранить порядок выбора для {email}. Показано последнее подтверждённое значение.", "codexAuth.switched": "{email} выбран для следующего запроса", "codexAuth.loadFailed": "Не удалось загрузить настройки аккаунтов Codex.", + "codexAuth.accountsRefreshFailed": "Последнее обновление аккаунтов не удалось. Ниже показаны последние подтверждённые аккаунты.", "codexAuth.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.", "codexAuth.removeConfirm": "Удалить {id}?", "codexAuth.removeFailed": "Не удалось удалить аккаунт. Ничего не изменено.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f8a086ac3ab..2fb46067b86 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2205,6 +2205,7 @@ export const tr: Record = { "codexAuth.switched": "Sonraki istek için {email} seçildi", "codexAuth.loadFailed": "Codex hesap ayarları yüklenemedi.", + "codexAuth.accountsRefreshFailed": "Son hesap yenilemesi başarısız oldu. Aşağıda son doğrulanan hesaplar gösteriliyor.", "codexAuth.switchFailed": "Hesap değiştirilemedi.", "codexAuth.removeConfirm": "{id} kaldırılsın mı?", "codexAuth.removeFailed": "Hesap kaldırılamadı.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index f9eabd994c7..f14875f0824 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2202,6 +2202,7 @@ export const vi: Record = { "accountPool.priorityUpdateFailed": "Không thể lưu thứ tự lựa chọn cho {email}. Giá trị được xác nhận gần nhất đang được hiển thị.", "codexAuth.switched": "Tài khoản được chọn cho request tiếp theo là {email}", "codexAuth.loadFailed": "Không thể tải cài đặt tài khoản Codex.", + "codexAuth.accountsRefreshFailed": "Lần làm mới tài khoản gần nhất thất bại. Dưới đây là các tài khoản được xác nhận gần nhất.", "codexAuth.switchExceedsThresholdWarning": "Mức sử dụng của tài khoản này đã đạt hoặc vượt ngưỡng chuyển đổi ({threshold}%). Lựa chọn đã ghim sẽ được giải phóng nếu không còn hạn ngạch khả dụng.", "codexAuth.switchFailed": "Không thể chuyển đổi tài khoản. Tùy chọn trước đó của bạn vẫn được giữ nguyên.", "codexAuth.removeConfirm": "Gỡ bỏ {id}?", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 25abb6135b4..c95bb6765e2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1669,6 +1669,7 @@ export const zhTW: Record = { "accountPool.quotaWindowInert": "只有配額策略,或門檻大於 0 的填滿優先策略,才會依用量計分;在目前的輪換策略下,這項設定不會有任何作用。", "codexAuth.switched": "下一次請求將使用 {email}", "codexAuth.loadFailed": "無法載入 Codex 帳號設定。", + "codexAuth.accountsRefreshFailed": "最近一次帳號重新整理失敗。以下是最後一次確認的帳號。", "codexAuth.switchFailed": "無法切換帳號。之前的選擇保持不變。", "codexAuth.removeConfirm": "刪除 {id}?", "codexAuth.removeFailed": "無法移除帳號。未進行任何更改。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cf4b6e94055..9e41810935c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1687,6 +1687,7 @@ export const zh: Record = { "accountPool.priorityUpdateFailed": "无法保存 {email} 的选择顺序。当前显示最后一次确认的值。", "codexAuth.switched": "下一次请求将使用 {email}", "codexAuth.loadFailed": "无法加载 Codex 账号设置。", + "codexAuth.accountsRefreshFailed": "最近一次账号刷新失败。以下是最后一次确认的账号。", "codexAuth.switchFailed": "无法切换账户。之前的选择保持不变。", "codexAuth.removeConfirm": "删除 {id}?", "codexAuth.removeFailed": "无法移除账户。未进行任何更改。", diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 34a9e808971..5dc15873e1f 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -29,6 +29,7 @@ background: var(--raised); border-radius: var(--radius-xs); } .pwi-auth-state--error { color: var(--red); background: var(--red-soft); justify-content: space-between; } +.pwi-auth-state--stale { color: var(--amber); background: var(--amber-soft); justify-content: space-between; } .pwi-auth-state--empty { justify-content: center; } .pwi-auth-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; } diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index 044b8156a01..3f1d5c848d4 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -21,6 +21,10 @@ test("the controller is the single data owner and exposes the agreed contract", // WP2 (260730_gui_hydration_loading_unify/010): progress is part of the contract, because a // forced quota refresh keeps `loadState` at "ready" and would otherwise be invisible. "refreshing", "initialLoading", + // #5261: for the same reason in the other direction. A warm refresh failure keeps the rows + // and keeps `loadState` at "ready", so without this the surface has no way to say that what + // it is showing predates a failed read. + "refreshFailed", ]) { expect(hook).toContain(member); } diff --git a/gui/tests/codex-account-pool-stale-refresh.test.tsx b/gui/tests/codex-account-pool-stale-refresh.test.tsx new file mode 100644 index 00000000000..0f164c71a72 --- /dev/null +++ b/gui/tests/codex-account-pool-stale-refresh.test.tsx @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import CodexAccountPool from "../src/components/CodexAccountPool"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { useCodexAccountPool, type CodexAccountEntry, type CodexAccountPoolController } from "../src/hooks/useCodexAccountPool"; +import { en } from "../src/i18n/en"; +import { LanguageProvider } from "../src/i18n/provider"; + +/** + * #5261: a failed account refresh used to leave the roster looking current. + * + * Keeping the rows is deliberate — blanking a populated pool on a soft poll miss is its own + * defect — but the controller also went on reporting `ready`, so nothing distinguished a list + * the server had just confirmed from one that predated a failure. The case that surfaced it: + * add an account, the read that would bring it over fails, and the dashboard shows the older + * accounts with the new one simply absent. + * + * Both halves are held here because either alone is satisfiable without the other: a flag the + * surface never reads changes nothing a user sees, and a banner with no flag behind it never + * appears. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let accountsOk = true; +let serverAccounts: unknown[] = []; +let baseCounter = 0; +let activeResponseGate: Promise | null = null; + +function row(id: string, email: string, isMain = false) { + return { id, email, isMain, paused: false, priority: 0, hasCredential: true, quota: null }; +} + +const mainAccount: CodexAccountEntry = { + id: "main", + email: "main@example.test", + isMain: true, + paused: false, + priority: 0, + hasCredential: true, + quota: null, + quotaAutoRefresh: { + fiveHourAvailable: false, + weeklyAvailable: false, + fiveHourEnabled: false, + weeklyEnabled: false, + }, +}; + +function makeController(overrides: Partial = {}): CodexAccountPoolController { + return { + accounts: [mainAccount], + activeId: null, + loadState: "ready", + refreshing: false, + refreshFailed: false, + initialLoading: false, + switchingId: null, + pauseUpdatingId: null, + priorityUpdatingId: null, + pausingExhausted: false, + activeNeedsReauth: false, + activePinnedId: null, + load: async () => true, + switchAccount: async () => ({ ok: true, activeId: null }), + setAccountPaused: async () => ({ ok: true }), + setAccountPriority: async () => ({ ok: true }), + pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), + saveAlias: async () => ({ ok: true }), + removeAccount: async () => ({ ok: true }), + syncAfterAccountAdded: async () => ({ ok: true }), + pauseRefresh: () => ({ __brand: "codex-pool-pause" }) as never, + resumeRefresh: () => {}, + subscribeLoadObserver: () => () => {}, + readLastThreshold: () => undefined, + readLastActive: () => undefined, + ...overrides, + }; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + originalFetch = globalThis.fetch; + accountsOk = true; + activeResponseGate = null; + serverAccounts = [row("a1", "account-one", true)]; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const path = String(url).split("/api/")[1] ?? String(url); + if (path.startsWith("usage?")) { + return { ok: true, json: async () => ({ accounts: [] }) } as unknown as Response; + } + if (path.startsWith("codex-auth/accounts")) { + if (!accountsOk) return { ok: false, status: 503 } as unknown as Response; + return { ok: true, json: async () => ({ accounts: serverAccounts }) } as unknown as Response; + } + if (path.startsWith("codex-auth/active")) { + const gate = activeResponseGate; + activeResponseGate = null; + if (gate) await gate; + return { + ok: true, + json: async () => ({ activeCodexAccountId: null, autoSwitchThreshold: 80 }), + } as unknown as Response; + } + return { ok: true, json: async () => ({}) } as unknown as Response; + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +/** A fresh apiBase each time: the controller's last-good snapshot is keyed by it. */ +async function mountController() { + baseCounter += 1; + const apiBase = `stale-${Date.now()}-${baseCounter}`; + const seen: { current: CodexAccountPoolController | null } = { current: null }; + function Probe() { + seen.current = useCodexAccountPool(apiBase, true); + return null; + } + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + return seen; +} + +async function mountPool(controller: CodexAccountPoolController) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +function staleBanner(): Element | null { + return host.querySelector(".pwi-auth-state--stale"); +} + +test("a failed refresh keeps the rows and stops reporting them as current", async () => { + const seen = await mountController(); + expect(seen.current!.loadState).toBe("ready"); + expect(seen.current!.refreshFailed).toBe(false); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1"]); + + // The server now has an account this client has never seen, and the read that would have + // brought it over fails. This is the reported shape of the defect, not a synthetic one. + serverAccounts = [row("a1", "account-one", true), row("a2", "account-two")]; + accountsOk = false; + await act(async () => { await seen.current!.load(); }); + + expect(seen.current!.refreshFailed).toBe(true); + // Still ready, and still holding the rows: blanking a populated pool on a miss is its own + // defect, so the fix is that the surface now has something to say, not that it shows less. + expect(seen.current!.loadState).toBe("ready"); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1"]); + + accountsOk = true; + await act(async () => { await seen.current!.load(); }); + + expect(seen.current!.refreshFailed).toBe(false); + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); +}); + +test("the banner clears with the rows it qualifies, not with the whole load", async () => { + // The rows are painted the moment /accounts returns, while /active can still be running on + // its own much longer budget. Clearing the flag at the settle instead would leave the rows + // that just replaced the stale ones labelled as the stale ones for that whole window. + const seen = await mountController(); + + accountsOk = false; + await act(async () => { await seen.current!.load(); }); + expect(seen.current!.refreshFailed).toBe(true); + + accountsOk = true; + serverAccounts = [row("a1", "account-one", true), row("a2", "account-two")]; + let releaseActive!: () => void; + activeResponseGate = new Promise(resolve => { releaseActive = resolve; }); + + let pending: Promise; + await act(async () => { + pending = seen.current!.load(); + await new Promise((r) => setTimeout(r, 10)); + }); + + // /accounts has landed; /active has not. + expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); + expect(seen.current!.refreshFailed).toBe(false); + + await act(async () => { releaseActive(); await pending!; }); + expect(seen.current!.refreshFailed).toBe(false); +}); + +test("a cold failure still replaces the surface rather than annotating an empty one", async () => { + // Non-regression: the cold path is unchanged, and this holds it there now that a second + // failure signal exists that must not take it over. + accountsOk = false; + const seen = await mountController(); + + expect(seen.current!.loadState).toBe("error"); + expect(seen.current!.accounts).toEqual([]); +}); + +test("the roster says so on screen when the rows it shows are the pre-refresh ones", async () => { + await mountPool(makeController({ refreshFailed: true })); + + const banner = staleBanner(); + expect(banner).not.toBeNull(); + expect(banner!.textContent).toContain(en["codexAuth.accountsRefreshFailed"]); + // Non-destructive: the accounts it is qualifying are still rendered underneath it. + expect(host.textContent).toContain("main@example.test"); +}); + +test("a roster whose refresh succeeded carries no banner", async () => { + // Non-regression: passes before this change too, and is here so the new banner cannot start + // appearing over a roster the server has just confirmed. + await mountPool(makeController({ refreshFailed: false })); + + expect(staleBanner()).toBeNull(); +}); + +test("a cold failure shows its own error instead of the stale banner", async () => { + // Precedence, not regression: nothing survived to qualify, so the banner would be describing + // an empty list. The cold error has to win even though both conditions hold. + await mountPool(makeController({ accounts: [], loadState: "error", refreshFailed: true })); + + expect(staleBanner()).toBeNull(); + expect(host.textContent).toContain(en["codexAuth.loadFailed"]); +}); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index 5e64bed40a3..c730876f670 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -70,6 +70,7 @@ function makeController(overrides: Partial = {}): Co activeNeedsReauth: false, activePinnedId: null, refreshing: false, + refreshFailed: false, initialLoading: false, load: async () => true, switchAccount: async () => ({ ok: true, activeId: null }), diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8d43bc015c..d5466e31887 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1031,6 +1031,7 @@ "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", + "oauth-login-cli-browser-launch.test.ts": "oauth", "oauth-login-cli-live-update.test.ts": "oauth", "oauth-login-open-browser.test.ts": "oauth", "oauth-login-summary.test.ts": "oauth", diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index de6364eb05f..bf233f0462c 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -2,6 +2,7 @@ import { writeSync } from "node:fs"; import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; +import { BROWSER_LAUNCH_FAILED_NOTICE } from "../lib/browser-launch-notice"; import { CliUsageError, printData, @@ -80,9 +81,12 @@ interface LoginStart { * either way, so the user waits at a terminal that looks like it is working. Names the fixed * callback port because that is the part people cannot guess — ChatGPT supplies the redirect * URI, so the flow cannot move to a free port, and `--device` is the way around it. + * + * Extends the shared notice rather than repeating it: only the second line is specific to this + * flow, and the first is the sentence every other login prints for the same failure. */ export const BROWSER_LAUNCH_FAILED_HINT = - "⚠️ No browser could be opened here — open the URL above yourself." + BROWSER_LAUNCH_FAILED_NOTICE + "\n If nothing on this machine can reach http://localhost:1455, rerun with --device instead."; /** `-` means "read it from stdin", the documented way to pass a code silently. */ diff --git a/src/lib/browser-launch-notice.ts b/src/lib/browser-launch-notice.ts new file mode 100644 index 00000000000..fb39c0193e1 --- /dev/null +++ b/src/lib/browser-launch-notice.ts @@ -0,0 +1,59 @@ +import type { OpenUrlResult } from "./open-url"; + +/** + * The one sentence a terminal login says when nothing opened. + * + * Stated once because it is now said from three places — the Codex account login, the generic + * OAuth login and the key login — and a sentence restated three times drifts three ways. Callers + * that have something more specific to add append to it rather than rewriting it, so the part a + * user learns to recognize stays identical everywhere. + */ +export const BROWSER_LAUNCH_FAILED_NOTICE = + "⚠️ No browser could be opened here — open the URL above yourself."; + +/** + * Report a browser launch whose answer arrives after the code that started it has moved on. + * + * The OAuth controller does not await `onAuth`, so a CLI login cannot simply await the launcher + * there: the flow continues, and on a callback-server provider the very next thing it does is + * draw a readline prompt. A warning written at that moment lands on the line the user is typing + * on, which is worse than not warning at all. + * + * So the launch reports itself as soon as it settles, and anything that would collide with it + * waits on {@link BrowserLaunchReport.settled} first. The launcher answers within its own settle + * window, so the wait costs a fraction of a second and buys a deterministic order. + */ +export interface BrowserLaunchReport { + /** Adopt a launch already in flight. Its failure is reported once, when it settles. */ + track(launch: Promise): void; + /** + * Resolves once every launch tracked BEFORE this call has been reported, and immediately when + * none was. A login publishes one URL, so "before this call" and "at all" are the same set + * here; the narrower promise is the one this actually keeps. + */ + settled(): Promise; +} + +export function createBrowserLaunchReport( + warn: (message: string) => void = message => { console.warn(message); }, +): BrowserLaunchReport { + let pending: Promise = Promise.resolve(); + return { + track(launch) { + pending = pending + .then(() => launch) + .then( + result => { + if (result.status !== "started") warn(`\n${BROWSER_LAUNCH_FAILED_NOTICE}`); + }, + // openUrl is documented never to reject, and a launcher that did would mean the same + // thing as one that failed. Swallowing it here is not politeness: this chain is what + // `settled()` hands to a prompt, so a rejection would propagate out of a login that + // is still perfectly able to continue, and would go unhandled in the polling flows + // that do not reach that await until minutes later. + () => { warn(`\n${BROWSER_LAUNCH_FAILED_NOTICE}`); }, + ); + }, + settled: () => pending, + }; +} diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 19ad8f833cd..a842affc9ca 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -2,6 +2,7 @@ import * as readline from "node:readline"; import { modelSelectionGuidance } from "../cli/model-selection-guidance"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { openUrl } from "../lib/open-url"; +import { createBrowserLaunchReport } from "../lib/browser-launch-notice"; import { loadConfig, saveConfig } from "../config"; import { findLiveProxy } from "../server/proxy-liveness"; import { @@ -14,6 +15,41 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; +/** + * Seams a test drives in place of a browser, a terminal and a real provider. Production passes + * none of them. + * + * They exist because the thing worth proving here is an ORDER — that a browser which did not + * open is on screen before the question that assumes it did — and an order is only observable + * from something that records both events. Spawning a launcher and attaching to stdin to find + * that out would test the operating system instead. + */ +export interface LoginCliDeps { + runLogin?: typeof runLogin; + openUrl?: typeof openUrl; + warn?: (message: string) => void; + /** Ask one question, read one line. Defaults to a readline prompt that owns its own lifetime. */ + ask?: (question: string) => Promise; +} + +/** + * Run `body` with a line reader, creating and closing a real one only when the caller did not + * supply its own. A readline interface attaches to stdin, so building one that nothing will ask + * a question keeps the process alive for no reason. + */ +async function withPrompt( + supplied: ((question: string) => Promise) | undefined, + body: (ask: (question: string) => Promise) => Promise, +): Promise { + if (supplied) return await body(supplied); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + return await body(question => new Promise(resolve => rl.question(question, resolve))); + } finally { + rl.close(); + } +} + const LIVE_RELOAD_PROVIDERS = new Set([ ...listOAuthProviders(), ...Object.keys(KEY_LOGIN_PROVIDERS), @@ -83,7 +119,7 @@ export function loginUsageMessage(): string { + ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`; } -export async function handleLogin(provider?: string): Promise { +export async function handleLogin(provider?: string, deps: LoginCliDeps = {}): Promise { const name = (provider ?? "").trim().toLowerCase(); // A removed provider id reached through its alias still logs in — the merged // successor owns the flow. Warn rather than silently reroute so scripts and @@ -91,30 +127,40 @@ export async function handleLogin(provider?: string): Promise { const alias = DEPRECATED_OAUTH_PROVIDER_ALIASES[name]; if (alias) { console.error(`${name} is deprecated; logging in as ${alias}`); - return handleOAuthLogin(alias); + return handleOAuthLogin(alias, deps); } - if (isPublicOAuthProvider(name)) return handleOAuthLogin(name); - if (isKeyLoginProvider(name)) return handleKeyLogin(name); + if (isPublicOAuthProvider(name)) return handleOAuthLogin(name, deps); + if (isKeyLoginProvider(name)) return handleKeyLogin(name, deps); console.error(loginUsageMessage()); process.exit(1); } -async function handleOAuthLogin(name: string): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - try { - await runLogin(name, { +export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}): Promise { + const login = deps.runLogin ?? runLogin; + const launch = deps.openUrl ?? openUrl; + const browser = createBrowserLaunchReport(deps.warn); + await withPrompt(deps.ask, async (ask) => { + await login(name, { onAuth: ({ url, instructions }) => { console.log(`\n🔐 Opening browser for ${name} login...\n${url}\n`); if (instructions) console.log(instructions); - void openUrl(url); + // The controller does not await onAuth, so the launcher's answer cannot be reported from + // here — this returns long before it arrives. It reports itself instead, and the one + // thing that could collide with it waits below (#5261). + browser.track(launch(url)); }, onProgress: (m) => console.log(` ${m}`), - onManualCodeInput: () => - new Promise((res) => rl.question("Paste redirect URL or code (or wait for browser): ", res)), + onManualCodeInput: async () => { + // "or wait for browser" is a lie if nothing opened, and a warning printed after readline + // has drawn the prompt lands on the line the user is typing on. + await browser.settled(); + return await ask("Paste redirect URL or code (or wait for browser): "); + }, }); - } finally { - rl.close(); - } + }); + // A device or polling provider never prompts, so nothing above waited on the launcher. It is + // still owed an answer before this claims the login worked. + await browser.settled(); const reload = await notifyRunningProxyAfterOAuthLogin(name); console.log(`\n✅ Logged in to ${name}. Try: ocx sync`); for (const line of modelSelectionGuidance(name)) console.log(line); @@ -192,8 +238,9 @@ export async function commitKeyLoginProvider( return mergedProvider; } -async function handleKeyLogin(name: string): Promise { +export async function handleKeyLogin(name: string, deps: LoginCliDeps = {}): Promise { const def = KEY_LOGIN_PROVIDERS[name]; + const launch = deps.openUrl ?? openUrl; const preflightConfig = loadConfig(); const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name); if (namespaceCollision) { @@ -201,21 +248,25 @@ async function handleKeyLogin(name: string): Promise { process.exit(1); } console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`); - void openUrl(def.dashboardUrl); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const key = (await new Promise((res) => rl.question(`Paste your ${def.label} API key: `, res))).trim(); - // Template URL with placeholders needs resolution before saving. - let baseUrl = def.baseUrl; - if (/\{[^}]*\}/.test(baseUrl)) { - const resolved = (await new Promise((res) => rl.question(`Your endpoint URL (${baseUrl}): `, res))).trim(); - if (!resolved) { - rl.close(); - console.error("A resolved URL is required — replace the {placeholder} with your actual value."); - process.exit(1); + const browser = createBrowserLaunchReport(deps.warn); + browser.track(launch(def.dashboardUrl)); + // The next question asks for a key the user gets FROM that page, so a page that never opened + // has to be on screen before the question rather than underneath it (#5261). + await browser.settled(); + const { key, baseUrl } = await withPrompt(deps.ask, async (ask) => { + const entered = (await ask(`Paste your ${def.label} API key: `)).trim(); + // Template URL with placeholders needs resolution before saving. + let resolvedBaseUrl = def.baseUrl; + if (/\{[^}]*\}/.test(resolvedBaseUrl)) { + const resolved = (await ask(`Your endpoint URL (${resolvedBaseUrl}): `)).trim(); + if (!resolved) { + console.error("A resolved URL is required — replace the {placeholder} with your actual value."); + process.exit(1); + } + resolvedBaseUrl = resolved; } - baseUrl = resolved; - } - rl.close(); + return { key: entered, baseUrl: resolvedBaseUrl }; + }); if (!key) { console.error("No key entered."); process.exit(1); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 53fc4cbab3d..a34abfa4bbc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -857,6 +857,7 @@ "oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth", "oauth-log.test.ts": "oauth", + "oauth-login-cli-browser-launch.test.ts": "oauth", "oauth-login-cli-live-update.test.ts": "oauth", "oauth-login-open-browser.test.ts": "oauth", "oauth-login-summary.test.ts": "oauth", diff --git a/tests/oauth/oauth-login-cli-browser-launch.test.ts b/tests/oauth/oauth-login-cli-browser-launch.test.ts new file mode 100644 index 00000000000..1efad4b987b --- /dev/null +++ b/tests/oauth/oauth-login-cli-browser-launch.test.ts @@ -0,0 +1,242 @@ +/** + * #5261 remainder: the two CLI logins that still discarded the launcher's answer. + * + * The landed half taught `openUrl` to say whether anything started, and the Codex account login + * to print it. `ocx login ` and `ocx login ` kept calling + * `void openUrl(...)`, so on a host with no browser both printed a URL, claimed to be opening + * it, and then asked a question that assumes it opened. The user waits at a prompt that looks + * like progress. + * + * What these assert is the ORDER, not merely the presence of a warning. The OAuth controller + * does not await `onAuth`, so the launcher answers after the login flow has moved on — on a + * callback-server provider, after it has already drawn a readline prompt. A warning that lands + * there is written over the line the user is typing on, which is why "it warns eventually" is + * not the contract. + * + * Nothing here opens a browser or attaches to stdin: both are injected, which is the only way + * two events can be observed in sequence at all. + */ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { handleKeyLogin, handleOAuthLogin, type LoginCliDeps } from "../../src/oauth/login-cli"; +import { KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers"; +import { listOAuthProviders } from "../../src/oauth"; +import { BROWSER_LAUNCH_FAILED_NOTICE, createBrowserLaunchReport } from "../../src/lib/browser-launch-notice"; +import { BROWSER_LAUNCH_FAILED_HINT } from "../../src/cli/account-auth"; +import { repoPath } from "../helpers/repo-root"; +import { readFileSync } from "node:fs"; +import type { OpenUrlResult } from "../../src/lib/open-url"; +import type { OAuthCredentials } from "../../src/oauth/types"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let logSpy: { mockRestore(): void } | null = null; + +type FakeRunLogin = NonNullable; + +/** Named from the roster rather than typed in, so a renamed provider cannot leave this passing. */ +function anyOAuthProvider(): string { + const [first] = listOAuthProviders(); + if (!first) throw new Error("no OAuth providers are registered"); + return first; +} + +/** A key provider whose baseUrl needs no placeholder resolution, so one prompt ends the flow. */ +function anyDirectKeyProvider(): string { + const found = Object.entries(KEY_LOGIN_PROVIDERS).find(([, def]) => !/\{[^}]*\}/.test(def.baseUrl)); + if (!found) throw new Error("no key-login provider has a resolved baseUrl"); + return found[0]; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-login-launch-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-login-launch-")); + process.env.OPENCODEX_HOME = testDir; + // The one row exists so the config validates; it is deliberately not the provider under test, + // which keeps the post-login live-reload notify from looking for a proxy. An empty table would + // fail validation and be silently replaced by the packaged default, whose contents this has no + // reason to depend on. + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "login-launch-stub", + providers: { + "login-launch-stub": { + adapter: "openai-chat", + baseUrl: "https://stub.invalid/v1", + apiKey: "sk-login-launch-stub", + }, + }, + } as OcxConfig); + logSpy = spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + logSpy?.mockRestore(); + logSpy = null; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); + testDir = ""; +}); + +describe("CLI OAuth login reports the browser launch (#5261)", () => { + test("a browser that never opened is on screen before the paste prompt", async () => { + const events: string[] = []; + let settleLaunch: (result: OpenUrlResult) => void = () => {}; + const launch = new Promise(resolve => { settleLaunch = resolve; }); + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://accounts.example.test/authorize?state=1" }); + // The launcher answers only after onAuth has returned. That gap is the defect: the old + // code had already discarded the promise by this point. + settleLaunch({ status: "failed", reason: "launcher-exit" }); + expect(await ctrl.onManualCodeInput?.()).toBe("pasted-code"); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: () => launch, + warn: message => { events.push(`warn:${message}`); }, + ask: async () => { events.push("ask"); return "pasted-code"; }, + }); + + expect(events).toEqual([`warn:\n${BROWSER_LAUNCH_FAILED_NOTICE}`, "ask"]); + }); + + test("a browser that did open adds nothing to the prompt", async () => { + // A non-regression guard rather than proof of the fix: it passed before this change too, and + // it is here so the new warning cannot start firing on a launch that worked. + const events: string[] = []; + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://accounts.example.test/authorize?state=2" }); + await ctrl.onManualCodeInput?.(); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: async () => ({ status: "started" }), + warn: message => { events.push(`warn:${message}`); }, + ask: async () => { events.push("ask"); return "pasted-code"; }, + }); + + expect(events).toEqual(["ask"]); + }); + + test("a polling flow that never prompts is still told the launch failed", async () => { + // Device and polling providers publish a URL and then wait. Nothing asks a question, so + // nothing there would have waited on the launcher; the answer is still owed before the + // login claims to have worked. + const events: string[] = []; + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://device.example.test/activate", deviceCode: "WDJB-MJHT" }); + ctrl.onProgress?.("Waiting for approval..."); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: async () => ({ status: "failed", reason: "spawn-error" }), + warn: () => { events.push("warn"); }, + ask: async () => { events.push("ask"); return ""; }, + }); + + expect(events).toEqual(["warn"]); + }); + + test("a launcher that throws is reported, not turned into a failed login", async () => { + // openUrl documents that it never rejects, but this seam accepts any launcher. A rejection + // here used to travel out through settled(), which a polling flow does not await until the + // whole login has finished — so it would surface as an unhandled rejection and take down a + // login that could still have completed by hand. + const events: string[] = []; + const login: FakeRunLogin = async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "https://accounts.example.test/authorize?state=3" }); + ctrl.onProgress?.("Waiting for browser authentication..."); + return {} as OAuthCredentials; + }; + + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: login, + openUrl: async () => { throw new Error("launcher blew up"); }, + warn: () => { events.push("warn"); }, + ask: async () => { events.push("ask"); return ""; }, + }); + + expect(events).toEqual(["warn"]); + }); +}); + +describe("CLI key login reports the dashboard launch (#5261)", () => { + test("a dashboard that never opened is on screen before the key prompt", async () => { + const events: string[] = []; + const errors: string[] = []; + const errorSpy = spyOn(console, "error").mockImplementation((...parts: unknown[]) => { + errors.push(parts.join(" ")); + }); + const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + + try { + await expect(handleKeyLogin(anyDirectKeyProvider(), { + openUrl: async () => ({ status: "failed", reason: "launcher-exit" }), + warn: () => { events.push("warn"); }, + // An empty key ends the flow immediately after the prompt. The key path beyond it is + // already covered where it lives; what this case is about is what precedes the question. + ask: async () => { events.push("ask"); return ""; }, + })).rejects.toThrow("process.exit:1"); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + + expect(events).toEqual(["warn", "ask"]); + expect(errors).toContain("No key entered."); + }); +}); + +describe("the launch report is one sentence and one order", () => { + test("settled() cannot resolve before the warning is written", async () => { + const events: string[] = []; + let settleLaunch: (result: OpenUrlResult) => void = () => {}; + const launch = new Promise(resolve => { settleLaunch = resolve; }); + const report = createBrowserLaunchReport(() => { events.push("warn"); }); + + report.track(launch); + const waited = report.settled().then(() => { events.push("prompt"); }); + settleLaunch({ status: "failed", reason: "invalid-url" }); + await waited; + + expect(events).toEqual(["warn", "prompt"]); + }); + + test("a report with nothing tracked resolves rather than hanging", async () => { + const events: string[] = []; + await createBrowserLaunchReport(() => { events.push("warn"); }).settled(); + expect(events).toEqual([]); + }); + + test("the Codex account hint extends the shared notice instead of holding its own copy", () => { + // Asserting only that the strings agree would pass on a second copy that happens to match + // today, which is the state this replaced. The source is read as well, so the sentence has + // exactly one home and a later edit to it cannot reach two thirds of the logins. + expect(BROWSER_LAUNCH_FAILED_HINT.startsWith(BROWSER_LAUNCH_FAILED_NOTICE)).toBe(true); + expect(BROWSER_LAUNCH_FAILED_HINT.length).toBeGreaterThan(BROWSER_LAUNCH_FAILED_NOTICE.length); + + const accountAuth = readFileSync(repoPath("src", "cli", "account-auth.ts"), "utf8"); + expect(accountAuth).toContain("BROWSER_LAUNCH_FAILED_NOTICE"); + expect(accountAuth).not.toContain(BROWSER_LAUNCH_FAILED_NOTICE); + }); +});