fix(auth): stop Codex OAuth refresh spam

Treat high-confidence app-server OAuth refresh invalidation as terminal auth-profile failure, while keeping entitlement and rate-limit payloads out of re-auth classification.
This commit is contained in:
Vincent Koc
2026-05-14 13:28:47 +08:00
committed by GitHub
parent 6cac228a0c
commit 3485a907d1
4 changed files with 69 additions and 16 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Canvas: return not found for malformed percent-encoded Canvas/A2UI/document asset paths and keep decoded parent traversal blocked before path normalization.
- Agents: allow dot-dot-prefixed filenames such as `..note.txt` through sandbox FS bridge, remote sandbox reads, and apply_patch summaries without mistaking the name for parent traversal.
- CLI/migrate: hide per-item source/plugin hints on non-conflicting Codex skill and plugin selection prompts, keeping the hint text reserved for rows that actually need attention. Thanks @sjf.
- Codex harness: treat high-confidence app-server OAuth refresh invalidation as a terminal auth-profile failure, stopping repeated raw token-refresh errors without turning entitlement or usage-limit payloads into re-auth prompts.
- CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf.
- CLI/migrate: render migrate result rows with distinct glyphs for manual-review (🔍) and archive (📖) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf.
- CLI/migrate: split Codex migrate output into separate preview and result phases so the Before plan and After result render through clack with independently tunable copy. Thanks @sjf.
@@ -43,7 +44,7 @@ Docs: https://docs.openclaw.ai
- macOS/companion: require system TLS trust before pinning a first-use direct `wss://` gateway certificate and honor `gateway.remote.tlsFingerprint` as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev.
- Update: snapshot config before update-time repair and restart writes, preserve plugin install records through doctor cleanup, and keep update-time config size drops from blocking the update while pointing users to the pre-update backup. Fixes #80077. (#80257) Thanks @Jerry-Xin and @vincentkoc.
- WebChat/TUI: route Codex `tools.message` source replies to the active internal UI turn and mirror them to session history, so message-tool-only harness replies, including rich presentation and button-only replies, no longer disappear while WebChat and TUI remain non-targetable outbound channels. (#81586) Thanks @pashpashpash.
- Codex auth: accept OAuth profiles backed by `oauthRef` during runtime auth selection, so official Codex OAuth logins are used by app-server agent runs. (#81633)
- Codex auth: accept OAuth profiles backed by `oauthRef` during runtime auth selection, so official Codex OAuth logins are used by app-server agent runs. (#81633) Thanks @obviyus.
- Sessions/status: classify ACP spawn-child sessions as `kind: "spawn-child"` instead of `"direct"` in `openclaw sessions` and status output; extract the duplicated session-kind classifier into a shared helper (`src/sessions/classify-session-kind.ts`) so both surfaces stay in sync. Fixes catalog #19. (#79544)
- Sessions/Gateway: report `agentRuntime.id: "acpx"` (or stored backend id) with `source: "session-key"` for ACP control-plane session rows in `openclaw sessions --json`, `openclaw status`, and Gateway session RPC responses instead of the incorrect `"auto"` / `"pi"` implicit fallback. Fixes catalog #18. (#79550)
- Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies.

View File

@@ -676,6 +676,41 @@ describe("bridgeCodexAppServerStartOptions", () => {
).toBe("openai-codex:default");
});
it("answers refresh requests from a discovered oauthRef-backed Codex profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
access: "refreshed-ref-backed-access-token",
refresh: "refreshed-ref-backed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-ref-backed-refreshed",
});
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "ref-backed-access-token",
refresh: "ref-backed-refresh-token",
expires: Date.now() + 60_000,
accountId: "account-ref-backed",
email: "codex@example.test",
},
});
await expect(refreshCodexAppServerAuthTokens({ agentDir })).resolves.toEqual({
accessToken: "refreshed-ref-backed-access-token",
chatgptAccountId: "account-ref-backed-refreshed",
chatgptPlanType: null,
});
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("ref-backed-refresh-token");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("applies native Codex CLI OAuth when no OpenClaw auth profile exists", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const agentDir = path.join(root, "agent");

View File

@@ -1442,21 +1442,34 @@ describe("classifyProviderRuntimeFailureKind", () => {
});
it("classifies OAuth refresh failures", () => {
expect(
classifyProviderRuntimeFailureKind(
"OAuth token refresh failed for openai-codex: invalid_grant. Please try again or re-authenticate.",
),
).toBe("auth_refresh");
expect(
classifyProviderRuntimeFailureKind(
"Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
),
).toBe("auth_refresh");
expect(
classifyProviderRuntimeFailureKind(
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
),
).toBe("auth_refresh");
const refreshFailures = [
"OAuth token refresh failed for openai-codex: invalid_grant. Please try again or re-authenticate.",
"Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
];
for (const message of refreshFailures) {
expect(classifyProviderRuntimeFailureKind(message)).toBe("auth_refresh");
expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("auth_permanent");
}
});
it("does not make uncertain OAuth refresh wrappers terminal", () => {
const message =
"OAuth token refresh failed for openai-codex: file lock timeout for /tmp/agent/auth-profiles.json. Please try again or re-authenticate.";
expect(classifyProviderRuntimeFailureKind(message)).toBe("auth_refresh");
expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("auth");
});
it("keeps Codex entitlement and usage-limit payloads out of terminal auth", () => {
const entitlementMessages = [
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), try again after 11:34 AM.",
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits, try again later.",
'429 {"type":"error","error":{"type":"rate_limit_error","message":"You\\u0027ve hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), try again after 11:34 AM."}}',
];
for (const message of entitlementMessages) {
expect(classifyProviderRuntimeFailureKind(message)).not.toBe("auth_refresh");
expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("rate_limit");
}
});
it("classifies OAuth refresh timeouts and lock contention distinctly", () => {

View File

@@ -854,6 +854,10 @@ function classifyFailoverClassificationFromMessage(
if (isBillingErrorMessage(raw)) {
return toReasonClassification("billing");
}
const oauthRefreshFailure = classifyOAuthRefreshFailure(raw);
if (oauthRefreshFailure?.reason) {
return toReasonClassification("auth_permanent");
}
if (isAuthPermanentErrorMessage(raw)) {
return toReasonClassification("auth_permanent");
}