fix: make Copilot token exchange 403 errors actionable (#112025)

* fix: explain GitHub Copilot 403 recovery

* fix: keep control UI log hint internal

* fix: clarify log viewing hint
This commit is contained in:
Dallin Romney
2026-07-21 17:11:25 +09:00
committed by GitHub
parent c1da5a1c40
commit c3c665f7f6
10 changed files with 103 additions and 27 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
// Github Copilot tests cover embeddings plugin behavior.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
@@ -381,7 +382,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
).toBe(true);
expect(
shouldContinueAutoSelection(
new Error("Copilot token exchange failed: timed out after 30000ms"),
new CopilotTokenExchangeError({ reason: "timeout", timeoutMs: 30_000 }),
),
).toBe(true);
expect(shouldContinueAutoSelection(new Error("Network timeout"))).toBe(false);
+4 -1
View File
@@ -15,6 +15,7 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveFirstGithubToken } from "./auth.js";
import { resolveGithubCopilotDomain } from "./domain.js";
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
const COPILOT_EMBEDDING_PROVIDER_ID = "github-copilot";
@@ -63,6 +64,9 @@ type GitHubCopilotEmbeddingClient = {
};
function isCopilotSetupError(err: unknown): boolean {
if (err instanceof CopilotTokenExchangeError) {
return true;
}
if (!(err instanceof Error)) {
return false;
}
@@ -72,7 +76,6 @@ function isCopilotSetupError(err: unknown): boolean {
// model discovery errors, and user-pinned model not available on Copilot.
return (
err.message.includes("No GitHub token available") ||
err.message.includes("Copilot token exchange failed") ||
err.message.includes("Copilot token response") ||
err.message.includes("No embedding models available") ||
err.message.includes("GitHub Copilot model discovery") ||
+22
View File
@@ -6,6 +6,7 @@ import { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-a
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CachedCopilotToken } from "./token-cache.js";
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
import { resolveCopilotApiToken } from "./token.js";
import { fetchCopilotUsage } from "./usage.js";
@@ -437,6 +438,27 @@ describe("github-copilot token", () => {
expect(jsonStoreMocks.saveJsonFile).toHaveBeenCalledTimes(1);
});
it("explains how to recover from a forbidden token exchange", async () => {
jsonStoreMocks.loadJsonFile.mockReturnValue(undefined);
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
const rejection = resolveCopilotApiToken({
githubToken: "gh",
cachePath,
loadJsonFileImpl: jsonStoreMocks.loadJsonFile,
saveJsonFileImpl: jsonStoreMocks.saveJsonFile,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
await expect(rejection).rejects.toBeInstanceOf(CopilotTokenExchangeError);
await expect(rejection).rejects.toMatchObject({
code: "github_copilot_token_exchange_failed",
reason: "http_error",
status: 403,
message: expect.stringContaining("login-github-copilot"),
});
});
it("keeps exchanges per source credential in plugin state", async () => {
const values = new Map<string, CachedCopilotToken>();
const register = vi.fn((key: string, value: CachedCopilotToken) => {
@@ -0,0 +1,40 @@
// GitHub Copilot token exchange errors shared by runtime and fallback policy.
type CopilotTokenExchangeFailure =
| { reason: "http_error"; status: number }
| { reason: "timeout"; timeoutMs: number; cause?: unknown };
function buildCopilotTokenExchangeMessage(failure: CopilotTokenExchangeFailure): string {
if (failure.reason === "timeout") {
return `Copilot token exchange failed: timed out after ${failure.timeoutMs}ms`;
}
const message = `Copilot token exchange failed: HTTP ${failure.status}`;
if (failure.status !== 403) {
return message;
}
return (
`${message}. Run \`openclaw models auth login-github-copilot\` in a terminal to ` +
"authenticate again. If this still fails, verify that your GitHub account has Copilot " +
"access and that your organization or enterprise policy permits it."
);
}
export class CopilotTokenExchangeError extends Error {
readonly code = "github_copilot_token_exchange_failed";
readonly reason: CopilotTokenExchangeFailure["reason"];
readonly status?: number;
readonly timeoutMs?: number;
constructor(failure: CopilotTokenExchangeFailure) {
super(
buildCopilotTokenExchangeMessage(failure),
failure.reason === "timeout" ? { cause: failure.cause } : undefined,
);
this.name = "CopilotTokenExchangeError";
this.reason = failure.reason;
if (failure.reason === "http_error") {
this.status = failure.status;
} else {
this.timeoutMs = failure.timeoutMs;
}
}
}
+7 -5
View File
@@ -19,6 +19,7 @@ import {
resolveCopilotTokenCache,
type CachedCopilotToken,
} from "./token-cache.js";
import { CopilotTokenExchangeError } from "./token-exchange-error.js";
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
const COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
let openConfiguredCacheStore: (() => PluginStateSyncKeyedStore<CachedCopilotToken>) | undefined;
@@ -151,17 +152,18 @@ export async function resolveCopilotApiToken(params: {
});
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new Error(`Copilot token exchange failed: HTTP ${response.status}`);
throw new CopilotTokenExchangeError({ reason: "http_error", status: response.status });
}
payload = parseCopilotTokenResponse(
await readProviderJsonResponse(response, "github-copilot.token"),
);
} catch (error) {
if (signal.aborted && error === signal.reason) {
throw new Error(
`Copilot token exchange failed: timed out after ${COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS}ms`,
{ cause: error },
);
throw new CopilotTokenExchangeError({
reason: "timeout",
timeoutMs: COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS,
cause: error,
});
}
throw error;
}
@@ -25,6 +25,7 @@ import {
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js";
import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js";
import { buildControlUiAgentFailureText } from "./reply/agent-runner-failure-copy.js";
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js";
import type { MsgContext } from "./templating.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
@@ -65,8 +66,7 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
if (/context window exceeded/i.test(message)) {
return "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.";
}
const trimmed = message.replace(/\.\s*$/, "");
return `⚠️ Agent failed before reply: ${trimmed}.\nLogs: openclaw logs --follow`;
return buildControlUiAgentFailureText(message);
};
const stripHeartbeat = (text?: string) => {
const trimmed = text?.trim();
@@ -361,8 +361,7 @@ describe("trigger handling", () => {
for (const testCase of [
{
error: "sandbox is not defined.",
expected:
"⚠️ Agent failed before reply: sandbox is not defined.\nLogs: openclaw logs --follow",
expected: buildControlUiAgentFailureText("sandbox is not defined."),
},
{
error: "Context window exceeded",
@@ -31,6 +31,7 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
import type { AgentRunLoopResult, AgentTurnParams } from "./agent-runner-execution.types.js";
import {
buildControlUiAgentFailureText,
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
} from "./agent-runner-failure-copy.js";
@@ -167,9 +168,9 @@ export async function handleAgentExecutionError(params: {
);
takePendingLifecycleTerminal()?.emit("error", err);
const switchErrorText = params.shouldSurfaceToControlUi
? "⚠️ Agent failed before reply: model switch could not be completed. " +
"The requested model may be temporarily unavailable.\n" +
"Logs: openclaw logs --follow"
? buildControlUiAgentFailureText(
"model switch could not be completed. The requested model may be temporarily unavailable.",
)
: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel)
? "⚠️ Agent failed before reply: model switch could not be completed. " +
"The requested model may be temporarily unavailable. Please try again shortly."
@@ -438,9 +439,9 @@ export async function handleAgentExecutionError(params: {
failoverReason === "overloaded" ? "overloaded" : message,
)
: undefined;
const trimmedMessage = (
isTransientHttp ? sanitizeUserFacingText(message, { errorContext: true }) : message
).replace(/\.\s*$/, "");
const userFacingMessage = isTransientHttp
? sanitizeUserFacingText(message, { errorContext: true })
: message;
const externalRunFailureReply =
!isBilling &&
!(isRateLimit && !isOverloaded) &&
@@ -466,7 +467,7 @@ export async function handleAgentExecutionError(params: {
: isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: params.shouldSurfaceToControlUi
? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`
? buildControlUiAgentFailureText(userFacingMessage)
: (externalRunFailureReply?.text ??
(turn.isHeartbeat
? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
@@ -118,11 +118,10 @@ describe("runAgentTurnWithFallback: conversation failures", () => {
}
});
it("keeps raw generic errors on internal control surfaces", async () => {
it("keeps actionable provider errors on internal control surfaces", async () => {
state.isInternalMessageChannelMock.mockReturnValue(true);
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new Error("INVALID_ARGUMENT: some other failure"),
);
const providerError = "provider failed with actionable details";
state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error(providerError));
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
@@ -151,9 +150,9 @@ describe("runAgentTurnWithFallback: conversation failures", () => {
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toContain("Agent failed before reply");
expect(result.payload.text).toContain("INVALID_ARGUMENT: some other failure");
expect(result.payload.text).toContain("Logs: openclaw logs --follow");
expect(result.payload.text).toContain(providerError);
expect(result.payload.text).toContain("openclaw logs --follow");
expect(result.payload.text).toMatch(/terminal/i);
}
});
});
@@ -5,6 +5,14 @@ export const GENERIC_EXTERNAL_RUN_FAILURE_TEXT =
export const HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT =
"⚠️ Heartbeat check failed before it could produce an update. The main chat session remains available.";
const CONTROL_UI_LOG_HINT = "To view logs, run `openclaw logs --follow` in a terminal.";
/** Preserves raw errors on internal surfaces while making the log command actionable. */
export function buildControlUiAgentFailureText(errorText: string): string {
const trimmedError = errorText.trim().replace(/\.\s*$/, "");
return `⚠️ Agent failed before reply: ${trimmedError}.\n${CONTROL_UI_LOG_HINT}`;
}
/** True when text is exactly the generic external run failure copy. */
function isGenericExternalRunFailureText(text: string | undefined): boolean {
return text?.trim() === GENERIC_EXTERNAL_RUN_FAILURE_TEXT;
@@ -9,6 +9,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { defaultRuntime } from "../../runtime.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
import { buildControlUiAgentFailureText } from "./agent-runner-failure-copy.js";
import { markAgentRunFailureReplyPayload } from "./agent-runner-failure-reply.js";
import type { AgentFallbackCandidatesResult } from "./agent-runner-fallback-candidate.js";
import type {
@@ -115,12 +116,12 @@ export async function settleAgentFallbackCycle(params: {
emitSettledLifecycleError(new Error(terminalErrorMessage ?? "Agent run failed"));
const providerRequestError = classifyProviderRequestError(embeddedError);
turn.replyOperation?.fail("run_failed", embeddedError);
const embeddedErrorText = formatErrorMessage(embeddedError).replace(/\.\s*$/, "");
const embeddedErrorText = formatErrorMessage(embeddedError);
return {
kind: "final",
payload: markAgentRunFailureReplyPayload({
text: cycle.shouldSurfaceToControlUi
? `⚠️ Agent failed before reply: ${embeddedErrorText}.\nLogs: openclaw logs --follow`
? buildControlUiAgentFailureText(embeddedErrorText)
: (providerRequestError?.userMessage ?? PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE),
}),
};