Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ MODIFY `gui/src/i18n/{en,de,fr,ja,ko,ru,tr,zh,zh-TW}.ts`
failure copy (actionable, safe; no auto-retry wording). Revise
`mainTokenExpired` so it no longer claims App login is the only path.

## Audit folds (wp4 A)

- Start POSTs an EMPTY body (the route rejects any body with 400); poll
immediately until verificationUrl/deviceCode arrive (they are empty in the
start response), and keep the last url/code through the committing state.
- Map the full MainDeviceReauthStatus union + HTTP error shapes: committing
(no url/code), failed.code (identity_mismatch, credential_changed,
native_main_unavailable, device_authorization_failed,
publication_failed, reconciliation_failed), 409 flow_in_progress, 503
native_main_unavailable; when credentialUpdated is true the copy never
claims the file was unchanged; the verification URL is allowlisted to
https://auth.openai.com/codex/device.
- structure claim lands in structure/gui-and-management-api.md (the
Codex-accounts row :312), not overview.md.

## Tests (red-first)

NEW `gui/tests/main-device-reauth.test.tsx` — happy-dom mount per
Expand Down
12 changes: 10 additions & 2 deletions gui/src/components/CodexAccountPool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IconPlus } from "../icons";
import { EmptyState, type NoticeTone } from "../ui";
import AddCodexAccountModal from "./AddCodexAccountModal";
import { useCodexAccountPool, type CodexAccountPoolController } from "../hooks/useCodexAccountPool";
import { useMainDeviceReauth } from "./use-main-device-reauth";
import type { ReactNode } from "react";
import type { CodexAccountModeState } from "../codex-multi-state";
import CodexAutoSwitchSetting from "./CodexAutoSwitchSetting";
Expand Down Expand Up @@ -71,6 +72,12 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
const ownController = useCodexAccountPool(apiBase, !injectedController);
const controller = injectedController ?? ownController;
const { accounts, activeId, loadState, 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(); });
const mainReauthActive = mainReauth.state.phase === "starting"
|| mainReauth.state.phase === "pending"
|| mainReauth.state.phase === "committing";
const [confirm, setConfirm] = useState<CodexAccountEntry | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [modelsNotice, setModelsNotice] = useState<{ catalogRefreshPending: boolean } | null>(null);
Expand Down Expand Up @@ -172,10 +179,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
}, [readLastThreshold, hydrateServerValue]);

useEffect(() => {
if (!showAdd) return;
if (!showAdd && !mainReauthActive) return;
const token = controller.pauseRefresh();
return () => controller.resumeRefresh(token);
}, [controller, showAdd]);
}, [controller, showAdd, mainReauthActive]);

const activePoolAccount = activeId && activeId !== "__main__"
? accounts.find(a => a.id === activeId)
Expand Down Expand Up @@ -531,6 +538,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
onCopyDoctor={showDoctorCopy ? copyDoctor : undefined}
doctorCopyOutcomeFor={showDoctorCopy ? doctorCopy.outcomeFor : undefined}
onManageMainHardLock={hasMainHardLockSetting ? manageMainHardLock : undefined}
mainReauth={mainReauth}
/>

<div className="section-sep">
Expand Down
50 changes: 49 additions & 1 deletion gui/src/components/codex-account-pool-main-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-he
import type { CodexAccountEntry } 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";
import type { NoticeTone } from "../ui";
import { navigateHash } from "../hash-routing";
import {
Expand Down Expand Up @@ -37,6 +38,7 @@ export function CodexAccountPoolMainCard({
onCopyDoctor,
doctorCopyOutcomeFor,
onManageMainHardLock,
mainReauth,
}: {
t: TFn;
main: CodexAccountEntry | undefined;
Expand All @@ -62,6 +64,12 @@ export function CodexAccountPoolMainCard({
onCopyDoctor?: (accountId: string) => void;
doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null;
onManageMainHardLock?: () => void;
/** #3898: native-main device reauth flow state and controls (dedicated namespace). */
mainReauth?: {
state: MainDeviceReauthState;
start: () => Promise<void>;
cancel: () => Promise<void>;
} | undefined;
}) {
const mainFallbackLabel = t("codexAuth.codexApp");
const mainId = main?.id ?? "__main__";
Expand Down Expand Up @@ -182,7 +190,47 @@ export function CodexAccountPoolMainCard({
<div className="card-sub faint">{t("pws.healthCooldownHint")}</div>
)}
{showReauth
? <div className="card-sub faint">{t("codexAuth.mainTokenExpired")}</div>
? <div className="card-sub faint">
<p role="status">{t("codexAuth.mainTokenExpired")}</p>
{mainReauth && (mainReauth.state.phase === "idle" || mainReauth.state.phase === "failed") && (
<>
<button
type="button"
className="btn btn-ghost btn-sm codex-auth-action-btn"
onClick={() => { void mainReauth.start(); }}
>
{t("codexAuth.mainReauthDevice")}
</button>
{mainReauth.state.phase === "failed" && (
<span className="badge badge-amber">{t("codexAuth.mainReauthFailed")}: {mainReauth.state.code}</span>
)}
</>
)}
{mainReauth && mainReauth.state.phase === "starting" && (
<span className="faint">{t("codexAuth.mainReauthPending")}</span>
)}
{mainReauth && (mainReauth.state.phase === "pending" || mainReauth.state.phase === "committing") && (
<span className="codex-main-reauth-pending">
{mainReauth.state.verificationUrl && (
<span>{t("codexAuth.mainReauthOpen")}: {mainReauth.state.verificationUrl}</span>
)}
{mainReauth.state.deviceCode && (
<strong>{t("codexAuth.mainReauthCode")}: {mainReauth.state.deviceCode}</strong>
)}
<span className="faint">{t("codexAuth.mainReauthPending")}</span>
<button
type="button"
className="btn btn-ghost btn-sm codex-auth-action-btn"
onClick={() => { void mainReauth.cancel(); }}
>
{t("codexAuth.mainReauthCancel")}
</button>
</span>
)}
{mainReauth && mainReauth.state.phase === "succeeded" && (
<span className="badge badge-primary">{t("codexAuth.mainReauthSucceeded")}</span>
)}
</div>
: !inCooldown && <>
<QuotaBars
quota={main?.quota ?? null}
Expand Down
184 changes: 184 additions & 0 deletions gui/src/components/use-main-device-reauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useRef, useState } from "react";

/**
* Main-card device reauth (#3898 L3): drives the dedicated native-main
* namespace /api/codex-auth/main/reauth-device. Deliberately NOT the pool
* AddCodexAccountModal/openReauth path — /api/codex-auth/login rejects
* __main__ and would write the wrong credential store.
*
* DTO hygiene: the hook only ever reads flowId, status, verificationUrl,
* deviceCode, and the closed failure-code set; token fields are never
* accepted even if a payload carried them. The verification URL is
* allowlisted to the known device page. Polling owns its flowId: late
* responses from a replaced flow are ignored, and nothing persists to
* browser storage.
*/

const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
const POLL_INTERVAL_MS = 2_000;
const POLL_TICK_TIMEOUT_MS = 10_000;

export type MainDeviceReauthFailureCode =
| "identity_mismatch"
| "credential_changed"
| "native_main_unavailable"
| "device_authorization_failed"
| "publication_failed"
| "reconciliation_failed"
| "flow_in_progress"
| "request_failed";

export type MainDeviceReauthState =
| { phase: "idle" }
| { phase: "starting" }
| { phase: "pending"; flowId: string; verificationUrl: string; deviceCode: string }
| { phase: "committing"; flowId: string; verificationUrl: string; deviceCode: string }
| { phase: "succeeded" }
| { phase: "cancelled" }
| { phase: "failed"; code: MainDeviceReauthFailureCode };

type FlowDto = {
flowId?: unknown;
status?: unknown;
verificationUrl?: unknown;
deviceCode?: unknown;
code?: unknown;
error?: unknown;
};

const FAILURE_CODES = new Set<MainDeviceReauthFailureCode>([
"identity_mismatch",
"credential_changed",
"native_main_unavailable",
"device_authorization_failed",
"publication_failed",
"reconciliation_failed",
"flow_in_progress",
]);

function failureCode(value: unknown): MainDeviceReauthFailureCode {
return typeof value === "string" && FAILURE_CODES.has(value as MainDeviceReauthFailureCode)
? value as MainDeviceReauthFailureCode
: "request_failed";
}

function allowedVerificationUrl(value: unknown): string {
return typeof value === "string" && value.startsWith(DEVICE_VERIFICATION_URL) ? value : "";
}

function humanCode(value: unknown): string {
return typeof value === "string" && /^[A-Z0-9-]{1,16}$/i.test(value) ? value : "";
}

export function useMainDeviceReauth(apiBase: string, onCompleted: () => void) {
const [state, setState] = useState<MainDeviceReauthState>({ phase: "idle" });
const flowRef = useRef<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const unmountedRef = useRef(false);

const stopPolling = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);

const cancel = useCallback(async () => {
const flowId = flowRef.current;
stopPolling();
flowRef.current = null;
if (!flowId) {
setState({ phase: "idle" });
return;
}
try {
await fetch(`${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`, { method: "DELETE" });
} catch { /* best-effort: the flow expires on its own */ }
setState({ phase: "cancelled" });
}, [apiBase, stopPolling]);

const start = useCallback(async () => {
stopPolling();
flowRef.current = null;
const ctrl = new AbortController();
abortRef.current = ctrl;
setState({ phase: "starting" });
let flowId: string;
try {
// Empty body by contract: the route rejects any request keys with 400.
const res = await fetch(`${apiBase}/api/codex-auth/main/reauth-device`, { method: "POST", signal: ctrl.signal });
const dto = await res.json().catch(() => ({})) as FlowDto;
if (!res.ok) {
setState({ phase: "failed", code: failureCode(dto.code) });
return;
}
if (typeof dto.flowId !== "string" || !dto.flowId) {
setState({ phase: "failed", code: "request_failed" });
return;
}
flowId = dto.flowId;
} catch {
if (!ctrl.signal.aborted) setState({ phase: "failed", code: "request_failed" });
return;
}
flowRef.current = flowId;
let lastUrl = "";
let lastCode = "";
// Poll immediately: the start response predates the usercode reply, so the
// URL and human code only arrive through status reads.
while (!ctrl.signal.aborted) {
if (ctrl.signal.aborted || unmountedRef.current || flowRef.current !== flowId) return;
try {
const res = await fetch(
`${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`,
{ signal: AbortSignal.any([ctrl.signal, AbortSignal.timeout(POLL_TICK_TIMEOUT_MS)]) },
);
const dto = await res.json().catch(() => ({})) as FlowDto;
if (!res.ok) {
setState({ phase: "failed", code: failureCode(dto.code) });
return;
}
lastUrl = allowedVerificationUrl(dto.verificationUrl) || lastUrl;
lastCode = humanCode(dto.deviceCode) || lastCode;
if (dto.status === "pending" || dto.status === "committing") {
setState({
phase: dto.status,
flowId,
verificationUrl: lastUrl,
deviceCode: lastCode,
});
} else if (dto.status === "succeeded") {
flowRef.current = null;
setState({ phase: "succeeded" });
onCompleted();
return;
} else if (dto.status === "cancelled") {
flowRef.current = null;
setState({ phase: "cancelled" });
return;
} else if (dto.status === "failed") {
flowRef.current = null;
setState({ phase: "failed", code: failureCode(dto.code) });
return;
}
} catch {
if (ctrl.signal.aborted || unmountedRef.current) return;
// A tick failure is transient: the service flow keeps its own deadline.
}
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
}
}, [apiBase, onCompleted, stopPolling]);

useEffect(() => {
return () => {
unmountedRef.current = true;
stopPolling();
const flowId = flowRef.current;
flowRef.current = null;
if (flowId) {
void fetch(`${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`, { method: "DELETE" })
.catch(() => {});
}
};
}, [apiBase, stopPolling]);

return { state, start, cancel };
}
9 changes: 8 additions & 1 deletion gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,7 +1508,14 @@ export const de: Record<TKey, string> = {
"codexAuth.needsReauth": "Erneut anmelden",
"codexAuth.reauthenticate": "Re-authenticate",
"codexAuth.tokenExpired": "Token abgelaufen — dieses Konto erneut authentifizieren",
"codexAuth.mainTokenExpired": "Token abgelaufen — erneut über Codex-App-Login anmelden",
"codexAuth.mainTokenExpired": "Token abgelaufen — unten mit Gerätecode oder über Codex-App-Login erneut anmelden",
"codexAuth.mainReauthSucceeded": "Angemeldet",
"codexAuth.mainReauthFailed": "Anmeldung fehlgeschlagen",
"codexAuth.mainReauthCancel": "Abbrechen",
"codexAuth.mainReauthCode": "Code",
"codexAuth.mainReauthOpen": "Öffnen",
"codexAuth.mainReauthPending": "Warte auf Anmeldung…",
"codexAuth.mainReauthDevice": "Erneut mit Gerätecode anmelden",
"codexAuth.emailCollision": "Dieses Konto entspricht deinem Haupt-Codex-Login. Nutze ein anderes Konto.",
"codexAuth.resetCreditsTitle": "Gutschriften zurücksetzen",
"codexAuth.resetCreditsAvailable": "Du hast {count} Reset-Gutschrift(en) verfügbar.",
Expand Down
9 changes: 8 additions & 1 deletion gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2092,7 +2092,14 @@ export const en = {
"codexAuth.needsReauth": "Re-login",
"codexAuth.reauthenticate": "Re-authenticate",
"codexAuth.tokenExpired": "Token expired — re-authenticate this account",
"codexAuth.mainTokenExpired": "Token expired — sign in again via Codex App login",
"codexAuth.mainTokenExpired": "Token expired — re-login with a device code below, or via Codex App login",
"codexAuth.mainReauthSucceeded": "Signed in",
"codexAuth.mainReauthFailed": "Re-login failed",
"codexAuth.mainReauthCancel": "Cancel",
"codexAuth.mainReauthCode": "Code",
"codexAuth.mainReauthOpen": "Open",
"codexAuth.mainReauthPending": "Waiting for sign-in…",
"codexAuth.mainReauthDevice": "Re-login with device code",
"codexAuth.emailCollision": "This account matches your main Codex login. Use a different account.",

"codexAuth.resetCreditsTitle": "Reset Credits",
Expand Down
9 changes: 8 additions & 1 deletion gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2014,7 +2014,14 @@ export const fr: Record<TKey, string> = {
"codexAuth.needsReauth": "Se reconnecter",
"codexAuth.reauthenticate": "Se réauthentifier",
"codexAuth.tokenExpired": "Jeton expiré — réauthentifiez ce compte",
"codexAuth.mainTokenExpired": "Jeton expiré — reconnectez-vous depuis l’application Codex",
"codexAuth.mainTokenExpired": "Jeton expiré — reconnectez-vous avec un code appareil ci-dessous ou via la connexion Codex App",
"codexAuth.mainReauthSucceeded": "Connecté",
"codexAuth.mainReauthFailed": "Échec de la reconnexion",
"codexAuth.mainReauthCancel": "Annuler",
"codexAuth.mainReauthCode": "Code",
"codexAuth.mainReauthOpen": "Ouvrir",
"codexAuth.mainReauthPending": "En attente de connexion…",
"codexAuth.mainReauthDevice": "Reconnectez-vous avec un code appareil",
"codexAuth.emailCollision": "Ce compte correspond à votre connexion Codex principale. Utilisez un autre compte.",
"codexAuth.resetCreditsTitle": "Crédits de réinitialisation",
"codexAuth.resetCreditsAvailable": "Vous disposez de {count} crédit(s) de réinitialisation.",
Expand Down
9 changes: 8 additions & 1 deletion gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1943,7 +1943,14 @@ export const ja: Record<TKey, string> = {
"codexAuth.needsReauth": "再ログイン",
"codexAuth.reauthenticate": "再認証",
"codexAuth.tokenExpired": "トークンが期限切れ — このアカウントを再認証してください",
"codexAuth.mainTokenExpired": "トークンが期限切れ — Codex アプリログインから再度サインインしてください",
"codexAuth.mainTokenExpired": "トークンの有効期限切れ — 下のデバイスコードまたは Codex アプリログインで再ログインしてください",
"codexAuth.mainReauthSucceeded": "サインインしました",
"codexAuth.mainReauthFailed": "再ログインに失敗しました",
"codexAuth.mainReauthCancel": "キャンセル",
"codexAuth.mainReauthCode": "コード",
"codexAuth.mainReauthOpen": "開く",
"codexAuth.mainReauthPending": "サインインを待っています…",
"codexAuth.mainReauthDevice": "デバイスコードで再ログイン",
"codexAuth.emailCollision": "このアカウントはメインの Codex ログインと一致します。別のアカウントを使用してください。",

"codexAuth.resetCreditsTitle": "リセットクレジット",
Expand Down
Loading
Loading