mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user