Auth: sanitize reauth commands

This commit is contained in:
Mariano Belinky
2026-04-08 17:40:06 +02:00
parent 4ceea1aab2
commit ae073f9820
6 changed files with 82 additions and 57 deletions

View File

@@ -1,3 +1,7 @@
import { formatCliCommand } from "../../cli/command-format.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { normalizeProviderId } from "../model-selection.js";
export type OAuthRefreshFailureReason =
| "refresh_token_reused"
| "invalid_grant"
@@ -6,12 +10,21 @@ export type OAuthRefreshFailureReason =
| "revoked";
const OAUTH_REFRESH_FAILURE_PROVIDER_RE = /OAuth token refresh failed for ([^:]+):/i;
const SAFE_PROVIDER_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
export function extractOAuthRefreshFailureProvider(message: string): string | null {
const provider = message.match(OAUTH_REFRESH_FAILURE_PROVIDER_RE)?.[1]?.trim();
return provider && provider.length > 0 ? provider : null;
}
export function sanitizeOAuthRefreshFailureProvider(
provider: string | null | undefined,
): string | null {
const sanitized = provider ? sanitizeForLog(provider).replaceAll("`", "").trim() : "";
const normalized = normalizeProviderId(sanitized);
return normalized && SAFE_PROVIDER_ID_RE.test(normalized) ? normalized : null;
}
export function classifyOAuthRefreshFailureReason(
message: string,
): OAuthRefreshFailureReason | null {
@@ -42,7 +55,14 @@ export function classifyOAuthRefreshFailure(message: string): {
return null;
}
return {
provider: extractOAuthRefreshFailureProvider(message),
provider: sanitizeOAuthRefreshFailureProvider(extractOAuthRefreshFailureProvider(message)),
reason: classifyOAuthRefreshFailureReason(message),
};
}
export function buildOAuthRefreshFailureLoginCommand(provider: string | null | undefined): string {
const safeProvider = sanitizeOAuthRefreshFailureProvider(provider);
return safeProvider
? formatCliCommand(`openclaw models auth login --provider ${safeProvider}`)
: formatCliCommand("openclaw models auth login");
}

View File

@@ -1000,6 +1000,46 @@ describe("runAgentTurnWithFallback", () => {
}
});
it("falls back to a generic reauth command when the provider in the OAuth error is unsafe", async () => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error(
"OAuth token refresh failed for openai-codex`\nrm -rf /: invalid_grant. Please try again or re-authenticate.",
),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
commandBody: "hello",
followupRun: createFollowupRun(),
sessionCtx: {
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext,
opts: {},
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterCompactionFailure: async () => false,
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(
"⚠️ Model login expired on the gateway. Re-auth with `openclaw models auth login`, then try again.",
);
}
});
it("returns a session reset hint for Bedrock tool mismatch errors on external chat channels", async () => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error(

View File

@@ -4,7 +4,10 @@ import {
hasOutboundReplyContent,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { classifyOAuthRefreshFailure } from "../../agents/auth-profiles/oauth-refresh-failure.js";
import {
buildOAuthRefreshFailureLoginCommand,
classifyOAuthRefreshFailure,
} from "../../agents/auth-profiles/oauth-refresh-failure.js";
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionBinding } from "../../agents/cli-session.js";
@@ -311,9 +314,7 @@ function buildExternalRunFailureText(message: string): string {
}
const oauthRefreshFailure = classifyOAuthRefreshFailure(message);
if (oauthRefreshFailure) {
const loginCommand = oauthRefreshFailure.provider
? `openclaw models auth login --provider ${oauthRefreshFailure.provider}`
: "openclaw models auth login --provider <provider>";
const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider);
if (oauthRefreshFailure.reason) {
return `⚠️ Model login expired on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Re-auth with \`${loginCommand}\`, then try again.`;
}

View File

@@ -38,49 +38,6 @@ afterEach(() => {
});
describe("buildEmbeddedRunBaseParams runtime config", () => {
it("prefers the active runtime snapshot when queued reply config still contains SecretRefs", () => {
const sourceConfig: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
},
models: [],
},
},
},
};
const runtimeConfig: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "resolved-runtime-key",
models: [],
},
},
},
};
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
const resolved = buildEmbeddedRunBaseParams({
run: makeRun(sourceConfig),
provider: "openai",
model: "gpt-4.1-mini",
runId: "run-1",
authProfile: resolveProviderScopedAuthProfile({
provider: "openai",
primaryProvider: "openai",
}),
});
expect(resolved.config).toBe(runtimeConfig);
});
it("keeps an already-resolved run config instead of reverting to a stale runtime snapshot", () => {
const staleSnapshot: OpenClawConfig = {
models: {

View File

@@ -51,4 +51,17 @@ describe("resolveUnusableProfileHint", () => {
"- openai-codex:default: OAuth refresh failed — Try again; if this persists, run `openclaw models auth login --provider openai-codex`.",
);
});
it("drops the provider-specific command when the parsed provider is unsafe", () => {
expect(
formatOAuthRefreshFailureDoctorLine({
profileId: "openai-codex:default",
provider: "openai-codex",
message:
"OAuth token refresh failed for openai-codex`\nrm -rf /: invalid_grant. Please try again or re-authenticate.",
}),
).toBe(
"- openai-codex:default: re-auth required [invalid_grant] — Run `openclaw models auth login`.",
);
});
});

View File

@@ -12,20 +12,17 @@ import {
} from "../agents/auth-profiles.js";
import { formatAuthDoctorHint } from "../agents/auth-profiles/doctor.js";
import {
buildOAuthRefreshFailureLoginCommand,
classifyOAuthRefreshFailure,
type OAuthRefreshFailureReason,
} from "../agents/auth-profiles/oauth-refresh-failure.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
import { note } from "../terminal/note.js";
import { isRecord } from "../utils.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import {
buildProviderAuthRecoveryHint,
resolveProviderAuthLoginCommand,
} from "./provider-auth-guidance.js";
import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js";
const CODEX_PROVIDER_ID = "openai-codex";
const CODEX_OAUTH_WARNING_TITLE = "Codex OAuth";
@@ -202,10 +199,7 @@ export function formatOAuthRefreshFailureDoctorLine(params: {
return null;
}
const provider = classified.provider ?? params.provider;
const command =
resolveProviderAuthLoginCommand({
provider,
}) ?? formatCliCommand(`openclaw models auth login --provider ${provider}`);
const command = buildOAuthRefreshFailureLoginCommand(provider);
if (classified.reason) {
return `- ${params.profileId}: re-auth required [${formatOAuthRefreshFailureReason(classified.reason)}] — Run \`${command}\`.`;
}