diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index 239a4dd7d439..34e9fbc3d9b8 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -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); diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index 759f6aa34b14..0a5a69ad8988 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -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") || diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 23f9d90e0799..c1048a9955c2 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -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(); const register = vi.fn((key: string, value: CachedCopilotToken) => { diff --git a/extensions/github-copilot/token-exchange-error.ts b/extensions/github-copilot/token-exchange-error.ts new file mode 100644 index 000000000000..ce17e2e9da50 --- /dev/null +++ b/extensions/github-copilot/token-exchange-error.ts @@ -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; + } + } +} diff --git a/extensions/github-copilot/token.ts b/extensions/github-copilot/token.ts index 55b14c37b1bc..206a5c5b854c 100644 --- a/extensions/github-copilot/token.ts +++ b/extensions/github-copilot/token.ts @@ -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) | 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; } diff --git a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts index 2231d6e575f6..bbb54d2ebb82 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts @@ -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", diff --git a/src/auto-reply/reply/agent-runner-error-handler.ts b/src/auto-reply/reply/agent-runner-error-handler.ts index 91541346feec..28f8ae928a24 100644 --- a/src/auto-reply/reply/agent-runner-error-handler.ts +++ b/src/auto-reply/reply/agent-runner-error-handler.ts @@ -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 diff --git a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts index 09404789e059..a36eafcd0df4 100644 --- a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts @@ -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); } }); }); diff --git a/src/auto-reply/reply/agent-runner-failure-copy.ts b/src/auto-reply/reply/agent-runner-failure-copy.ts index 6616c00d9875..924a08cc4a06 100644 --- a/src/auto-reply/reply/agent-runner-failure-copy.ts +++ b/src/auto-reply/reply/agent-runner-failure-copy.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner-fallback-settlement.ts b/src/auto-reply/reply/agent-runner-fallback-settlement.ts index 4db11c605079..3609eb96a2d0 100644 --- a/src/auto-reply/reply/agent-runner-fallback-settlement.ts +++ b/src/auto-reply/reply/agent-runner-fallback-settlement.ts @@ -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), }), };