fix(FN-XXX): unify codex auth and chat fallback
This commit is contained in:
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal file
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
243
packages/core/src/oauth-credential-interop.ts
Normal file
243
packages/core/src/oauth-credential-interop.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user