Skip to content

Commit 030e4d6

Browse files
authored
Merge pull request #835 from lidge-jun/agent/fix-cli-codex-health-auth-819
fix(cli): authenticate Codex health with admin token
2 parents dd1b90f + c9f18e5 commit 030e4d6

8 files changed

Lines changed: 117 additions & 17 deletions

File tree

src/cli/doctor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ export async function collectOAuthDoctorChecks(
149149
message:
150150
"Codex account health unavailable (proxy not running). Action: start the proxy and re-run `ocx doctor` to inspect live cooldown/reauth",
151151
});
152+
} else if (report.codexHealthSource === "management-auth-failed") {
153+
checks.push({
154+
level: "WARN",
155+
message:
156+
"Codex account health unavailable (proxy running; management authentication failed). Action: verify the admin token configuration, restart the proxy, and re-run `ocx doctor`",
157+
});
158+
} else if (report.codexHealthSource === "management-api-unavailable") {
159+
checks.push({
160+
level: "WARN",
161+
message:
162+
"Codex account health unavailable (proxy running; management API response failed). Action: inspect the proxy service log, restart the proxy if needed, and re-run `ocx doctor`",
163+
});
152164
}
153165
for (const entry of report.entries) {
154166
if (entry.health.status === "healthy") continue;

src/cli/status-oauth.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { maskAccountId } from "../lib/privacy";
22
import {
3+
CODEX_HEALTH_AUTH_FAILED_NOTE,
4+
CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE,
35
CODEX_HEALTH_UNAVAILABLE_NOTE,
46
MASKED_ACCOUNT_FALLBACK,
57
type OAuthAccountHealth,
@@ -59,8 +61,16 @@ export function formatOAuthHealthForStatus(
5961
: input;
6062

6163
const parts: string[] = [];
62-
if (report.codexHealthSource === "unavailable") {
63-
parts.push(CODEX_HEALTH_UNAVAILABLE_NOTE);
64+
switch (report.codexHealthSource) {
65+
case "unavailable":
66+
parts.push(CODEX_HEALTH_UNAVAILABLE_NOTE);
67+
break;
68+
case "management-auth-failed":
69+
parts.push(CODEX_HEALTH_AUTH_FAILED_NOTE);
70+
break;
71+
case "management-api-unavailable":
72+
parts.push(CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE);
73+
break;
6474
}
6575
const oauthBlock = formatEntryBlock(report.entries);
6676
if (oauthBlock) parts.push(oauthBlock);

src/oauth/health.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
33
import { isAccountNeedsReauth } from "../codex/account-runtime-state";
44
import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store";
55
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
6+
import { configuredAdminToken } from "../lib/admin-secrets";
67
import { maskAccountId } from "../lib/privacy";
7-
import { loadServiceTokenFromFile } from "../lib/service-secrets";
88
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
99
import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store";
1010
import type { ProviderAccount } from "./types";
@@ -320,36 +320,50 @@ function coerceRemoteAccountHealth(
320320
return projectOAuthAccountHealth({ needsReauth: account.needsReauth === true });
321321
}
322322

323+
type LiveProxyCodexHealthResult = {
324+
source: CodexHealthSource;
325+
entries: OAuthHealthEntry[] | null;
326+
};
327+
323328
async function fetchCodexHealthFromLiveProxy(
324329
fetchImpl: typeof fetch = fetch,
325330
findLiveProxyImpl: typeof findLiveProxy = findLiveProxy,
326-
): Promise<OAuthHealthEntry[] | null> {
331+
): Promise<LiveProxyCodexHealthResult> {
327332
const live = await findLiveProxyImpl();
328-
if (!live) return null;
329-
const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
333+
if (!live) return { source: "unavailable", entries: null };
334+
// This is a management-plane endpoint. A data-plane service token is intentionally not
335+
// interchangeable with the admin credential even on loopback.
336+
const token = configuredAdminToken();
330337
const headers: Record<string, string> = {};
331338
if (token) headers.Authorization = `Bearer ${token}`;
332339
try {
333340
const res = await fetchImpl(
334341
`http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`,
335342
{ headers, signal: AbortSignal.timeout(4000) },
336343
);
337-
if (!res.ok) return null;
344+
if (res.status === 401 || res.status === 403) {
345+
return { source: "management-auth-failed", entries: null };
346+
}
347+
if (!res.ok) return { source: "management-api-unavailable", entries: null };
338348
const json = await res.json() as { accounts?: ProxyCodexAccountHealth[] };
339-
if (!Array.isArray(json.accounts)) return null;
349+
if (!Array.isArray(json.accounts)) return { source: "management-api-unavailable", entries: null };
340350
const entries: OAuthHealthEntry[] = [];
341351
for (const account of json.accounts) {
342352
if (!account?.id || typeof account.id !== "string") continue;
343353
pushEntry(entries, "codex", account.id, coerceRemoteAccountHealth(account));
344354
}
345-
return entries;
355+
return { source: "management-api", entries };
346356
} catch {
347-
return null;
357+
return { source: "management-api-unavailable", entries: null };
348358
}
349359
}
350360

351361
/** How CLI/doctor obtained Codex cooldown/reauth (proxy memory only lives in the proxy). */
352-
export type CodexHealthSource = "management-api" | "unavailable";
362+
export type CodexHealthSource =
363+
| "management-api"
364+
| "unavailable"
365+
| "management-auth-failed"
366+
| "management-api-unavailable";
353367

354368
export type OAuthCliHealthReport = {
355369
entries: OAuthHealthEntry[];
@@ -359,6 +373,10 @@ export type OAuthCliHealthReport = {
359373
/** Shown by `ocx status` / `ocx doctor` when the proxy management API is unreachable. */
360374
export const CODEX_HEALTH_UNAVAILABLE_NOTE =
361375
"Codex health: unavailable (proxy not running; live cooldown/reauth requires the management API)";
376+
export const CODEX_HEALTH_AUTH_FAILED_NOTE =
377+
"Codex health: unavailable (proxy running; management authentication failed)";
378+
export const CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE =
379+
"Codex health: unavailable (proxy running; management API did not return account health)";
362380

363381
/**
364382
* CLI/doctor collector: observe-only OAuth store reads, and Codex health only from the
@@ -373,9 +391,9 @@ export async function collectOAuthHealthEntriesForCli(
373391
): Promise<OAuthCliHealthReport> {
374392
const entries = collectOAuthHealthEntries(now, { observeOnly: true, includeLocalCodex: false });
375393
const remote = await fetchCodexHealthFromLiveProxy(deps.fetchImpl, deps.findLiveProxyImpl);
376-
if (remote) {
377-
for (const entry of remote) entries.push(entry);
394+
if (remote.entries) {
395+
for (const entry of remote.entries) entries.push(entry);
378396
return { entries, codexHealthSource: "management-api" };
379397
}
380-
return { entries, codexHealthSource: "unavailable" };
398+
return { entries, codexHealthSource: remote.source };
381399
}

src/oauth/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { resolveProviderTransport } from "../providers/xai-transport";
2020
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
2121
import { logOAuthEvent } from "./log";
2222
export {
23+
CODEX_HEALTH_AUTH_FAILED_NOTE,
24+
CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE,
2325
CODEX_HEALTH_UNAVAILABLE_NOTE,
2426
MASKED_ACCOUNT_FALLBACK,
2527
collectOAuthHealthEntries,

structure/05_gui-and-management-api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ OpenCodex uses three mutually exclusive admission credential classes:
2323
The service token file remains a delivery mechanism for the data-plane environment token; it is not
2424
a fourth credential class. A management credential that equals any configured data-plane credential
2525
does not enable management access. The data plane may continue to start, but `/api/*` remains closed.
26+
CLI health collection follows the same boundary: `ocx status` and `ocx doctor` use the configured
27+
management credential for `/api/codex-auth/accounts`, never the service/data-plane token. Their
28+
output distinguishes a missing proxy, rejected management authentication, and an unexpected
29+
management response so a reachable `401` cannot be reported as "proxy not running."
2630

2731
Management authentication never has a loopback bypass. If no management credential is available, or
2832
management token creation, validation, or permission hardening fails, every `/api/*` request returns

tests/cli-status-oauth-health.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ describe("formatOAuthHealthForStatus", () => {
7676
expect(text).toContain("Codex health: unavailable");
7777
expect(text).toContain("management API");
7878
});
79+
80+
test("does not call an authentication failure a stopped proxy", () => {
81+
const text = formatOAuthHealthForStatus({
82+
entries: [],
83+
codexHealthSource: "management-auth-failed",
84+
});
85+
expect(text).toContain("proxy running");
86+
expect(text).toContain("management authentication failed");
87+
expect(text).not.toContain("proxy not running");
88+
});
7989
});
8090

8191
describe("collectOAuthHealthEntries via status formatter", () => {

tests/doctor-oauth.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ describe("collectOAuthDoctorChecks", () => {
7575
expect(warn!.message).toContain("start the proxy");
7676
});
7777

78+
test("labels management auth failure without claiming the proxy is down", async () => {
79+
const checks = await collectOAuthDoctorChecks(Date.now(), {
80+
findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }),
81+
fetchImpl: async () => new Response("unauthorized", { status: 401 }),
82+
});
83+
const warn = checks.find((c) => c.level === "WARN" && c.message.includes("Codex account health unavailable"));
84+
expect(warn).toBeTruthy();
85+
expect(warn!.message).toContain("proxy running");
86+
expect(warn!.message).toContain("management authentication failed");
87+
expect(warn!.message).not.toContain("proxy not running");
88+
expect(warn!.message).toContain("Action:");
89+
});
90+
7891
test("Codex needsReauth WARN comes from management API, not CLI process maps", async () => {
7992
const checks = await collectOAuthDoctorChecks(Date.now(), {
8093
findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }),

tests/oauth-health.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { formatOAuthHealthForStatus } from "../src/cli/status-oauth";
2525

2626
const origHome = process.env.HOME;
2727
const origOcxHome = process.env.OPENCODEX_HOME;
28+
const origAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN;
2829
let tmp: string;
2930

3031
beforeEach(() => {
@@ -40,6 +41,8 @@ afterEach(() => {
4041
else process.env.HOME = origHome;
4142
if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME;
4243
else process.env.OPENCODEX_HOME = origOcxHome;
44+
if (origAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN;
45+
else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = origAdminToken;
4346
clearCodexUpstreamHealth();
4447
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
4548
rmSync(tmp, { recursive: true, force: true });
@@ -170,10 +173,13 @@ describe("collectOAuthHealthEntries", () => {
170173
describe("collectOAuthHealthEntriesForCli", () => {
171174
test("uses management API Codex health and does not read CLI process maps", async () => {
172175
markCodexAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
176+
process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test";
177+
let authorization: string | null = null;
173178
const report = await collectOAuthHealthEntriesForCli(Date.now(), {
174179
findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }),
175-
fetchImpl: async () =>
176-
new Response(JSON.stringify({
180+
fetchImpl: async (_input, init) => {
181+
authorization = new Headers(init?.headers).get("authorization");
182+
return new Response(JSON.stringify({
177183
accounts: [{
178184
id: "proxy-codex-acct",
179185
health: {
@@ -182,8 +188,10 @@ describe("collectOAuthHealthEntriesForCli", () => {
182188
reason: "rate_limit",
183189
},
184190
}],
185-
}), { status: 200 }),
191+
}), { status: 200 });
192+
},
186193
});
194+
expect(authorization).toBe("Bearer ocx-admin-health-test");
187195
expect(report.codexHealthSource).toBe("management-api");
188196
expect(report.entries.some(e => e.accountId === MAIN_CODEX_ACCOUNT_ID)).toBe(false);
189197
const remote = report.entries.find(e => e.accountId === "proxy-codex-acct");
@@ -207,6 +215,29 @@ describe("collectOAuthHealthEntriesForCli", () => {
207215
expect(text).not.toContain(MAIN_CODEX_ACCOUNT_ID);
208216
});
209217

218+
test("distinguishes management authentication failure from a stopped proxy", async () => {
219+
const report = await collectOAuthHealthEntriesForCli(Date.now(), {
220+
findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }),
221+
fetchImpl: async () => new Response("unauthorized", { status: 401 }),
222+
});
223+
expect(report.codexHealthSource).toBe("management-auth-failed");
224+
const text = formatOAuthHealthForStatus(report);
225+
expect(text).toContain("proxy running");
226+
expect(text).toContain("management authentication failed");
227+
expect(text).not.toContain("proxy not running");
228+
});
229+
230+
test("distinguishes an invalid management response from a stopped proxy", async () => {
231+
const report = await collectOAuthHealthEntriesForCli(Date.now(), {
232+
findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }),
233+
fetchImpl: async () => new Response("upstream error", { status: 500 }),
234+
});
235+
expect(report.codexHealthSource).toBe("management-api-unavailable");
236+
const text = formatOAuthHealthForStatus(report);
237+
expect(text).toContain("proxy running");
238+
expect(text).toContain("management API did not return account health");
239+
});
240+
210241
test("malformed remote health is re-derived instead of rendering undefined", async () => {
211242
const report = await collectOAuthHealthEntriesForCli(Date.now(), {
212243
findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }),

0 commit comments

Comments
 (0)