Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions base-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down
114 changes: 104 additions & 10 deletions base-action/src/run-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<number>((resolve) => {
claudeProcess.on("close", (code) => {
resolve(code || 0);
});
// Wait for Claude to finish with timeout protection
const exitCode = await Promise.race([
new Promise<number>((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 {
Expand Down Expand Up @@ -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<typeof spawn>,
): Promise<number> {
return new Promise<number>((_, 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);
});
}
4 changes: 3 additions & 1 deletion base-action/src/validate-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
61 changes: 60 additions & 1 deletion base-action/test/run-claude.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});