Skip to content

Commit b77db8c

Browse files
authored
Reply: surface OAuth reauth failures (#63217)
Merged via squash. Prepared head SHA: 68b7ffd Co-authored-by: mbelinky <[email protected]> Co-authored-by: mbelinky <[email protected]> Reviewed-by: @mbelinky
1 parent 45195e3 commit b77db8c

17 files changed

Lines changed: 663 additions & 62 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
- QQBot/media-tags: support HTML entity-encoded angle brackets (`&lt;`/`&gt;`) in media-tag regexes so entity-escaped `<qqimg>` tags from upstream are correctly parsed and normalized. (#60493) Thanks @ylc0919.
1818
- npm packaging: mirror bundled Slack, Telegram, Discord, and Feishu channel runtime deps at the root and harden published-install verification so fresh installs fail fast on manifest drift instead of missing-module crashes. (#63065) Thanks @scoootscooob.
1919
- npm packaging: derive required root runtime mirrors from bundled plugin manifests and built root chunks, then install packed release tarballs without the repo `node_modules` so release checks catch missing plugin deps before publish.
20+
- Reply/doctor: resolve reply-run SecretRefs before preflight helpers touch config, surface gateway OAuth reauth failures to users, and make `openclaw doctor` call out exact reauth commands.
2021

2122
## 2026.4.8
2223

docs/gateway/doctor.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,11 @@ Anthropic setup-token path.
323323
Refresh prompts only appear when running interactively (TTY); `--non-interactive`
324324
skips refresh attempts.
325325

326+
When an OAuth refresh fails permanently (for example `refresh_token_reused`,
327+
`invalid_grant`, or a provider telling you to sign in again), doctor reports
328+
that re-auth is required and prints the exact `openclaw models auth login --provider ...`
329+
command to run.
330+
326331
Doctor also reports auth profiles that are temporarily unusable due to:
327332

328333
- short cooldowns (rate limits/timeouts/auth failures)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { formatCliCommand } from "../../cli/command-format.js";
2+
import { sanitizeForLog } from "../../terminal/ansi.js";
3+
import { normalizeProviderId } from "../model-selection.js";
4+
5+
export type OAuthRefreshFailureReason =
6+
| "refresh_token_reused"
7+
| "invalid_grant"
8+
| "sign_in_again"
9+
| "invalid_refresh_token"
10+
| "revoked";
11+
12+
const OAUTH_REFRESH_FAILURE_PROVIDER_RE = /OAuth token refresh failed for ([^:]+):/i;
13+
const SAFE_PROVIDER_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
14+
15+
export function extractOAuthRefreshFailureProvider(message: string): string | null {
16+
const provider = message.match(OAUTH_REFRESH_FAILURE_PROVIDER_RE)?.[1]?.trim();
17+
return provider && provider.length > 0 ? provider : null;
18+
}
19+
20+
export function sanitizeOAuthRefreshFailureProvider(
21+
provider: string | null | undefined,
22+
): string | null {
23+
const sanitized = provider ? sanitizeForLog(provider).replaceAll("`", "").trim() : "";
24+
const normalized = normalizeProviderId(sanitized);
25+
return normalized && SAFE_PROVIDER_ID_RE.test(normalized) ? normalized : null;
26+
}
27+
28+
export function classifyOAuthRefreshFailureReason(
29+
message: string,
30+
): OAuthRefreshFailureReason | null {
31+
const lower = message.toLowerCase();
32+
if (lower.includes("refresh_token_reused")) {
33+
return "refresh_token_reused";
34+
}
35+
if (lower.includes("invalid_grant")) {
36+
return "invalid_grant";
37+
}
38+
if (lower.includes("signing in again") || lower.includes("sign in again")) {
39+
return "sign_in_again";
40+
}
41+
if (lower.includes("invalid refresh token")) {
42+
return "invalid_refresh_token";
43+
}
44+
if (lower.includes("expired or revoked") || lower.includes("revoked")) {
45+
return "revoked";
46+
}
47+
return null;
48+
}
49+
50+
export function classifyOAuthRefreshFailure(message: string): {
51+
provider: string | null;
52+
reason: OAuthRefreshFailureReason | null;
53+
} | null {
54+
if (!/oauth token refresh failed/i.test(message)) {
55+
return null;
56+
}
57+
return {
58+
provider: sanitizeOAuthRefreshFailureProvider(extractOAuthRefreshFailureProvider(message)),
59+
reason: classifyOAuthRefreshFailureReason(message),
60+
};
61+
}
62+
63+
export function buildOAuthRefreshFailureLoginCommand(provider: string | null | undefined): string {
64+
const safeProvider = sanitizeOAuthRefreshFailureProvider(provider);
65+
return safeProvider
66+
? formatCliCommand(`openclaw models auth login --provider ${safeProvider}`)
67+
: formatCliCommand("openclaw models auth login");
68+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { TemplateContext } from "../templating.js";
3+
import type { FollowupRun, QueueSettings } from "./queue.js";
4+
import { createMockTypingController } from "./test-helpers.js";
5+
6+
const freshCfg = { runtimeFresh: true };
7+
const staleCfg = {
8+
runtimeFresh: false,
9+
skills: {
10+
entries: {
11+
whisper: {
12+
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
13+
},
14+
},
15+
},
16+
};
17+
const sentinelError = new Error("stop-after-preflight");
18+
19+
const resolveQueuedReplyExecutionConfigMock = vi.fn();
20+
const resolveReplyToModeMock = vi.fn();
21+
const createReplyToModeFilterForChannelMock = vi.fn();
22+
const createReplyMediaPathNormalizerMock = vi.fn();
23+
const runPreflightCompactionIfNeededMock = vi.fn();
24+
const runMemoryFlushIfNeededMock = vi.fn();
25+
const enqueueFollowupRunMock = vi.fn();
26+
27+
vi.mock("./agent-runner-utils.js", () => ({
28+
resolveQueuedReplyExecutionConfig: (...args: unknown[]) =>
29+
resolveQueuedReplyExecutionConfigMock(...args),
30+
}));
31+
32+
vi.mock("./reply-threading.js", () => ({
33+
resolveReplyToMode: (...args: unknown[]) => resolveReplyToModeMock(...args),
34+
createReplyToModeFilterForChannel: (...args: unknown[]) =>
35+
createReplyToModeFilterForChannelMock(...args),
36+
}));
37+
38+
vi.mock("./reply-media-paths.js", () => ({
39+
createReplyMediaPathNormalizer: (...args: unknown[]) =>
40+
createReplyMediaPathNormalizerMock(...args),
41+
}));
42+
43+
vi.mock("./agent-runner-memory.js", () => ({
44+
runPreflightCompactionIfNeeded: (...args: unknown[]) =>
45+
runPreflightCompactionIfNeededMock(...args),
46+
runMemoryFlushIfNeeded: (...args: unknown[]) => runMemoryFlushIfNeededMock(...args),
47+
}));
48+
49+
vi.mock("./queue.js", async () => {
50+
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
51+
return {
52+
...actual,
53+
enqueueFollowupRun: (...args: unknown[]) => enqueueFollowupRunMock(...args),
54+
};
55+
});
56+
57+
const { runReplyAgent } = await import("./agent-runner.js");
58+
59+
describe("runReplyAgent runtime config", () => {
60+
beforeEach(() => {
61+
resolveQueuedReplyExecutionConfigMock.mockReset();
62+
resolveReplyToModeMock.mockReset();
63+
createReplyToModeFilterForChannelMock.mockReset();
64+
createReplyMediaPathNormalizerMock.mockReset();
65+
runPreflightCompactionIfNeededMock.mockReset();
66+
runMemoryFlushIfNeededMock.mockReset();
67+
enqueueFollowupRunMock.mockReset();
68+
69+
resolveQueuedReplyExecutionConfigMock.mockResolvedValue(freshCfg);
70+
resolveReplyToModeMock.mockReturnValue("default");
71+
createReplyToModeFilterForChannelMock.mockReturnValue((payload: unknown) => payload);
72+
createReplyMediaPathNormalizerMock.mockReturnValue((payload: unknown) => payload);
73+
runPreflightCompactionIfNeededMock.mockRejectedValue(sentinelError);
74+
runMemoryFlushIfNeededMock.mockResolvedValue(undefined);
75+
});
76+
77+
it("resolves direct reply runs before early helpers read config", async () => {
78+
const followupRun = {
79+
prompt: "hello",
80+
summaryLine: "hello",
81+
enqueuedAt: Date.now(),
82+
run: {
83+
sessionId: "session-1",
84+
sessionKey: "agent:main:telegram:default:direct:test",
85+
messageProvider: "telegram",
86+
sessionFile: "/tmp/session.jsonl",
87+
workspaceDir: "/tmp",
88+
config: staleCfg,
89+
skillsSnapshot: {},
90+
provider: "openai",
91+
model: "gpt-5.4",
92+
thinkLevel: "low",
93+
verboseLevel: "off",
94+
elevatedLevel: "off",
95+
bashElevated: {
96+
enabled: false,
97+
allowed: false,
98+
defaultLevel: "off",
99+
},
100+
timeoutMs: 1_000,
101+
blockReplyBreak: "message_end",
102+
},
103+
} as unknown as FollowupRun;
104+
105+
const resolvedQueue = { mode: "interrupt" } as QueueSettings;
106+
const typing = createMockTypingController();
107+
const sessionCtx = {
108+
Provider: "telegram",
109+
OriginatingChannel: "telegram",
110+
OriginatingTo: "12345",
111+
AccountId: "default",
112+
ChatType: "dm",
113+
MessageSid: "msg-1",
114+
} as unknown as TemplateContext;
115+
116+
await expect(
117+
runReplyAgent({
118+
commandBody: "hello",
119+
followupRun,
120+
queueKey: "main",
121+
resolvedQueue,
122+
shouldSteer: false,
123+
shouldFollowup: false,
124+
isActive: false,
125+
isStreaming: false,
126+
typing,
127+
sessionCtx,
128+
defaultModel: "openai/gpt-5.4",
129+
resolvedVerboseLevel: "off",
130+
isNewSession: false,
131+
blockStreamingEnabled: false,
132+
resolvedBlockStreamingBreak: "message_end",
133+
shouldInjectGroupIntro: false,
134+
typingMode: "instant",
135+
}),
136+
).rejects.toBe(sentinelError);
137+
138+
expect(followupRun.run.config).toBe(freshCfg);
139+
expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(staleCfg);
140+
expect(resolveReplyToModeMock).toHaveBeenCalledWith(freshCfg, "telegram", "default", "dm");
141+
expect(createReplyMediaPathNormalizerMock).toHaveBeenCalledWith({
142+
cfg: freshCfg,
143+
sessionKey: undefined,
144+
workspaceDir: "/tmp",
145+
});
146+
expect(runPreflightCompactionIfNeededMock).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
cfg: freshCfg,
149+
followupRun,
150+
}),
151+
);
152+
});
153+
154+
it("does not resolve secrets before the enqueue-followup queue path", async () => {
155+
const followupRun = {
156+
prompt: "hello",
157+
summaryLine: "hello",
158+
enqueuedAt: Date.now(),
159+
run: {
160+
sessionId: "session-1",
161+
sessionKey: "agent:main:telegram:default:direct:test",
162+
messageProvider: "telegram",
163+
sessionFile: "/tmp/session.jsonl",
164+
workspaceDir: "/tmp",
165+
config: staleCfg,
166+
skillsSnapshot: {},
167+
provider: "openai",
168+
model: "gpt-5.4",
169+
thinkLevel: "low",
170+
verboseLevel: "off",
171+
elevatedLevel: "off",
172+
bashElevated: {
173+
enabled: false,
174+
allowed: false,
175+
defaultLevel: "off",
176+
},
177+
timeoutMs: 1_000,
178+
blockReplyBreak: "message_end",
179+
},
180+
} as unknown as FollowupRun;
181+
182+
const resolvedQueue = { mode: "interrupt" } as QueueSettings;
183+
const typing = createMockTypingController();
184+
const sessionCtx = {
185+
Provider: "telegram",
186+
OriginatingChannel: "telegram",
187+
OriginatingTo: "12345",
188+
AccountId: "default",
189+
ChatType: "dm",
190+
MessageSid: "msg-1",
191+
} as unknown as TemplateContext;
192+
193+
await expect(
194+
runReplyAgent({
195+
commandBody: "hello",
196+
followupRun,
197+
queueKey: "main",
198+
resolvedQueue,
199+
shouldSteer: false,
200+
shouldFollowup: true,
201+
isActive: true,
202+
isStreaming: false,
203+
typing,
204+
sessionCtx,
205+
defaultModel: "openai/gpt-5.4",
206+
resolvedVerboseLevel: "off",
207+
isNewSession: false,
208+
blockStreamingEnabled: false,
209+
resolvedBlockStreamingBreak: "message_end",
210+
shouldInjectGroupIntro: false,
211+
typingMode: "instant",
212+
}),
213+
).resolves.toBeUndefined();
214+
215+
expect(resolveQueuedReplyExecutionConfigMock).not.toHaveBeenCalled();
216+
expect(enqueueFollowupRunMock).toHaveBeenCalledWith(
217+
"main",
218+
followupRun,
219+
resolvedQueue,
220+
"message-id",
221+
expect.any(Function),
222+
false,
223+
);
224+
});
225+
});

0 commit comments

Comments
 (0)