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
5
.changeset/fn-4709-codex-fusion-oauth-fallback.md
Normal file
5
.changeset/fn-4709-codex-fusion-oauth-fallback.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Codex usage panel now falls back to the Fusion-stored `openai-codex` OAuth credential (`~/.fusion/agent/auth.json`) when the Codex CLI `auth.json` is missing, so Fusion OAuth users no longer see a spurious "run codex to login" error.
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 {
|
import {
|
||||||
fetchAllProviderUsage,
|
fetchAllProviderUsage,
|
||||||
clearUsageCache,
|
clearUsageCache,
|
||||||
@@ -59,6 +70,10 @@ describe("usage", () => {
|
|||||||
mockExecFileSync.mockImplementation(() => {
|
mockExecFileSync.mockImplementation(() => {
|
||||||
throw new Error("File not found");
|
throw new Error("File not found");
|
||||||
});
|
});
|
||||||
|
coreInteropMocks.choosePreferredStoredCredential.mockImplementation((...credentials: any[]) =>
|
||||||
|
credentials.findLast((credential) => credential !== undefined)
|
||||||
|
);
|
||||||
|
coreInteropMocks.readStoredCredentialsFromAuthFile.mockReturnValue({});
|
||||||
vi.stubEnv("HOME", "/home/testuser");
|
vi.stubEnv("HOME", "/home/testuser");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1776,6 +1791,148 @@ describe("usage", () => {
|
|||||||
expect(codex).toBeUndefined();
|
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 () => {
|
it("parses usage data from API response", async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import * as https from "node:https";
|
import * as https from "node:https";
|
||||||
import * as child_process from "node:child_process";
|
import * as child_process from "node:child_process";
|
||||||
|
import { choosePreferredStoredCredential, readStoredCredentialsFromAuthFile } from "@fusion/core";
|
||||||
import { getAuthFileCandidates } from "./auth-paths.js";
|
import { getAuthFileCandidates } from "./auth-paths.js";
|
||||||
|
|
||||||
function getHomeDir(): string {
|
function getHomeDir(): string {
|
||||||
@@ -1114,15 +1115,13 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
|
|||||||
|
|
||||||
// ── Codex fetcher ──────────────────────────────────────────────────────────
|
// ── Codex fetcher ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchCodexUsage(): Promise<ProviderUsage> {
|
type CodexCredential = {
|
||||||
const usage: ProviderUsage = {
|
accessToken: string;
|
||||||
name: "Codex",
|
idToken?: string;
|
||||||
icon: "🟢",
|
source: "codex-cli" | "fusion-auth";
|
||||||
status: "no-auth",
|
};
|
||||||
windows: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load Codex auth
|
async function loadCodexCredential(): Promise<CodexCredential | null> {
|
||||||
const codexHome = process.env.CODEX_HOME || path.join(getHomeDir(), ".codex");
|
const codexHome = process.env.CODEX_HOME || path.join(getHomeDir(), ".codex");
|
||||||
const authPath = path.join(codexHome, "auth.json");
|
const authPath = path.join(codexHome, "auth.json");
|
||||||
|
|
||||||
@@ -1131,19 +1130,64 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
|||||||
try {
|
try {
|
||||||
auth = JSON.parse(await readFile(authPath, "utf-8"));
|
auth = JSON.parse(await readFile(authPath, "utf-8"));
|
||||||
} catch {
|
} 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";
|
usage.error = "No Codex credentials — run 'codex' to login";
|
||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = auth?.tokens?.access_token;
|
const accessToken = credential.accessToken;
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
usage.error = "No Codex access token found";
|
usage.error = "No Codex access token found";
|
||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract plan and email from id_token
|
// Extract plan and email from id_token when sourced from Codex CLI auth
|
||||||
if (auth?.tokens?.id_token) {
|
if (credential.source === "codex-cli" && credential.idToken) {
|
||||||
const claims = decodeJwtPayload(auth.tokens.id_token);
|
const claims = decodeJwtPayload(credential.idToken);
|
||||||
if (claims) {
|
if (claims) {
|
||||||
usage.email = claims.email || null;
|
usage.email = claims.email || null;
|
||||||
const openaiAuth = claims["https://api.openai.com/auth"];
|
const openaiAuth = claims["https://api.openai.com/auth"];
|
||||||
@@ -1163,7 +1207,10 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
|||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
usage.status = "error";
|
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;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user