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); + }); +});