Compare commits

...

1 Commits

Author SHA1 Message Date
Alex Knight
2f7bf4c025 fix: warn about stale OpenAI keys with Codex auth 2026-05-26 07:15:27 +10:00
5 changed files with 256 additions and 7 deletions

View File

@@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai
- Config: keep benign legacy metadata write anomalies out of default doctor and config command output while preserving explicit anomaly logging for diagnostics.
- Codex: log when implicit app-server `never` approvals are promoted for OpenClaw tool policy, including whether the trigger was a `before_tool_call` hook or trusted tool policy.
- Codex harness: make subscription usage-limit errors without reset times explain that OpenClaw cannot determine the reset and point users to wait until Codex is available, use another Codex account, or switch to another configured model/provider. Thanks @amknight.
- Models/Codex: warn when stale OpenAI API-key auth remains beside Codex OAuth routes, and skip importing Codex `OPENAI_API_KEY` values when ChatGPT OAuth is present. Thanks @amknight.
- Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago.
- Telegram: route normal `[telegram][diag]` polling diagnostics through `runtime.log` while keeping non-diag warnings and persistence failures on `runtime.error`, so healthy polling startup no longer looks like an error. Fixes #82957. (#82958) Thanks @galiniliev.

View File

@@ -30,6 +30,8 @@ const CODEX_REASON_AUTH_PROFILE_EXISTS = "auth profile exists";
const CODEX_REASON_AUTH_PROFILE_WRITE_FAILED = "failed to write auth profile";
const CODEX_REASON_AUTH_NO_LONGER_PRESENT = "auth credential no longer present";
const CODEX_REASON_MISSING_AUTH_METADATA = "missing auth metadata";
const CODEX_REASON_API_KEY_IGNORED_OAUTH_PRESENT =
"Codex OpenAI API key ignored because ChatGPT OAuth is present";
const CODEX_CONFIG_PATCH_MODE_RETURN = "return";
type CodexMigrationTargets = ReturnType<typeof resolveCodexMigrationTargets>;
@@ -61,6 +63,11 @@ type CodexAuthProfileConfig = {
displayName?: string;
};
type CodexAuthCredentials = {
credentials: CodexAuthCredential[];
ignoredApiKey: boolean;
};
type CodexAuthConfigApplyResult = "configured" | "conflict" | "unavailable";
class CodexAuthConfigConflict extends Error {}
@@ -245,10 +252,22 @@ async function buildCodexApiKeyCredential(
};
}
async function readCodexAuthCredentials(source: CodexSource): Promise<CodexAuthCredential[]> {
async function readCodexAuthMode(source: CodexSource): Promise<string | undefined> {
const raw = await readJsonObject(source.authPath);
return readString(raw.auth_mode)?.toLowerCase();
}
async function readCodexAuthCredentials(source: CodexSource): Promise<CodexAuthCredentials> {
const oauth = await buildCodexOAuthCredential(source);
const apiKey = await buildCodexApiKeyCredential(source);
return [oauth, apiKey].filter((entry): entry is CodexAuthCredential => entry !== null);
const authMode = await readCodexAuthMode(source);
const ignoredApiKey = Boolean(oauth && apiKey && authMode !== "apikey");
return {
credentials: [oauth, ignoredApiKey ? null : apiKey].filter(
(entry): entry is CodexAuthCredential => entry !== null,
),
ignoredApiKey,
};
}
function findMatchingOAuthProfile(
@@ -548,13 +567,13 @@ export async function buildCodexAuthItems(params: {
source: CodexSource;
targets: CodexMigrationTargets;
}): Promise<MigrationItem[]> {
const credentials = await readCodexAuthCredentials(params.source);
if (credentials.length === 0) {
const auth = await readCodexAuthCredentials(params.source);
if (auth.credentials.length === 0 && !auth.ignoredApiKey) {
return [];
}
const store = loadAuthProfileStoreWithoutExternalProfiles(params.targets.agentDir);
const skipped = !params.ctx.includeSecrets;
return credentials.map((credential) => {
const items = auth.credentials.map((credential) => {
const { profileId, matchedExisting } = itemProfileTarget(credential, store);
const targetExists = Boolean(store.profiles[profileId]);
const configProfile = authProfileConfigForCredential(credential, profileId);
@@ -593,6 +612,28 @@ export async function buildCodexAuthItems(params: {
},
});
});
if (auth.ignoredApiKey) {
items.push(
createMigrationItem({
id: "auth:openai:codex-api-key-ignored",
kind: "auth",
action: "skip",
source: params.source.authPath,
status: "warning",
sensitive: true,
reason: CODEX_REASON_API_KEY_IGNORED_OAUTH_PRESENT,
message:
"Codex auth.json also contains OPENAI_API_KEY; OpenClaw leaves it unimported because ChatGPT OAuth is present.",
details: {
provider: OPENAI_PROVIDER_ID,
sourceKind: "codex-auth-json",
credentialKind: "api_key",
ignoredBecause: OPENAI_CODEX_PROVIDER_ID,
},
}),
);
}
return items;
}
export async function applyCodexAuthItem(params: {
@@ -612,7 +653,7 @@ export async function applyCodexAuthItem(params: {
if (!profileId || !provider) {
return markMigrationItemError(item, CODEX_REASON_MISSING_AUTH_METADATA);
}
const credential = (await readCodexAuthCredentials(source)).find(
const credential = (await readCodexAuthCredentials(source)).credentials.find(
(candidate) => candidate.provider === provider,
);
if (!credential) {
@@ -707,7 +748,7 @@ export async function buildCodexAuthConfigPatchItems(params: {
if (!profileId || !provider) {
return [];
}
const credential = (await readCodexAuthCredentials(source)).find(
const credential = (await readCodexAuthCredentials(source)).credentials.find(
(candidate) => candidate.provider === provider,
);
if (!credential) {

View File

@@ -442,6 +442,85 @@ describe("buildCodexMigrationProvider", () => {
);
});
it("warns and skips Codex auth.json API keys when ChatGPT OAuth is present", async () => {
const fixture = await createCodexFixture();
const reportDir = path.join(fixture.root, "report");
const accessToken = fakeJwt({
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_oauth",
chatgpt_plan_type: "plus",
},
"https://api.openai.com/profile": {
email: "codex@example.test",
},
});
await writeFile(
path.join(fixture.codexHome, "auth.json"),
JSON.stringify({
auth_mode: "chatgpt",
OPENAI_API_KEY: "sk-stale-platform",
tokens: {
access_token: accessToken,
refresh_token: "refresh-oauth-token",
account_id: "acct_oauth",
},
}),
);
const configState: MigrationProviderContext["config"] = {
agents: {
defaults: {
workspace: fixture.workspaceDir,
},
},
};
const provider = buildCodexMigrationProvider();
const ctx = makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
runtime: createConfigRuntime(configState),
reportDir,
includeSecrets: true,
});
const plan = await provider.plan(ctx);
expectRecordFields(findItem(plan.items, "auth:openai-codex"), { status: "planned" });
expect(findItem(plan.items, "auth:openai:codex-api-key-ignored")).toEqual(
expect.objectContaining({
status: "warning",
action: "skip",
reason: "Codex OpenAI API key ignored because ChatGPT OAuth is present",
details: expect.objectContaining({
provider: "openai",
credentialKind: "api_key",
ignoredBecause: "openai-codex",
}),
}),
);
const result = await provider.apply(ctx, plan);
expect(result.items.some((item) => item.id === "auth:openai")).toBe(false);
expect(findItem(result.items, "auth:openai:codex-api-key-ignored")).toEqual(
expect.objectContaining({ status: "warning" }),
);
const authStore = JSON.parse(
await fs.readFile(
path.join(fixture.stateDir, "agents", "main", "agent", "auth-profiles.json"),
"utf8",
),
) as { profiles?: Record<string, { provider?: string; type?: string }> };
expect(authStore.profiles?.["openai-codex:account-acct_oauth"]).toEqual(
expect.objectContaining({
type: "oauth",
provider: "openai-codex",
}),
);
expect(authStore.profiles?.["openai:codex-import"]).toBeUndefined();
});
it("reports late-created Codex API key config auth profile conflicts before writing", async () => {
const fixture = await createCodexFixture();
const reportDir = path.join(fixture.root, "report");

View File

@@ -31,6 +31,7 @@ import {
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import {
OPENAI_PROVIDER_ID,
OPENAI_CODEX_PROVIDER_ID,
openAIProviderUsesCodexRuntimeByDefault,
} from "../../agents/openai-codex-routing.js";
@@ -106,6 +107,16 @@ type StatusSyntheticAuth = {
expiresAt?: number;
};
type AuthStatusWarning = {
code: string;
severity: "warning";
message: string;
remediation: string;
providers: string[];
profiles?: string[];
sources?: string[];
};
function loadProviderUsageRuntime(): Promise<ProviderUsageRuntime> {
return providerUsageRuntimeLoader.load();
}
@@ -486,6 +497,48 @@ export async function modelsStatusCommand(
return hasAny;
});
const providerAuthMap = new Map(providerAuth.map((entry) => [entry.provider, entry]));
const codexAuthProfileOrder = resolveAuthProfileOrder({
cfg,
store,
provider: OPENAI_CODEX_PROVIDER_ID,
});
const codexOauthProfiles = codexAuthProfileOrder.filter((profileId) => {
const credential = store.profiles[profileId];
return (
credential?.provider === OPENAI_CODEX_PROVIDER_ID &&
(credential.type === "oauth" || credential.type === "token")
);
});
const openAIApiKeyProfilesInCodexOrder = codexAuthProfileOrder.filter((profileId) => {
const credential = store.profiles[profileId];
return credential?.provider === OPENAI_PROVIDER_ID && credential.type === "api_key";
});
const authWarnings: AuthStatusWarning[] = [];
const openAIProviderAuth = providerAuthMap.get(OPENAI_PROVIDER_ID);
const directOpenAISources = [
...openAIApiKeyProfilesInCodexOrder.map((profileId) => `profile:${profileId}`),
...(openAIProviderAuth?.env ? [`env:${openAIProviderAuth.env.source}`] : []),
...(openAIProviderAuth?.modelsJson
? [`models.json:${openAIProviderAuth.modelsJson.source}`]
: []),
];
if (
codexRuntimeAuthUsages.length > 0 &&
codexOauthProfiles.length > 0 &&
directOpenAISources.length > 0
) {
authWarnings.push({
code: "openai-api-key-with-codex-oauth",
severity: "warning",
message:
"OpenAI API-key auth is still configured while OpenAI models are routed through Codex OAuth. A stale platform key can cause OpenAI quota or billing errors if it is selected by a fallback path.",
remediation:
"Remove OPENAI_API_KEY/openai:* API-key profiles, or pin auth.order.openai-codex to the intended openai-codex OAuth profile if the API key is only for direct OpenAI use.",
providers: [OPENAI_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID],
profiles: openAIApiKeyProfilesInCodexOrder,
sources: directOpenAISources,
});
}
const missingProviderAuthEffective: ProviderAuthOverview["effective"] = {
kind: "missing",
detail: "missing",
@@ -787,6 +840,7 @@ export async function modelsStatusCommand(
providersWithOAuth: providersWithOauth,
missingProvidersInUse,
runtimeAuthRoutes,
warnings: authWarnings,
providers: providerAuth,
unusableProfiles,
oauth: {
@@ -993,6 +1047,15 @@ export async function modelsStatusCommand(
}
}
if (authWarnings.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.heading, "Auth warnings"));
for (const warning of authWarnings) {
runtime.log(`- ${colorize(rich, theme.warn, warning.code)} ${warning.message}`);
runtime.log(` ${colorize(rich, theme.muted, warning.remediation)}`);
}
}
if (missingProvidersInUse.length > 0) {
const { buildProviderAuthRecoveryHint } = await import("../provider-auth-guidance.js");
runtime.log("");

View File

@@ -838,6 +838,71 @@ describe("modelsStatusCommand auth overview", () => {
}
});
it("warns when OpenAI API-key auth remains beside Codex OAuth runtime auth", async () => {
const localRuntime = createRuntime();
const originalLoadConfig = mocks.loadConfig.getMockImplementation();
const originalProfiles = { ...mocks.store.profiles };
const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined;
mocks.loadConfig.mockReturnValue({
agents: {
defaults: {
model: { primary: "openai/gpt-5.5", fallbacks: [] },
models: { "openai/gpt-5.5": {} },
},
},
models: { providers: {} },
env: { shellEnv: { enabled: true } },
});
mocks.store.profiles = {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "usable-access",
refresh: "usable-refresh",
expires: Date.now() + 60_000,
},
"openai:default": {
type: "api_key",
provider: "openai",
key: "abc123",
},
};
mocks.store.order = {
openai: ["openai:default"],
};
try {
await modelsStatusCommand({ json: true }, localRuntime as never);
const payload = parseFirstJsonLog(localRuntime);
expect(payload.auth.warnings).toEqual([
expect.objectContaining({
code: "openai-api-key-with-codex-oauth",
severity: "warning",
providers: ["openai", "openai-codex"],
profiles: ["openai:default"],
sources: expect.arrayContaining([
"profile:openai:default",
"env:shell env: OPENAI_API_KEY",
]),
}),
]);
expect(payload.auth.runtimeAuthRoutes).toEqual([
expect.objectContaining({
provider: "openai",
runtime: "codex",
authProvider: "openai-codex",
status: "usable",
}),
]);
} finally {
mocks.store.profiles = originalProfiles;
mocks.store.order = originalOrder;
if (originalLoadConfig) {
mocks.loadConfig.mockImplementation(originalLoadConfig);
}
}
});
it("fails --check when an in-use provider alias has expired canonical auth health", async () => {
const localRuntime = createRuntime();
const originalLoadConfig = mocks.loadConfig.getMockImplementation();