From b9f29377231ec44214b21e01c12dbc9d8e901756 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 15:17:13 +0000 Subject: [PATCH] fix: prevent indefinite hang when OAuth token expires Adds timeout mechanism and authentication error detection to prevent indefinite hangs when OAuth tokens expire or become invalid. Implements graceful and forced process termination with clear error guidance. - Add configurable timeout (default 15 minutes) via claude_code_timeout_ms input - Detect authentication errors in real-time during execution - Provide actionable error messages for expired tokens/subscriptions - Add comprehensive test coverage for error detection - Enhance validation error messages with timeout behavior notes Fixes issue where expired OAuth tokens cause GitHub Actions to hang indefinitely at "Setting up Claude Code..." without any feedback. --- action.yml | 5 ++ base-action/action.yml | 5 ++ base-action/src/run-claude.ts | 114 +++++++++++++++++++++++++--- base-action/src/validate-env.ts | 4 +- base-action/test/run-claude.test.ts | 61 ++++++++++++++- 5 files changed, 177 insertions(+), 12 deletions(-) diff --git a/action.yml b/action.yml index 63f37ae4f..c525e3dad 100644 --- a/action.yml +++ b/action.yml @@ -113,6 +113,10 @@ inputs: description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')" required: false default: "" + claude_code_timeout_ms: + description: "Maximum time in milliseconds to wait for Claude Code execution before timing out (default: 900000 = 15 minutes). Prevents indefinite hangs when authentication fails or tokens expire." + required: false + default: "900000" outputs: execution_file: @@ -228,6 +232,7 @@ runs: INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }} + INPUT_CLAUDE_CODE_TIMEOUT_MS: ${{ inputs.claude_code_timeout_ms }} # Model configuration GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} diff --git a/base-action/action.yml b/base-action/action.yml index 8d0458551..2393c80d2 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -67,6 +67,10 @@ inputs: description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')" required: false default: "" + claude_code_timeout_ms: + description: "Maximum time in milliseconds to wait for Claude Code execution before timing out (default: 900000 = 15 minutes). Prevents indefinite hangs when authentication fails or tokens expire." + required: false + default: "900000" outputs: conclusion: @@ -141,6 +145,7 @@ runs: INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }} + INPUT_CLAUDE_CODE_TIMEOUT_MS: ${{ inputs.claude_code_timeout_ms }} # Provider configuration ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 2ffbc196c..6681fd409 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -11,6 +11,10 @@ const execAsync = promisify(exec); const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; const BASE_ARGS = ["--verbose", "--output-format", "stream-json"]; +const TIMEOUT_MS = parseInt( + process.env.INPUT_CLAUDE_CODE_TIMEOUT_MS || "900000", + 10, +); // 15 min default /** * Sanitizes JSON output to remove sensitive information when full output is disabled @@ -209,9 +213,17 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { // Capture output for parsing execution metrics let output = ""; + let authErrorDetected = false; + claudeProcess.stdout.on("data", (data) => { const text = data.toString(); + // Check for authentication errors in the output + if (!authErrorDetected && containsAuthenticationError(text)) { + authErrorDetected = true; + logAuthenticationErrorGuidance(); + } + // Try to parse as JSON and handle based on verbose setting const lines = text.split("\n"); lines.forEach((line: string, index: number) => { @@ -259,17 +271,20 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { claudeProcess.kill("SIGTERM"); }); - // Wait for Claude to finish - const exitCode = await new Promise((resolve) => { - claudeProcess.on("close", (code) => { - resolve(code || 0); - }); + // Wait for Claude to finish with timeout protection + const exitCode = await Promise.race([ + new Promise((resolve) => { + claudeProcess.on("close", (code) => { + resolve(code || 0); + }); - claudeProcess.on("error", (error) => { - console.error("Claude process error:", error); - resolve(1); - }); - }); + claudeProcess.on("error", (error) => { + console.error("Claude process error:", error); + resolve(1); + }); + }), + createTimeoutPromise(TIMEOUT_MS, claudeProcess), + ]); // Clean up processes try { @@ -331,3 +346,82 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { process.exit(exitCode); } } + +/** + * Checks if the given text contains authentication-related error patterns + */ +export function containsAuthenticationError(text: string): boolean { + const lowerText = text.toLowerCase(); + return ( + lowerText.includes("authentication") || + lowerText.includes("invalid token") || + lowerText.includes("expired") || + lowerText.includes("unauthorized") || + lowerText.includes("subscription") || + lowerText.includes("401") || + lowerText.includes("403") + ); +} + +/** + * Displays helpful authentication error guidance to the user + */ +function logAuthenticationErrorGuidance(): void { + console.error("\n⚠️ Authentication Error Detected"); + console.error("Your OAuth token or API key may be expired or invalid."); + console.error("Please check:"); + console.error( + " - CLAUDE_CODE_OAUTH_TOKEN is still valid (subscription active)", + ); + console.error(" - ANTHROPIC_API_KEY has not been rotated"); + console.error(" - Your subscription is active\n"); +} + +/** + * Creates a timeout promise that rejects after the specified duration + * Handles graceful and forced process termination + */ +function createTimeoutPromise( + timeoutMs: number, + processToKill: ReturnType, +): Promise { + return new Promise((_, reject) => { + setTimeout(() => { + console.error( + `\n⚠️ Claude Code execution timed out after ${timeoutMs / 60000} minutes`, + ); + console.error( + "This often indicates authentication issues (expired OAuth token or invalid API key).", + ); + console.error("Please verify:"); + console.error( + " - Your CLAUDE_CODE_OAUTH_TOKEN is still valid (subscription active)", + ); + console.error(" - Your ANTHROPIC_API_KEY has not been rotated"); + console.error(" - Your Claude subscription is active"); + console.error( + "\nYou can increase the timeout by setting the claude_code_timeout_ms input.\n", + ); + + // Attempt graceful shutdown first + processToKill.kill("SIGTERM"); + + // Force kill after 5 seconds if still running + setTimeout(() => { + try { + processToKill.kill("SIGKILL"); + } catch (e) { + // Process may already be dead + } + }, 5000); + + reject( + new Error( + `Claude Code execution timed out after ${timeoutMs / 60000} minutes. ` + + `This often indicates authentication issues (expired OAuth token or invalid API key). ` + + `Please verify your CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY is valid.`, + ), + ); + }, timeoutMs); + }); +} diff --git a/base-action/src/validate-env.ts b/base-action/src/validate-env.ts index 6e48a6843..83e6a5432 100644 --- a/base-action/src/validate-env.ts +++ b/base-action/src/validate-env.ts @@ -19,7 +19,9 @@ export function validateEnvironmentVariables() { if (!useBedrock && !useVertex) { if (!anthropicApiKey && !claudeCodeOAuthToken) { errors.push( - "Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.", + "Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.\n" + + "Note: Token validation occurs during Claude Code execution. If your subscription has expired or " + + "your token is invalid, the action will timeout with an authentication error.", ); } } else if (useBedrock) { diff --git a/base-action/test/run-claude.test.ts b/base-action/test/run-claude.test.ts index 1c7d13168..323c78f67 100644 --- a/base-action/test/run-claude.test.ts +++ b/base-action/test/run-claude.test.ts @@ -1,7 +1,11 @@ #!/usr/bin/env bun import { describe, test, expect } from "bun:test"; -import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude"; +import { + prepareRunConfig, + containsAuthenticationError, + type ClaudeOptions, +} from "../src/run-claude"; describe("prepareRunConfig", () => { test("should prepare config with basic arguments", () => { @@ -80,3 +84,58 @@ describe("prepareRunConfig", () => { }); }); }); + +describe("containsAuthenticationError", () => { + test("should return true for 'authentication' keyword", () => { + expect(containsAuthenticationError("authentication failed")).toBe(true); + expect(containsAuthenticationError("Authentication error occurred")).toBe( + true, + ); + expect(containsAuthenticationError("AUTHENTICATION system is down")).toBe( + true, + ); + }); + + test("should return true for 'invalid token' keyword", () => { + expect(containsAuthenticationError("invalid token provided")).toBe(true); + expect(containsAuthenticationError("Token is Invalid")).toBe(true); + }); + + test("should return true for 'expired' keyword", () => { + expect(containsAuthenticationError("token expired")).toBe(true); + expect(containsAuthenticationError("Your session has Expired")).toBe(true); + }); + + test("should return true for 'unauthorized' keyword", () => { + expect(containsAuthenticationError("unauthorized access")).toBe(true); + expect(containsAuthenticationError("401 Unauthorized")).toBe(true); + }); + + test("should return true for 'subscription' keyword", () => { + expect(containsAuthenticationError("subscription has ended")).toBe(true); + expect(containsAuthenticationError("Your Subscription expired")).toBe(true); + }); + + test("should return true for HTTP error codes", () => { + expect(containsAuthenticationError("Error 401: Access denied")).toBe(true); + expect(containsAuthenticationError("HTTP 403 Forbidden")).toBe(true); + }); + + test("should return false for non-auth related text", () => { + expect(containsAuthenticationError("processing request")).toBe(false); + expect(containsAuthenticationError("success")).toBe(false); + expect(containsAuthenticationError("task completed")).toBe(false); + expect(containsAuthenticationError("Error 500: Server error")).toBe(false); + }); + + test("should be case-insensitive", () => { + expect(containsAuthenticationError("AUTHENTICATION FAILED")).toBe(true); + expect(containsAuthenticationError("InVaLiD tOkEn")).toBe(true); + }); + + test("should detect multiple keywords in same text", () => { + expect( + containsAuthenticationError("authentication failed: token expired (401)"), + ).toBe(true); + }); +});