fix(FN-XXX): unify codex auth and chat fallback

This commit is contained in:
gsxdsm
2026-05-03 23:08:05 -07:00
parent 6666a90b6e
commit a3ce8e1f8e
41 changed files with 2340 additions and 325 deletions

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
choosePreferredStoredCredential,
extractCodexCliStoredCredential,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
} from "../oauth-credential-interop.js";
function encodeBase64Url(value: string): string {
return Buffer.from(value, "utf-8").toString("base64url");
}
function createJwt(payload: Record<string, unknown>): string {
return [
encodeBase64Url(JSON.stringify({ alg: "none", typ: "JWT" })),
encodeBase64Url(JSON.stringify(payload)),
"signature",
].join(".");
}
describe("oauth credential interop", () => {
it("extracts Codex CLI OAuth credentials from auth.json token payload", () => {
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3600;
const accessToken = createJwt({
exp: expiresAtSeconds,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_123",
},
});
const credential = extractCodexCliStoredCredential({
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
},
});
expect(credential).toEqual({
type: "oauth",
access: accessToken,
refresh: "refresh-token",
expires: expiresAtSeconds * 1000,
accountId: "acct_123",
});
});
it("falls back to last_refresh when Codex CLI JWT has no exp claim", () => {
const accessToken = createJwt({
sub: "user-123",
});
const lastRefresh = "2026-05-03T10:00:00.000Z";
const credential = extractCodexCliStoredCredential({
last_refresh: lastRefresh,
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_from_token",
},
});
expect(credential?.type).toBe("oauth");
expect(credential?.accountId).toBe("acct_from_token");
expect(credential?.expires).toBe(Date.parse(lastRefresh) + 55 * 60 * 1000);
});
it("prefers a valid OAuth credential over an expired one and hydrates only when better", () => {
const expired = {
type: "oauth",
access: "expired-access",
refresh: "expired-refresh",
expires: Date.now() - 60_000,
} as const;
const valid = {
type: "oauth",
access: "valid-access",
refresh: "valid-refresh",
expires: Date.now() + 60_000,
} as const;
expect(choosePreferredStoredCredential(expired, valid)).toEqual(valid);
expect(shouldHydrateStoredCredential(expired, valid)).toBe(true);
expect(shouldHydrateStoredCredential({ type: "api_key", key: "sk-live" }, valid)).toBe(false);
});
it("gracefully ignores malformed auth files", () => {
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
try {
const malformedPath = join(tempDir, "auth.json");
writeFileSync(malformedPath, "{ not-json");
expect(readStoredCredentialsFromAuthFile(malformedPath)).toEqual({});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -763,6 +763,14 @@ export type {
} from "./chat-types.js";
export { ChatStore } from "./chat-store.js";
export type { ChatStoreEvents } from "./chat-store.js";
export {
choosePreferredStoredCredential,
extractCodexCliStoredCredential,
getCodexCliAuthPath,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
} from "./oauth-credential-interop.js";
export type { StoredAuthCredential } from "./oauth-credential-interop.js";
// ── Error helpers ─────────────────────────────────────────
export { getErrorMessage } from "./error-message.js";

View File

@@ -0,0 +1,243 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type StoredAuthCredential = {
type?: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
accountId?: string;
[key: string]: unknown;
};
const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
const CODEX_REFRESH_FALLBACK_WINDOW_MS = 55 * 60 * 1000;
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
export function getCodexCliAuthPath(home = getHomeDir()): string {
return join(home, ".codex", "auth.json");
}
function parseJwtPayload(token: string): Record<string, unknown> | null {
try {
const [, payload = ""] = token.split(".", 3);
if (!payload) {
return null;
}
return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as Record<string, unknown>;
} catch {
return null;
}
}
function getJwtExpiryMs(token: string | undefined): number | undefined {
if (!token) {
return undefined;
}
const payload = parseJwtPayload(token);
const exp = payload?.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) {
return undefined;
}
return exp * 1000;
}
function getCodexAccountId(accessToken: string, fallbackAccountId: unknown): string | undefined {
const payload = parseJwtPayload(accessToken);
const authClaim = payload?.[OPENAI_AUTH_CLAIM];
const claimAccountId =
authClaim && typeof authClaim === "object"
? (authClaim as Record<string, unknown>).chatgpt_account_id
: undefined;
if (typeof claimAccountId === "string" && claimAccountId.trim().length > 0) {
return claimAccountId;
}
if (typeof fallbackAccountId === "string" && fallbackAccountId.trim().length > 0) {
return fallbackAccountId;
}
return undefined;
}
function getLastRefreshFallbackExpiryMs(lastRefresh: unknown): number | undefined {
if (typeof lastRefresh !== "string" || lastRefresh.trim().length === 0) {
return undefined;
}
const parsed = Date.parse(lastRefresh);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed + CODEX_REFRESH_FALLBACK_WINDOW_MS;
}
function isStoredAuthCredential(value: unknown): value is StoredAuthCredential {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return record.type === "oauth" || record.type === "api_key";
}
function isValidOauthCredential(credential: StoredAuthCredential | undefined): boolean {
return credential?.type === "oauth"
&& typeof credential.access === "string"
&& credential.access.length > 0
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires)
&& Date.now() < credential.expires;
}
function isRefreshableOauthCredential(credential: StoredAuthCredential | undefined): boolean {
return credential?.type === "oauth"
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires);
}
function compareStoredCredentials(
left: StoredAuthCredential | undefined,
right: StoredAuthCredential | undefined,
): number {
if (!left && !right) {
return 0;
}
if (left && !right) {
return 1;
}
if (!left && right) {
return -1;
}
if (left?.type === "api_key" && right?.type !== "api_key") {
return 1;
}
if (right?.type === "api_key" && left?.type !== "api_key") {
return -1;
}
if (left?.type === "oauth" && right?.type === "oauth") {
const leftValid = isValidOauthCredential(left);
const rightValid = isValidOauthCredential(right);
if (leftValid !== rightValid) {
return leftValid ? 1 : -1;
}
const leftRefreshable = isRefreshableOauthCredential(left);
const rightRefreshable = isRefreshableOauthCredential(right);
if (leftRefreshable !== rightRefreshable) {
return leftRefreshable ? 1 : -1;
}
const leftExpiry = typeof left.expires === "number" && Number.isFinite(left.expires) ? left.expires : -Infinity;
const rightExpiry = typeof right.expires === "number" && Number.isFinite(right.expires) ? right.expires : -Infinity;
if (leftExpiry !== rightExpiry) {
return leftExpiry > rightExpiry ? 1 : -1;
}
const leftAccessLength = typeof left.access === "string" ? left.access.length : 0;
const rightAccessLength = typeof right.access === "string" ? right.access.length : 0;
if (leftAccessLength !== rightAccessLength) {
return leftAccessLength > rightAccessLength ? 1 : -1;
}
}
return 0;
}
export function choosePreferredStoredCredential(
...credentials: Array<StoredAuthCredential | undefined>
): StoredAuthCredential | undefined {
let best: StoredAuthCredential | undefined;
for (const credential of credentials) {
if (compareStoredCredentials(credential, best) > 0) {
best = credential;
}
}
return best;
}
export function shouldHydrateStoredCredential(
current: StoredAuthCredential | undefined,
candidate: StoredAuthCredential | undefined,
): boolean {
if (!candidate || candidate.type !== "oauth") {
return false;
}
if (current?.type === "api_key") {
return false;
}
return compareStoredCredentials(candidate, current) > 0;
}
export function extractCodexCliStoredCredential(raw: unknown): StoredAuthCredential | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const tokens = record.tokens;
if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) {
return undefined;
}
const tokenRecord = tokens as Record<string, unknown>;
const access = typeof tokenRecord.access_token === "string" ? tokenRecord.access_token : undefined;
const refresh = typeof tokenRecord.refresh_token === "string" ? tokenRecord.refresh_token : undefined;
if (!access || !refresh) {
return undefined;
}
const expires =
getJwtExpiryMs(access)
?? getJwtExpiryMs(typeof tokenRecord.id_token === "string" ? tokenRecord.id_token : undefined)
?? getLastRefreshFallbackExpiryMs(record.last_refresh);
if (typeof expires !== "number" || !Number.isFinite(expires)) {
return undefined;
}
const accountId = getCodexAccountId(access, tokenRecord.account_id);
return {
type: "oauth",
access,
refresh,
expires,
...(accountId ? { accountId } : {}),
};
}
export function readStoredCredentialsFromAuthFile(authPath: string): Record<string, StoredAuthCredential> {
if (!existsSync(authPath)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
const codexCliCredential = extractCodexCliStoredCredential(parsed);
if (codexCliCredential) {
return { "openai-codex": codexCliCredential };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
const credentials: Record<string, StoredAuthCredential> = {};
for (const [providerId, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!isStoredAuthCredential(value)) {
continue;
}
credentials[providerId] = value;
}
return credentials;
} catch {
return {};
}
}

View File

@@ -506,7 +506,14 @@ Note: Refs (@e1, @e2) are invalidated after page navigation. Re-snapshot after c
toolMode: "readonly",
prompt: `You are a UX design reviewer. Verify frontend changes maintain visual polish and consistency with existing UI patterns and design tokens.
Design System Review:
FAST-BAIL RULE (check this FIRST):
- The task harness gives you a "Diff Scope" listing the files this task actually changed.
- If that list contains NO frontend/UI files (no .tsx/.jsx/.ts/.js component files, no .css/.scss/.sass/.styl, no .html/.vue/.svelte/.astro, no design-token/theme files), respond IMMEDIATELY with a single short line such as "No UI changes in scope — approved." and STOP.
- Do NOT explore the worktree looking for related-looking UI code to critique. If this task didn't change a UI file, your review is a no-op by definition.
Otherwise, restrict your review to the UI files actually present in the diff scope.
Design System Review (only for UI files in the diff scope):
1. **Visual Hierarchy** — Check that the changes maintain consistent heading levels, content flow, and information architecture
2. **Spacing and Typography** — Verify consistent spacing (margins, padding, gaps) and typography scale usage
3. **Color and Token Consistency** — Check that CSS custom properties and design tokens are used correctly; no hardcoded color values that bypass the design system
@@ -514,15 +521,16 @@ Design System Review:
5. **Responsive Behavior** — Check that layouts adapt properly across viewport sizes and maintain usability on mobile
6. **Fit with Design Language** — Verify the visual style matches existing patterns (border radius, shadows, transitions, icon style, etc.)
Files to Review:
Files to Review (only those that appear in the Diff Scope):
- Modified UI components (React, Vue, Angular, HTML)
- CSS/SCSS/styled-component files
- Design token or theme configuration files
Output Requirements:
- If design is consistent and polished: call task_done() with success status
- If issues found: describe each finding with specific file paths and suggested corrections via task_log()
- Prioritize issues by impact: layout breaks > visual inconsistency > style preferences`,
- If design is consistent and polished (or there are no UI files in scope): respond with a brief approval line and stop.
- If issues found: start your response with "REQUEST REVISION" and describe each finding with specific file paths and suggested corrections.
- Prioritize issues by impact: layout breaks > visual inconsistency > style preferences.
- Do NOT spend time on stylistic nits when no real issues exist.`,
},
];