feat(FN-4709): add codex oauth fallback delivery
Implements Codex OAuth fallback delivery as the final step of FN-4709, with a corresponding changeset for the `@runfusion/fusion` package and test coverage for the updated usage tracking logic. Fusion-Task-Id: FN-4709
This commit is contained in:
committed by
gsxdsm
parent
8137920b11
commit
bdda0e2da8
@@ -1,4 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const coreInteropMocks = vi.hoisted(() => ({
|
||||
choosePreferredStoredCredential: vi.fn(),
|
||||
readStoredCredentialsFromAuthFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
choosePreferredStoredCredential: coreInteropMocks.choosePreferredStoredCredential,
|
||||
readStoredCredentialsFromAuthFile: coreInteropMocks.readStoredCredentialsFromAuthFile,
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchAllProviderUsage,
|
||||
clearUsageCache,
|
||||
@@ -59,6 +70,10 @@ describe("usage", () => {
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
coreInteropMocks.choosePreferredStoredCredential.mockImplementation((...credentials: any[]) =>
|
||||
credentials.findLast((credential) => credential !== undefined)
|
||||
);
|
||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
|
||||
vi.stubEnv("HOME", "/home/testuser");
|
||||
});
|
||||
|
||||
@@ -1776,6 +1791,148 @@ describe("usage", () => {
|
||||
expect(codex).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to Fusion openai-codex oauth when codex auth.json is missing", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockImplementation((filePath: string) => {
|
||||
if (filePath.includes(".fusion/agent/auth.json")) {
|
||||
return {
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "fusion-access-token",
|
||||
refresh: "fusion-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
expect(options.headers.authorization).toBe("Bearer fusion-access-token");
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"email":"fusion@example.com","plan_type":"pro"}'));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex")!;
|
||||
expect(codex.status).toBe("ok");
|
||||
expect(codex.email).toBe("fusion@example.com");
|
||||
});
|
||||
|
||||
it("prefers codex auth.json over Fusion openai-codex oauth", async () => {
|
||||
mockReadFile.mockImplementation((filePath: string) => {
|
||||
if (filePath.includes(".codex/auth.json")) {
|
||||
return JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "codex-cli-token",
|
||||
id_token: "header.eyJlbWFpbCI6ImNsaUBleGFtcGxlLmNvbSJ9.signature",
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
|
||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "fusion-access-token",
|
||||
refresh: "fusion-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
expect(options.headers.authorization).toBe("Bearer codex-cli-token");
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"email":"cli@example.com"}'));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex")!;
|
||||
expect(codex.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns no-auth when Fusion openai-codex oauth is expired", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "fusion-access-token",
|
||||
refresh: "fusion-refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex");
|
||||
expect(codex).toBeUndefined();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no-auth when Fusion openai-codex entry is non-oauth", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
|
||||
"openai-codex": {
|
||||
type: "api_key",
|
||||
key: "not-a-bearer-token",
|
||||
},
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex");
|
||||
expect(codex).toBeUndefined();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces Fusion re-login guidance when Fusion-sourced token gets 401", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "fusion-access-token",
|
||||
refresh: "fusion-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"error":"unauthorized"}'));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const codex = providers.find((p) => p.name === "Codex")!;
|
||||
expect(codex.status).toBe("error");
|
||||
expect(codex.error).toContain("re-login from Fusion Settings");
|
||||
});
|
||||
|
||||
it("parses usage data from API response", async () => {
|
||||
const mockResponse = {
|
||||
email: "test@example.com",
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as https from "node:https";
|
||||
import * as child_process from "node:child_process";
|
||||
import { choosePreferredStoredCredential, readStoredCredentialsFromAuthFile } from "@fusion/core";
|
||||
import { getAuthFileCandidates } from "./auth-paths.js";
|
||||
|
||||
function getHomeDir(): string {
|
||||
@@ -1114,15 +1115,13 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
|
||||
|
||||
// ── Codex fetcher ──────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Codex",
|
||||
icon: "🟢",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
};
|
||||
type CodexCredential = {
|
||||
accessToken: string;
|
||||
idToken?: string;
|
||||
source: "codex-cli" | "fusion-auth";
|
||||
};
|
||||
|
||||
// Load Codex auth
|
||||
async function loadCodexCredential(): Promise<CodexCredential | null> {
|
||||
const codexHome = process.env.CODEX_HOME || path.join(getHomeDir(), ".codex");
|
||||
const authPath = path.join(codexHome, "auth.json");
|
||||
|
||||
@@ -1131,19 +1130,64 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
try {
|
||||
auth = JSON.parse(await readFile(authPath, "utf-8"));
|
||||
} catch {
|
||||
auth = null;
|
||||
}
|
||||
|
||||
const codexCliAccessToken = auth?.tokens?.access_token;
|
||||
if (typeof codexCliAccessToken === "string" && codexCliAccessToken.length > 0) {
|
||||
return {
|
||||
accessToken: codexCliAccessToken,
|
||||
idToken: typeof auth?.tokens?.id_token === "string" ? auth.tokens.id_token : undefined,
|
||||
source: "codex-cli",
|
||||
};
|
||||
}
|
||||
|
||||
let preferredCredential: ReturnType<typeof choosePreferredStoredCredential>;
|
||||
for (const candidatePath of getAuthFileCandidates()) {
|
||||
const authEntries = readStoredCredentialsFromAuthFile(candidatePath);
|
||||
preferredCredential = choosePreferredStoredCredential(preferredCredential, authEntries["openai-codex"]);
|
||||
}
|
||||
|
||||
if (
|
||||
preferredCredential?.type !== "oauth"
|
||||
|| typeof preferredCredential.access !== "string"
|
||||
|| preferredCredential.access.length === 0
|
||||
|| typeof preferredCredential.expires !== "number"
|
||||
|| !Number.isFinite(preferredCredential.expires)
|
||||
|| preferredCredential.expires <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: preferredCredential.access,
|
||||
source: "fusion-auth",
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
const usage: ProviderUsage = {
|
||||
name: "Codex",
|
||||
icon: "🟢",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
};
|
||||
|
||||
const credential = await loadCodexCredential();
|
||||
if (!credential) {
|
||||
usage.error = "No Codex credentials — run 'codex' to login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
const accessToken = auth?.tokens?.access_token;
|
||||
const accessToken = credential.accessToken;
|
||||
if (!accessToken) {
|
||||
usage.error = "No Codex access token found";
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Extract plan and email from id_token
|
||||
if (auth?.tokens?.id_token) {
|
||||
const claims = decodeJwtPayload(auth.tokens.id_token);
|
||||
// Extract plan and email from id_token when sourced from Codex CLI auth
|
||||
if (credential.source === "codex-cli" && credential.idToken) {
|
||||
const claims = decodeJwtPayload(credential.idToken);
|
||||
if (claims) {
|
||||
usage.email = claims.email || null;
|
||||
const openaiAuth = claims["https://api.openai.com/auth"];
|
||||
@@ -1163,7 +1207,10 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — run 'codex' to re-login";
|
||||
usage.error =
|
||||
credential.source === "fusion-auth"
|
||||
? "Auth expired — re-login from Fusion Settings or run 'codex'"
|
||||
: "Auth expired — run 'codex' to re-login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user