Skip to content

Commit 35eb8d9

Browse files
committed
fix(agents): dispatch auth failures by type
1 parent 30819ed commit 35eb8d9

13 files changed

Lines changed: 221 additions & 31 deletions

src/agents/auth-profiles/oauth-manager.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
log,
1212
} from "./constants.js";
1313
import { shouldMirrorRefreshedOAuthCredential } from "./oauth-identity.js";
14+
import { OAuthRefreshFailureError } from "./oauth-refresh-failure.js";
1415
import {
1516
buildRefreshContentionError,
1617
isGlobalRefreshLockTimeoutError,
@@ -61,9 +62,8 @@ export type ResolvedOAuthAccess = {
6162
credential: OAuthCredential;
6263
};
6364

64-
export class OAuthManagerRefreshError extends Error {
65+
export class OAuthManagerRefreshError extends OAuthRefreshFailureError {
6566
readonly profileId: string;
66-
readonly provider: string;
6767
readonly code?: string;
6868
readonly lockPath?: string;
6969
readonly #refreshedStore: AuthProfileStore;
@@ -91,13 +91,14 @@ export class OAuthManagerRefreshError extends Error {
9191
storedCredential?.type === "oauth" ? storedCredential : undefined,
9292
);
9393
const causeMessage = formatRedactedOAuthRefreshError(params.cause, secrets);
94-
super(`OAuth token refresh failed for ${params.credential.provider}: ${causeMessage}`, {
94+
super({
95+
provider: params.credential.provider,
96+
message: `OAuth token refresh failed for ${params.credential.provider}: ${causeMessage}`,
9597
cause: createRedactedOAuthRefreshCause(delegatedCause, secrets),
9698
});
9799
this.name = "OAuthManagerRefreshError";
98100
this.#credential = params.credential;
99101
this.profileId = params.profileId;
100-
this.provider = params.credential.provider;
101102
this.#refreshedStore = params.refreshedStore;
102103
if (structuredCause) {
103104
this.code = typeof structuredCause.code === "string" ? structuredCause.code : undefined;

src/agents/auth-profiles/oauth-refresh-failure.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
22
import {
33
buildOAuthRefreshFailureLoginCommand,
44
classifyOAuthRefreshFailure,
5+
classifyOAuthRefreshFailureError,
6+
OAuthRefreshFailureError,
57
} from "./oauth-refresh-failure.js";
68

79
describe("oauth refresh failure hints", () => {
@@ -16,4 +18,18 @@ describe("oauth refresh failure hints", () => {
1618
"openclaw models auth login --provider openai",
1719
);
1820
});
21+
22+
it("classifies typed refresh failures without parsing the display message", () => {
23+
expect(
24+
classifyOAuthRefreshFailureError(
25+
new OAuthRefreshFailureError({
26+
provider: "openai",
27+
message: "invalid_grant",
28+
}),
29+
),
30+
).toEqual({
31+
provider: "openai",
32+
reason: "invalid_grant",
33+
});
34+
});
1935
});

src/agents/auth-profiles/oauth-refresh-failure.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ export type OAuthRefreshFailureReason =
99
| "invalid_refresh_token"
1010
| "revoked";
1111

12+
export type OAuthRefreshFailure = {
13+
provider: string | null;
14+
reason: OAuthRefreshFailureReason | null;
15+
};
16+
17+
export class OAuthRefreshFailureError extends Error {
18+
readonly provider: string;
19+
readonly reason: OAuthRefreshFailureReason | null;
20+
21+
constructor(params: { provider: string; message: string; cause?: unknown }) {
22+
super(params.message, { cause: params.cause });
23+
this.name = "OAuthRefreshFailureError";
24+
this.provider = params.provider;
25+
this.reason = classifyOAuthRefreshFailureReason(params.message);
26+
}
27+
}
28+
1229
const OAUTH_REFRESH_FAILURE_PROVIDER_RE = /OAuth token refresh failed for ([^:]+):/i;
1330
const SAFE_PROVIDER_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
1431

@@ -54,10 +71,7 @@ export function classifyOAuthRefreshFailureReason(
5471
return null;
5572
}
5673

57-
export function classifyOAuthRefreshFailure(message: string): {
58-
provider: string | null;
59-
reason: OAuthRefreshFailureReason | null;
60-
} | null {
74+
export function classifyOAuthRefreshFailure(message: string): OAuthRefreshFailure | null {
6175
if (!isOAuthRefreshFailureMessage(message)) {
6276
return null;
6377
}
@@ -67,6 +81,16 @@ export function classifyOAuthRefreshFailure(message: string): {
6781
};
6882
}
6983

84+
export function classifyOAuthRefreshFailureError(err: unknown): OAuthRefreshFailure | null {
85+
if (!(err instanceof OAuthRefreshFailureError)) {
86+
return null;
87+
}
88+
return {
89+
provider: sanitizeOAuthRefreshFailureProvider(err.provider),
90+
reason: err.reason,
91+
};
92+
}
93+
7094
export function buildOAuthRefreshFailureLoginCommand(provider: string | null | undefined): string {
7195
const sanitizedProvider = sanitizeOAuthRefreshFailureProvider(provider);
7296
return sanitizedProvider

src/agents/auth-profiles/oauth.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
readManagedExternalCliCredential,
2626
} from "./external-cli-sync.js";
2727
import { createOAuthManager, OAuthManagerRefreshError } from "./oauth-manager.js";
28+
import { OAuthRefreshFailureError } from "./oauth-refresh-failure.js";
2829
import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js";
2930
import { clearLastGoodProfileWithLock } from "./profiles.js";
3031
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -495,11 +496,13 @@ export async function resolveApiKeyForProfile(
495496
provider: cred.provider,
496497
profileId,
497498
});
498-
throw new Error(
499-
`OAuth token refresh failed for ${cred.provider}: ${message}. ` +
499+
throw new OAuthRefreshFailureError({
500+
provider: cred.provider,
501+
message:
502+
`OAuth token refresh failed for ${cred.provider}: ${message}. ` +
500503
"Please try again or re-authenticate." +
501504
(hint ? `\n\n${hint}` : ""),
502-
{ cause: error },
503-
);
505+
cause: error,
506+
});
504507
}
505508
}

src/agents/embedded-agent-runner/compact.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-promp
8585
import {
8686
applyAuthHeaderOverride,
8787
applyLocalNoAuthHeaderOverride,
88-
formatMissingAuthError,
8988
getApiKeyForModel,
89+
MissingProviderAuthError,
9090
resolveModelAuthMode,
9191
} from "../model-auth.js";
9292
import { isFallbackSummaryError, runWithModelFallback } from "../model-fallback.js";
@@ -613,7 +613,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
613613

614614
if (!apiKeyInfo.apiKey) {
615615
if (apiKeyInfo.mode !== "aws-sdk") {
616-
throw new Error(formatMissingAuthError(apiKeyInfo, runtimeModel.provider));
616+
throw new MissingProviderAuthError(runtimeModel.provider, apiKeyInfo);
617617
}
618618
} else {
619619
const preparedAuth = await prepareProviderRuntimeAuth({

src/agents/embedded-agent-runner/run/auth-controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import {
1616
import { FailoverError, resolveFailoverStatus } from "../../failover-error.js";
1717
import { shouldAllowCooldownProbeForReason } from "../../failover-policy.js";
1818
import {
19-
formatMissingAuthError,
2019
getApiKeyForModel,
20+
MissingProviderAuthError,
2121
type ResolvedProviderAuth,
2222
} from "../../model-auth.js";
2323
import {
@@ -350,6 +350,7 @@ export function createEmbeddedRunAuthController(params: {
350350
provider,
351351
model: modelId,
352352
status: resolveFailoverStatus(reason),
353+
authProfileFailure: { allInCooldown: failoverParams.allInCooldown },
353354
cause: failoverParams.error,
354355
});
355356
}
@@ -378,7 +379,7 @@ export function createEmbeddedRunAuthController(params: {
378379
if (!apiKeyInfo.apiKey) {
379380
if (apiKeyInfo.mode !== "aws-sdk") {
380381
const runtimeModel = params.getRuntimeModel();
381-
throw new Error(formatMissingAuthError(apiKeyInfo, runtimeModel.provider));
382+
throw new MissingProviderAuthError(runtimeModel.provider, apiKeyInfo);
382383
}
383384
// AWS SDK auth via IMDS / instance role / ECS task role: no explicit API
384385
// key is available but the SDK default credential chain can resolve

src/agents/failover-error.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class FailoverError extends Error {
2323
readonly status?: number;
2424
readonly code?: string;
2525
readonly rawError?: string;
26+
readonly authProfileFailure?: { allInCooldown: boolean };
2627
// Originating request attribution propagated through wrapper errors so
2728
// structured log ingestion (e.g. api_health_log) can attribute exhausted
2829
// failover failures back to a session/lane and the last attempted provider.
@@ -41,6 +42,7 @@ export class FailoverError extends Error {
4142
status?: number;
4243
code?: string;
4344
rawError?: string;
45+
authProfileFailure?: { allInCooldown: boolean };
4446
sessionId?: string;
4547
lane?: string;
4648
cause?: unknown;
@@ -56,6 +58,7 @@ export class FailoverError extends Error {
5658
this.status = params.status;
5759
this.code = params.code;
5860
this.rawError = params.rawError;
61+
this.authProfileFailure = params.authProfileFailure;
5962
this.sessionId = params.sessionId;
6063
this.lane = params.lane;
6164
this.suspend = params.suspend;

src/agents/model-auth-runtime-shared.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,29 @@ export class ProviderAuthError extends Error {
2626
}
2727
}
2828

29+
export class MissingProviderAuthError extends ProviderAuthError {
30+
readonly mode: ResolvedProviderAuth["mode"];
31+
readonly source: string;
32+
33+
constructor(provider: string, auth: ResolvedProviderAuth) {
34+
super("missing-api-key", provider, formatMissingAuthError(auth, provider));
35+
this.name = "MissingProviderAuthError";
36+
this.mode = auth.mode;
37+
this.source = auth.source;
38+
}
39+
}
40+
2941
export function isProviderAuthError(
3042
err: unknown,
3143
code?: ProviderAuthErrorCode,
3244
): err is ProviderAuthError {
3345
return err instanceof ProviderAuthError && (!code || err.code === code);
3446
}
3547

48+
export function isMissingProviderAuthError(err: unknown): err is MissingProviderAuthError {
49+
return err instanceof MissingProviderAuthError;
50+
}
51+
3652
export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): string | undefined {
3753
if (env[AWS_BEARER_ENV]?.trim()) {
3854
return AWS_BEARER_ENV;
@@ -55,5 +71,5 @@ export function requireApiKey(auth: ResolvedProviderAuth, provider: string): str
5571
if (key) {
5672
return key;
5773
}
58-
throw new ProviderAuthError("missing-api-key", provider, formatMissingAuthError(auth, provider));
74+
throw new MissingProviderAuthError(provider, auth);
5975
}

src/agents/model-auth.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,28 @@ describe("requireApiKey", () => {
425425
'No API key resolved for provider "openai" (auth mode: api-key, checked: env: OPENAI_API_KEY).',
426426
);
427427
});
428+
429+
it("throws typed missing auth errors with source metadata", () => {
430+
let thrown: unknown;
431+
try {
432+
requireApiKey(
433+
{
434+
source: "env: OPENAI_API_KEY",
435+
mode: "api-key",
436+
},
437+
"openai",
438+
);
439+
} catch (err) {
440+
thrown = err;
441+
}
442+
expect(thrown).toMatchObject({
443+
name: "MissingProviderAuthError",
444+
code: "missing-api-key",
445+
provider: "openai",
446+
mode: "api-key",
447+
source: "env: OPENAI_API_KEY",
448+
});
449+
});
428450
});
429451

430452
describe("resolveUsableCustomProviderApiKey", () => {

src/agents/model-auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ export {
5858
} from "./auth-profiles.js";
5959
export {
6060
formatMissingAuthError,
61+
isMissingProviderAuthError,
6162
isProviderAuthError,
63+
MissingProviderAuthError,
6264
ProviderAuthError,
6365
requireApiKey,
6466
resolveAwsSdkEnvVarName,

0 commit comments

Comments
 (0)