diff --git a/base-action/action.yml b/base-action/action.yml index 9c4aa08..7ec09ec 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -190,7 +190,7 @@ runs: PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} run: | if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then - CLAUDE_CODE_VERSION="2.1.203" + CLAUDE_CODE_VERSION="2.1.220" echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." for attempt in 1 2 3; do echo "Installation attempt $attempt..." diff --git a/base-action/bun.lock b/base-action/bun.lock index f7c79c7..61f58c2 100644 --- a/base-action/bun.lock +++ b/base-action/bun.lock @@ -5,7 +5,7 @@ "name": "@step-security/claude-code-base-action", "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "0.3.203", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "axios": "^1.16.1", "shell-quote": "^1.8.4", }, @@ -27,23 +27,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.203", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.203" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-K/GMQlB3IpLtvqjI+/9Xcu//4wDRSxO2JimHMOM3zxHBKJA0EIb6U+4Ea0RSrxe+SHwFHT23XATL6U/9JggxXw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.203", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9uG6wp3reCtiWXA1H7NWZV5HFO6WgmedcpRCMpw0iyborLdw3MEZonJVlfRh93eQEVpflfnTOARTqtt7NzgHqg=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.203", "", { "os": "darwin", "cpu": "x64" }, "sha512-iP2cg+VovTYeU9f/l32Cq4cESNrgBvjZn/NipyhQ7RD468vBUjn22ii9cnGF7g9TepNrY3/IdkCPmwSea9besA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-p8wTbvWbQUscQBSefTdjwGbeVE6lYoEmMMdoNSOI8uR8jBr5YXoSwnSjBwmGDjz15WI4AIIdiWwyrf6sqrvqPA=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQNRpgHKuavsEyvY3SnDRZuCxf1z2pjGI4Q73Q0GuIEapwXrY2UM0ucAj0b7KtS6hpu//CGhnBra2kKOXsDQAw=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-YTW0+njIC61fZP3Qa9uzH9fOlEmApHG6SSxwyNxAQ8U3XX+yviL5/MyqLsauuUTVbyI9K7IqYOaE6xcDVDXIGg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-Hw2NNW8crM7ENR8peWZ+hG3ehUl9IYPzNxyqc1VM+odznB6squaki2xQeCcIatHbQImeW+DzrUFIDJLLz4pltg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.203", "", { "os": "win32", "cpu": "arm64" }, "sha512-EF2BPCSElFS9y76b/4TVU7RDw9xCr8DtxSUXAxYhcGsreo1gmJbMVdZ9tvLIvG6Ufquas4nrGmWh1ePLP2ol+g=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.203", "", { "os": "win32", "cpu": "x64" }, "sha512-sYf4UokyYhYO6Nke99o+gtgCakoaohB+Q/71i+4hEq0FYqVf31WoJ6lUTTTUPeEGVp6Ju6BblzmAX2MpvlWwCw=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/base-action/package.json b/base-action/package.json index 8211230..91ddeb6 100644 --- a/base-action/package.json +++ b/base-action/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "0.3.203", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "axios": "^1.16.1", "shell-quote": "^1.8.4" }, diff --git a/base-action/src/index.ts b/base-action/src/index.ts index 163d3b5..cb9af5c 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -125,7 +125,8 @@ async function run() { core.setOutput("conclusion", "failure"); process.exit(1); } finally { - // Stop refreshing the workload identity token file so the process can exit + // Stop refreshing the workload identity token file (so the process can + // exit) and delete the token material so it doesn't outlive this step workloadIdentity?.stop(); } } diff --git a/base-action/src/parse-sdk-options.ts b/base-action/src/parse-sdk-options.ts index e109509..a2c58d5 100644 --- a/base-action/src/parse-sdk-options.ts +++ b/base-action/src/parse-sdk-options.ts @@ -201,6 +201,9 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { // Detect if --json-schema is present (for hasJsonSchema flag) const hasJsonSchema = "json-schema" in extraArgs; + const modelFromClaudeArgs = extraArgs["model"] || undefined; + delete extraArgs["model"]; + const additionalDirectories = extraArgs["add-dir"] ? extraArgs["add-dir"] .split(ACCUMULATE_DELIMITER) @@ -304,7 +307,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { // Build SDK options - use merged tools from both direct options and claudeArgs const sdkOptions: SdkOptions = { // Direct options from ClaudeOptions inputs - model: options.model, + model: options.model || modelFromClaudeArgs, maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined, allowedTools: mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined, diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index 67d2da1..f83edf5 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -208,7 +208,10 @@ export async function runClaudeWithSdk( throw new Error("No result message received from Claude"); } - const isSuccess = resultMessage.subtype === "success"; + // subtype "success" with is_error:true means the run errored without producing + // a real result — treat it as failure so CI does not show a misleading green check. + const isSuccess = + resultMessage.subtype === "success" && !resultMessage.is_error; result.conclusion = isSuccess ? "success" : "failure"; // Handle structured output @@ -234,14 +237,21 @@ export async function runClaudeWithSdk( } if (!isSuccess) { + if (resultMessage.subtype === "success" && resultMessage.is_error) { + core.error( + "Claude result reported subtype success with is_error:true (run did not complete successfully)", + ); + } if ("errors" in resultMessage && resultMessage.errors) { core.error(`Execution failed: ${resultMessage.errors.join(", ")}`); } throw new Error( `Claude execution failed: ${ - "errors" in resultMessage && resultMessage.errors - ? resultMessage.errors.join(", ") - : "unknown error" + resultMessage.subtype === "success" && resultMessage.is_error + ? "result is_error:true" + : "errors" in resultMessage && resultMessage.errors + ? resultMessage.errors.join(", ") + : "unknown error" }`, ); } diff --git a/base-action/src/workload-identity.ts b/base-action/src/workload-identity.ts index 91266f3..79ebaf5 100644 --- a/base-action/src/workload-identity.ts +++ b/base-action/src/workload-identity.ts @@ -15,7 +15,8 @@ */ import * as core from "@actions/core"; -import { mkdirSync, writeFileSync } from "fs"; +import { createHash } from "crypto"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { retryWithBackoff } from "./retry"; @@ -50,6 +51,63 @@ async function fetchIdentityToken(audience: string) { return retryWithBackoff(() => core.getIDToken(audience)); } +/** + * Writes a profile config that switches federation resolution to the + * file-backed path. Resolving federation through a profile (rather than bare + * env vars) enables the SDK's on-disk credentials cache, so the several + * `claude` processes the action spawns (plugin installs, main query) share + * one exchanged access token instead of each re-exchanging the single-use + * GitHub OIDC token, which fails with 401 (`jti_reused`). + * + * The profile is intentionally minimal: the SDK gap-fills the federation + * fields (rule, organization, identity-token file, service account, base URL) + * from the ANTHROPIC_* env vars the action already exports, so the file only + * needs to exist to turn the cache on. + * + * The config dir name embeds a fingerprint of the federation inputs. The + * SDK's cache reuses a token on `expires_at` alone, with no record of the + * config that minted it, and the token's scope is bound at mint time — so a + * later action step in the same job (RUNNER_TEMP is per-job) with different + * federation inputs must land in a different dir or it would silently reuse + * the first step's token. + * + * Sharing the cache is only safe while the action spawns its `claude` + * subprocesses sequentially: the SDK cache is not cross-process serialized, + * and concurrent cache misses would each re-exchange the same single-use + * identity token. Parallelizing the plugin installs would reintroduce the + * `jti_reused` failures. + */ +function writeFederationProfile(baseDir: string): string { + // Every input that changes which credential the exchange mints must be in + // here; service_account_id and scope are sent in the exchange request body. + const fingerprint = createHash("sha256") + .update( + JSON.stringify([ + process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() ?? "", + process.env.ANTHROPIC_ORGANIZATION_ID?.trim() ?? "", + process.env.ANTHROPIC_SERVICE_ACCOUNT_ID?.trim() ?? "", + process.env.ANTHROPIC_WORKSPACE_ID?.trim() ?? "", + process.env.ANTHROPIC_BASE_URL?.trim() ?? "", + process.env.ANTHROPIC_SCOPE?.trim() ?? "", + ]), + ) + .digest("hex") + .slice(0, 16); + const configDir = join(baseDir, `config-${fingerprint}`); + + mkdirSync(join(configDir, "configs"), { recursive: true, mode: 0o700 }); + writeFileSync( + join(configDir, "configs", "default.json"), + JSON.stringify( + { version: "1.0", authentication: { type: "oidc_federation" } }, + null, + 2, + ), + { mode: 0o600 }, + ); + return configDir; +} + /** * Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP, * exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so @@ -57,7 +115,8 @@ async function fetchIdentityToken(audience: string) { * * Returns undefined when federation is not configured or is shadowed by a * higher-precedence credential. Callers must invoke stop() when execution - * finishes. + * finishes; it also deletes the identity token and any cached exchanged + * credential. */ export async function setupWorkloadIdentity(): Promise< WorkloadIdentityHandle | undefined @@ -101,6 +160,17 @@ export async function setupWorkloadIdentity(): Promise< } process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile; + if ( + process.env.ANTHROPIC_CONFIG_DIR?.trim() || + process.env.ANTHROPIC_PROFILE?.trim() + ) { + core.warning( + "ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE is already set, so the action will not write its own federation profile. Credential caching across the spawned Claude processes follows the existing profile configuration.", + ); + } else { + process.env.ANTHROPIC_CONFIG_DIR = writeFederationProfile(tokenDir); + process.env.ANTHROPIC_PROFILE = "default"; + } console.log( `Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`, ); @@ -115,6 +185,12 @@ export async function setupWorkloadIdentity(): Promise< return { tokenFile, - stop: () => clearInterval(refreshInterval), + stop: () => { + clearInterval(refreshInterval); + // RUNNER_TEMP is per-job, not per-step: remove the identity token, the + // profile, and the cached exchanged credential so they don't outlive + // this step. + rmSync(tokenDir, { recursive: true, force: true }); + }, }; } diff --git a/base-action/test/parse-sdk-options.test.ts b/base-action/test/parse-sdk-options.test.ts index 924d7fb..813bafd 100644 --- a/base-action/test/parse-sdk-options.test.ts +++ b/base-action/test/parse-sdk-options.test.ts @@ -106,7 +106,8 @@ describe("parseSdkOptions", () => { const result = parseSdkOptions(options); expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined(); - expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + expect(result.sdkOptions.model).toBe("claude-3-5-sonnet"); }); test("should handle hyphenated --allowed-tools flag", () => { @@ -366,7 +367,8 @@ describe("parseSdkOptions", () => { ); expect(mcpConfig.mcpServers).toHaveProperty("server1"); expect(mcpConfig.mcpServers).toHaveProperty("server2"); - expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + expect(result.sdkOptions.model).toBe("claude-3-5-sonnet"); }); test("should handle real-world scenario: action config + user config", () => { @@ -436,7 +438,8 @@ describe("parseSdkOptions", () => { const result = parseSdkOptions(options); expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]); - expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + expect(result.sdkOptions.model).toBe("claude-3-5-sonnet"); expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined(); }); }); @@ -464,7 +467,8 @@ describe("parseSdkOptions", () => { const result = parseSdkOptions(options); - expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + expect(result.sdkOptions.model).toBe("claude-haiku"); expect(result.sdkOptions.allowedTools).toEqual(["Edit"]); }); @@ -475,7 +479,8 @@ describe("parseSdkOptions", () => { const result = parseSdkOptions(options); - expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + expect(result.sdkOptions.model).toBe("claude-haiku"); }); test("should not strip inline # that appears inside a quoted value", () => { @@ -485,11 +490,37 @@ describe("parseSdkOptions", () => { const result = parseSdkOptions(options); - expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + expect(result.sdkOptions.model).toBe("claude-haiku"); expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000"); }); }); + describe("model handling", () => { + test("should map --model from claudeArgs to sdkOptions.model", () => { + const options: ClaudeOptions = { + claudeArgs: "--model claude-haiku-4-5-20251001", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.model).toBe("claude-haiku-4-5-20251001"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + }); + + test("should prefer direct model option over --model from claudeArgs", () => { + const options: ClaudeOptions = { + model: "claude-sonnet-4-6", + claudeArgs: "--model claude-haiku-4-5-20251001", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.model).toBe("claude-sonnet-4-6"); + expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); + }); + }); + describe("environment variables passthrough", () => { test("should include OTEL environment variables in sdkOptions.env", () => { // Set up test environment variables diff --git a/base-action/test/run-claude-sdk.test.ts b/base-action/test/run-claude-sdk.test.ts index 877e884..cb50318 100644 --- a/base-action/test/run-claude-sdk.test.ts +++ b/base-action/test/run-claude-sdk.test.ts @@ -63,4 +63,69 @@ describe("runClaudeWithSdk", () => { consoleLogSpy.mockRestore(); } }); + + test("fails when result subtype is success but is_error is true", async () => { + const consoleErrorSpy = spyOn(console, "error").mockImplementation( + () => {}, + ); + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + const coreErrorSpy = spyOn( + await import("@actions/core"), + "error", + ).mockImplementation(() => {}); + + tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-")); + process.env.RUNNER_TEMP = tempDir; + + const promptPath = join(tempDir, "prompt.txt"); + await writeFile(promptPath, "test prompt"); + + const initMessage = { + type: "system", + subtype: "init", + session_id: "session-123", + model: "claude-sonnet-5", + }; + + const errorResultMessage = { + type: "result", + subtype: "success", + is_error: true, + duration_ms: 434, + num_turns: 1, + total_cost_usd: 0, + permission_denials: [], + }; + + mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: async function* () { + yield initMessage; + yield errorResultMessage; + }, + })); + + try { + const { runClaudeWithSdk } = await import("../src/run-claude-sdk"); + + await expect( + runClaudeWithSdk(promptPath, { + sdkOptions: {}, + showFullOutput: false, + hasJsonSchema: false, + }), + ).rejects.toThrow("result is_error:true"); + + const executionFile = join(tempDir, "claude-execution-output.json"); + await expect(readFile(executionFile, "utf-8")).resolves.toBe( + JSON.stringify([initMessage, errorResultMessage], null, 2), + ); + expect(coreErrorSpy).toHaveBeenCalledWith( + "Claude result reported subtype success with is_error:true (run did not complete successfully)", + ); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + coreErrorSpy.mockRestore(); + } + }); }); diff --git a/base-action/test/workload-identity.test.ts b/base-action/test/workload-identity.test.ts index e95f4e0..53a39af 100644 --- a/base-action/test/workload-identity.test.ts +++ b/base-action/test/workload-identity.test.ts @@ -27,6 +27,12 @@ describe("workload identity federation", () => { delete process.env.ANTHROPIC_ORGANIZATION_ID; delete process.env.ANTHROPIC_OIDC_AUDIENCE; delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE; + delete process.env.ANTHROPIC_SERVICE_ACCOUNT_ID; + delete process.env.ANTHROPIC_WORKSPACE_ID; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_SCOPE; + delete process.env.ANTHROPIC_CONFIG_DIR; + delete process.env.ANTHROPIC_PROFILE; getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue( "test-identity-token", @@ -123,5 +129,99 @@ describe("workload identity federation", () => { handle?.stop(); } }); + + test("writes a minimal federation profile and selects it", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + process.env.ANTHROPIC_SERVICE_ACCOUNT_ID = "svac_test"; + process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_test"; + + const handle = await setupWorkloadIdentity(); + try { + const configDir = process.env.ANTHROPIC_CONFIG_DIR; + expect(configDir).toBeDefined(); + expect( + configDir!.startsWith( + join(tempDir, "claude-workload-identity", "config-"), + ), + ).toBe(true); + expect(process.env.ANTHROPIC_PROFILE).toBe("default"); + + const profilePath = join(configDir!, "configs", "default.json"); + expect(statSync(profilePath).mode & 0o777).toBe(0o600); + // Minimal on purpose: the SDK gap-fills the federation fields from + // the ANTHROPIC_* env vars the action exports. + expect(JSON.parse(readFileSync(profilePath, "utf-8"))).toEqual({ + version: "1.0", + authentication: { type: "oidc_federation" }, + }); + } finally { + handle?.stop(); + } + }); + + test("derives the config dir from the federation inputs", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_a"; + + (await setupWorkloadIdentity())?.stop(); + const firstConfigDir = process.env.ANTHROPIC_CONFIG_DIR; + expect(firstConfigDir).toBeDefined(); + + // A later step in the same job with a different workspace must not + // share the first step's credentials cache. + delete process.env.ANTHROPIC_CONFIG_DIR; + delete process.env.ANTHROPIC_PROFILE; + process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_b"; + + (await setupWorkloadIdentity())?.stop(); + const secondConfigDir = process.env.ANTHROPIC_CONFIG_DIR; + expect(secondConfigDir).toBeDefined(); + expect(secondConfigDir).not.toBe(firstConfigDir); + + // Same inputs land in the same dir, so an unchanged config can still + // reuse a cached token. + delete process.env.ANTHROPIC_CONFIG_DIR; + delete process.env.ANTHROPIC_PROFILE; + + (await setupWorkloadIdentity())?.stop(); + expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(secondConfigDir!); + }); + + test("does not overwrite an operator-set ANTHROPIC_PROFILE", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + process.env.ANTHROPIC_PROFILE = "operator"; + + const handle = await setupWorkloadIdentity(); + try { + // The action must warn and leave the operator's profile untouched. + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining("ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE"), + ); + expect(process.env.ANTHROPIC_PROFILE).toBe("operator"); + expect(process.env.ANTHROPIC_CONFIG_DIR).toBeUndefined(); + } finally { + handle?.stop(); + } + }); + + test("stop() removes the token dir so credentials don't outlive the step", async () => { + process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test"; + process.env.ANTHROPIC_ORGANIZATION_ID = + "00000000-0000-0000-0000-000000000000"; + + const handle = await setupWorkloadIdentity(); + const tokenFile = handle!.tokenFile; + const tokenDir = join(tempDir, "claude-workload-identity"); + + expect(existsSync(tokenFile)).toBe(true); + handle!.stop(); + expect(existsSync(tokenDir)).toBe(false); + }); }); }); diff --git a/bun.lock b/bun.lock index 884d84b..eac4d32 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^8.0.1", - "@anthropic-ai/claude-agent-sdk": "0.3.203", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", @@ -37,23 +37,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.203", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.203" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-K/GMQlB3IpLtvqjI+/9Xcu//4wDRSxO2JimHMOM3zxHBKJA0EIb6U+4Ea0RSrxe+SHwFHT23XATL6U/9JggxXw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.203", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9uG6wp3reCtiWXA1H7NWZV5HFO6WgmedcpRCMpw0iyborLdw3MEZonJVlfRh93eQEVpflfnTOARTqtt7NzgHqg=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.203", "", { "os": "darwin", "cpu": "x64" }, "sha512-iP2cg+VovTYeU9f/l32Cq4cESNrgBvjZn/NipyhQ7RD468vBUjn22ii9cnGF7g9TepNrY3/IdkCPmwSea9besA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-p8wTbvWbQUscQBSefTdjwGbeVE6lYoEmMMdoNSOI8uR8jBr5YXoSwnSjBwmGDjz15WI4AIIdiWwyrf6sqrvqPA=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQNRpgHKuavsEyvY3SnDRZuCxf1z2pjGI4Q73Q0GuIEapwXrY2UM0ucAj0b7KtS6hpu//CGhnBra2kKOXsDQAw=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-YTW0+njIC61fZP3Qa9uzH9fOlEmApHG6SSxwyNxAQ8U3XX+yviL5/MyqLsauuUTVbyI9K7IqYOaE6xcDVDXIGg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-Hw2NNW8crM7ENR8peWZ+hG3ehUl9IYPzNxyqc1VM+odznB6squaki2xQeCcIatHbQImeW+DzrUFIDJLLz4pltg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.203", "", { "os": "win32", "cpu": "arm64" }, "sha512-EF2BPCSElFS9y76b/4TVU7RDw9xCr8DtxSUXAxYhcGsreo1gmJbMVdZ9tvLIvG6Ufquas4nrGmWh1ePLP2ol+g=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.203", "", { "os": "win32", "cpu": "x64" }, "sha512-sYf4UokyYhYO6Nke99o+gtgCakoaohB+Q/71i+4hEq0FYqVf31WoJ6lUTTTUPeEGVp6Ju6BblzmAX2MpvlWwCw=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/docs/configuration.md b/docs/configuration.md index 017c82c..f4c311f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -337,17 +337,17 @@ For a complete list of available settings and their descriptions, see the [Claud Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate: -| Old Input | New Approach | -| --------------------- | -------------------------------------------------------- | -| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` | -| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` | -| `max_turns` | Use `claude_args: "--max-turns 10"` | -| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` | -| `claude_env` | Use `settings` with `"env"` object | -| `custom_instructions` | Use `claude_args: "--system-prompt 'Your instructions'"` | -| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` | -| `direct_prompt` | Use `prompt` input instead | -| `override_prompt` | Use `prompt` with GitHub context variables | +| Old Input | New Approach | +| --------------------- | --------------------------------------------------------------- | +| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` | +| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` | +| `max_turns` | Use `claude_args: "--max-turns 10"` | +| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` | +| `claude_env` | Use `settings` with `"env"` object | +| `custom_instructions` | Use `claude_args: "--append-system-prompt 'Your instructions'"` | +| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` | +| `direct_prompt` | Use `prompt` input instead | +| `override_prompt` | Use `prompt` with GitHub context variables | ## Custom Executables for Specialized Environments diff --git a/docs/custom-automations.md b/docs/custom-automations.md index 2123917..36def4e 100644 --- a/docs/custom-automations.md +++ b/docs/custom-automations.md @@ -26,7 +26,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg ## Automated Documentation Updates -Automatically update documentation when specific files change (see [`examples/claude-pr-path-specific.yml`](../examples/claude-pr-path-specific.yml)): +Automatically update documentation when specific files change (see [`examples/pr-review-filtered-paths.yml`](../examples/pr-review-filtered-paths.yml)): ```yaml on: @@ -47,7 +47,7 @@ When API files are modified, the action automatically detects that a `prompt` is ## Author-Specific Code Reviews -Automatically review PRs from specific authors or external contributors (see [`examples/claude-review-from-author.yml`](../examples/claude-review-from-author.yml)): +Automatically review PRs from specific authors or external contributors (see [`examples/pr-review-filtered-authors.yml`](../examples/pr-review-filtered-authors.yml)): ```yaml on: diff --git a/docs/faq.md b/docs/faq.md index 8ddac9e..0175c67 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -153,7 +153,7 @@ prompt: "Review this PR for security vulnerabilities" **These inputs are deprecated in v1.0:** - **`direct_prompt`** → Use `prompt` instead -- **`custom_instructions`** → Use `claude_args` with `--system-prompt` +- **`custom_instructions`** → Use `claude_args` with `--append-system-prompt` (appends to the default system prompt, matching v0 behavior; `--system-prompt` replaces it entirely) Migration examples: @@ -165,7 +165,7 @@ custom_instructions: "Focus on security" # New (v1.0) prompt: "Review this PR" claude_args: | - --system-prompt "Focus on security" + --append-system-prompt "Focus on security" ``` ### Why doesn't Claude execute my bash commands? diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 362ab64..1d91c75 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -14,19 +14,19 @@ This guide helps you migrate from Claude Code Action v0.x to v1.0. The new versi The following inputs have been deprecated and replaced: -| Deprecated Input | Replacement | Notes | -| --------------------- | ------------------------------------ | --------------------------------------------- | -| `mode` | Auto-detected | Action automatically chooses based on context | -| `direct_prompt` | `prompt` | Direct drop-in replacement | -| `override_prompt` | `prompt` | Use GitHub context variables instead | -| `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments | -| `max_turns` | `claude_args: --max-turns` | Use CLI format | -| `model` | `claude_args: --model` | Specify via CLI | -| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format | -| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format | -| `claude_env` | `settings` with env object | Use settings JSON | -| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments | -| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level | +| Deprecated Input | Replacement | Notes | +| --------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- | +| `mode` | Auto-detected | Action automatically chooses based on context | +| `direct_prompt` | `prompt` | Direct drop-in replacement | +| `override_prompt` | `prompt` | Use GitHub context variables instead | +| `custom_instructions` | `claude_args: --append-system-prompt` | Appends to the default prompt (v0 behavior); `--system-prompt` replaces it entirely | +| `max_turns` | `claude_args: --max-turns` | Use CLI format | +| `model` | `claude_args: --model` | Specify via CLI | +| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format | +| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format | +| `claude_env` | `settings` with env object | Use settings JSON | +| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments | +| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level | ## Migration Examples @@ -52,7 +52,7 @@ The following inputs have been deprecated and replaced: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | --max-turns 10 - --system-prompt "Follow our coding standards" + --append-system-prompt "Follow our coding standards" --allowedTools Edit,Read,Write ``` @@ -255,14 +255,15 @@ claude_args: | ### Common claude_args Options -| Option | Description | Example | -| ------------------- | ------------------------ | -------------------------------------- | -| `--max-turns` | Limit conversation turns | `--max-turns 10` | -| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` | -| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` | -| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` | -| `--system-prompt` | Add system instructions | `--system-prompt "Focus on security"` | -| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` | +| Option | Description | Example | +| ------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------ | +| `--max-turns` | Limit conversation turns | `--max-turns 10` | +| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` | +| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` | +| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` | +| `--system-prompt` | Replace the entire default system prompt | `--system-prompt "Focus on security"` | +| `--append-system-prompt` | Append to the default system prompt (keeps Claude Code's built-in prompt) | `--append-system-prompt "Follow our coding standards"` | +| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` | ## Provider-Specific Updates @@ -330,7 +331,7 @@ You can also pass MCP configuration from a file: - [ ] Remove `mode` input (auto-detected now) - [ ] Replace `direct_prompt` with `prompt` - [ ] Replace `override_prompt` with `prompt` using GitHub context -- [ ] Move `custom_instructions` to `claude_args` with `--system-prompt` +- [ ] Move `custom_instructions` to `claude_args` with `--append-system-prompt` - [ ] Convert `max_turns` to `claude_args` with `--max-turns` - [ ] Convert `model` to `claude_args` with `--model` - [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools` diff --git a/docs/usage.md b/docs/usage.md index 3982338..ce01cbd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -99,7 +99,7 @@ These inputs are deprecated and will be removed in a future version: | `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode | | `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` | | `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts | -| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` | +| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--append-system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` | | `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` | | `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` | | `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` | @@ -139,7 +139,7 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | --max-turns 10 - --system-prompt "Focus on security" + --append-system-prompt "Focus on security" ``` #### Automation Workflows diff --git a/package.json b/package.json index 7bf9645..5272f79 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^8.0.1", - "@anthropic-ai/claude-agent-sdk": "0.3.203", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", diff --git a/src/entrypoints/format-turns.ts b/src/entrypoints/format-turns.ts index 3241745..c18ab49 100755 --- a/src/entrypoints/format-turns.ts +++ b/src/entrypoints/format-turns.ts @@ -268,7 +268,8 @@ export function groupTurnsNaturally(data: Turn[]): GroupedContent[] { type: "system_init", tools_count: tools.length, }); - } else { + } else if (subtype !== "thinking_tokens") { + // Skip thinking_tokens - internal progress events not meant for summary groupedContent.push({ type: "system_other", data: turn, diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index c200607..42c4e0b 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -76,7 +76,7 @@ async function installClaudeCode(): Promise { return customExecutable; } - const claudeCodeVersion = "2.1.203"; + const claudeCodeVersion = "2.1.220"; console.log(`Installing Claude Code v${claudeCodeVersion}...`); for (let attempt = 1; attempt <= 3; attempt++) { @@ -366,7 +366,8 @@ async function run() { } finally { // Phase 4: Cleanup (always runs) - // Stop refreshing the workload identity token file + // Stop refreshing the workload identity token file and delete the token + // material so it doesn't outlive this step workloadIdentity?.stop(); // Update tracking comment diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index 4d12388..cea2ea2 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -204,11 +204,9 @@ export function isBodySafeToUse( * @param excludeActors - Comma-separated actors to exclude * @returns Filtered array of comments */ -export function filterCommentsByActor( - comments: T[], - includeActors: string = "", - excludeActors: string = "", -): T[] { +export function filterCommentsByActor< + T extends { author: { login: string } | null }, +>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] { const includeParsed = parseActorFilter(includeActors); const excludeParsed = parseActorFilter(excludeActors); @@ -219,7 +217,9 @@ export function filterCommentsByActor( return comments.filter((comment) => shouldIncludeCommentByActor( - comment.author.login, + // author is null for comments from deleted ("ghost") accounts; treat them + // as the "ghost" login so filtering never dereferences null and crashes. + comment.author?.login ?? "ghost", includeParsed, excludeParsed, ), diff --git a/src/github/data/formatter.ts b/src/github/data/formatter.ts index 398cc74..95d5603 100644 --- a/src/github/data/formatter.ts +++ b/src/github/data/formatter.ts @@ -21,7 +21,7 @@ export function formatContext( const prData = contextData as GitHubPullRequest; const sanitizedTitle = sanitizeContent(prData.title); return `PR Title: ${sanitizedTitle} -PR Author: ${prData.author.login} +PR Author: ${prData.author?.login ?? "ghost"} PR Branch: ${prData.headRefName} -> ${prData.baseRefName} PR State: ${prData.state} PR Labels: ${formatLabels(prData.labels.nodes)} @@ -33,7 +33,7 @@ Changed Files: ${prData.files.nodes.length} files`; const issueData = contextData as GitHubIssue; const sanitizedTitle = sanitizeContent(issueData.title); return `Issue Title: ${sanitizedTitle} -Issue Author: ${issueData.author.login} +Issue Author: ${issueData.author?.login ?? "ghost"} Issue State: ${issueData.state} Issue Labels: ${formatLabels(issueData.labels.nodes)}`; } @@ -71,7 +71,7 @@ export function formatComments( body = sanitizeContent(body); - return `[${comment.author.login} at ${comment.createdAt}]: ${body}`; + return `[${comment.author?.login ?? "ghost"} at ${comment.createdAt}]: ${body}`; }) .join("\n\n"); } @@ -85,7 +85,7 @@ export function formatReviewComments( } const formattedReviews = reviewData.nodes.map((review) => { - let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`; + let reviewOutput = `[Review by ${review.author?.login ?? "ghost"} at ${review.submittedAt}]: ${review.state}`; if (review.body && review.body.trim()) { let body = review.body; diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 253f763..e095280 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -27,7 +27,7 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined { * This prevents command injection by ensuring only safe characters are used. * * Valid branch names: - * - Start with alphanumeric character or @ (not dash, to prevent option injection) + * - Start with alphanumeric character, underscore, or @ (not dash, to prevent option injection) * - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@) * - Do not start or end with a period * - Do not end with a slash @@ -68,12 +68,15 @@ export function validateBranchName(branchName: string): void { // @ is valid per git-check-ref-format anywhere in a ref name, including the first character // (e.g. ticket conventions like "TICKET-123@add-feature" or prefixes like "@hotfix/..."); // the bare name "@" (HEAD shorthand) and the "@{" sequence (reflog syntax) are rejected below. + // _ is valid per git-check-ref-format anywhere in a ref name, including the first character; + // leading underscores are a common convention for release/internal branches (e.g. + // "_release/v1.2.3"), which previously failed validation as a PR's base branch. // All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk. - const validPattern = /^[a-zA-Z0-9@][a-zA-Z0-9/_.#+,@-]*$/; + const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@-]*$/; if (!validPattern.test(branchName)) { throw new Error( - `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`, + `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`, ); } diff --git a/src/github/types.ts b/src/github/types.ts index 6ed41e3..feeb7d7 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -1,4 +1,8 @@ // Types for GitHub GraphQL query responses + +// GitHub's GraphQL `author`/`actor` fields resolve to null when the underlying +// account has been deleted (the "ghost" user). Any field typed as +// `GitHubAuthor | null` can therefore be null at runtime and must be guarded. export type GitHubAuthor = { login: string; name?: string; @@ -8,7 +12,7 @@ export type GitHubComment = { id: string; databaseId: string; body: string; - author: GitHubAuthor; + author: GitHubAuthor | null; createdAt: string; updatedAt?: string; lastEditedAt?: string; @@ -39,7 +43,7 @@ export type GitHubFile = { export type GitHubReview = { id: string; databaseId: string; - author: GitHubAuthor; + author: GitHubAuthor | null; body: string; state: string; submittedAt: string; @@ -53,7 +57,7 @@ export type GitHubReview = { export type GitHubPullRequest = { title: string; body: string; - author: GitHubAuthor; + author: GitHubAuthor | null; baseRefName: string; headRefName: string; headRefOid: string; @@ -95,7 +99,7 @@ export type GitHubPullRequest = { export type GitHubIssue = { title: string; body: string; - author: GitHubAuthor; + author: GitHubAuthor | null; createdAt: string; updatedAt?: string; lastEditedAt?: string; diff --git a/src/github/utils/sanitizer.ts b/src/github/utils/sanitizer.ts index 0cb8408..47f60ab 100644 --- a/src/github/utils/sanitizer.ts +++ b/src/github/utils/sanitizer.ts @@ -10,7 +10,13 @@ export function stripInvisibleCharacters(content: string): string { } export function stripMarkdownImageAltText(content: string): string { - return content.replace(/!\[[^\]]*\]\(/g, "![]("); + // Inline images: ![alt](url) -> ![](url) + content = content.replace(/!\[[^\]]*\]\(/g, "![]("); + // Reference-style images: ![alt][ref] -> ![][ref] (keep the label, drop the + // alt text, which is otherwise a hidden-instruction channel just like the + // inline form above). + content = content.replace(/!\[[^\]]*\](\[[^\]]*\])/g, "![]$1"); + return content; } export function stripMarkdownLinkTitles(content: string): string { @@ -83,6 +89,12 @@ export function redactGitHubTokens(content: string): string { "[REDACTED_GITHUB_TOKEN]", ); + // GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) + content = content.replace( + /\bghu_[A-Za-z0-9]{36}\b/g, + "[REDACTED_GITHUB_TOKEN]", + ); + // GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) content = content.replace( /\bghs_[A-Za-z0-9]{36}\b/g, diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 0056dd6..fecfacd 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -28,6 +28,20 @@ function extractDescription( .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens } +/** + * Sanitizes a label into a git-safe branch segment. Labels are free-form and + * often scoped (e.g. "area:permissions"), so characters that are invalid in a + * branch name (":", "/", spaces, ...) are replaced with a hyphen rather than + * dropped, keeping the label readable. Returns "" if nothing usable remains. + */ +function sanitizeLabel(label: string): string { + return label + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen + .replace(/-+/g, "-") // Collapse multiple hyphens + .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens +} + export interface BranchTemplateVariables { prefix: string; entityType: string; @@ -78,7 +92,7 @@ export function generateBranchName( entityNumber, timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`, sha: sha?.substring(0, 8), // First 8 characters of SHA - label: label || entityType, // Fall back to entityType if no label + label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label description: title ? extractDescription(title) : undefined, }; diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 62ab6c1..418eaae 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -5,6 +5,7 @@ import { applyBranchTemplate, generateBranchName, } from "../src/utils/branch-template"; +import { validateBranchName } from "../src/github/operations/branch"; describe("branch template utilities", () => { describe("applyBranchTemplate", () => { @@ -144,6 +145,53 @@ describe("branch template utilities", () => { expect(result).toBe("dev/enhancement-issue_789"); }); + it("should sanitize scoped labels that contain invalid git characters", () => { + const template = "{{prefix}}{{label}}/{{entityNumber}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + "area:permissions", + ); + + expect(result).toBe("claude/area-permissions/123"); + // Regression: an unsanitized ":" here previously failed validateBranchName + // and crashed the run via process.exit(1). + expect(() => validateBranchName(result)).not.toThrow(); + }); + + it("should replace spaces in labels with hyphens", () => { + const template = "{{prefix}}{{label}}-{{entityNumber}}"; + const result = generateBranchName( + template, + "fix/", + "issue", + 456, + undefined, + "needs review", + ); + + expect(result).toBe("fix/needs-review-456"); + expect(() => validateBranchName(result)).not.toThrow(); + }); + + it("should fall back to entityType when a label sanitizes to empty", () => { + const template = "{{prefix}}{{label}}-{{entityNumber}}"; + const result = generateBranchName( + template, + "fix/", + "pr", + 789, + undefined, + "🎉", + ); + + expect(result).toBe("fix/pr-789"); + expect(() => validateBranchName(result)).not.toThrow(); + }); + it("should use description in template when provided", () => { const template = "{{prefix}}{{description}}/{{entityNumber}}"; const result = generateBranchName( diff --git a/test/comments-common.test.ts b/test/comments-common.test.ts new file mode 100644 index 0000000..b37dec9 --- /dev/null +++ b/test/comments-common.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect } from "bun:test"; +import { + SPINNER_HTML, + createJobRunLink, + createBranchLink, + createCommentBody, +} from "../src/github/operations/comments/common"; +import { GITHUB_SERVER_URL } from "../src/github/api/config"; + +describe("comments/common", () => { + describe("createJobRunLink", () => { + test("builds a markdown link to the workflow run", () => { + const result = createJobRunLink("anthropics", "claude-code-action", "42"); + expect(result).toBe( + `[View job run](${GITHUB_SERVER_URL}/anthropics/claude-code-action/actions/runs/42)`, + ); + }); + + test("honors GITHUB_SERVER_URL (GHES) rather than hardcoding github.com", () => { + // The link is built from the configured server URL, so it must point at + // whatever GITHUB_SERVER_URL resolves to (github.com by default, a GHES + // host in enterprise setups). + expect(createJobRunLink("o", "r", "1")).toContain(GITHUB_SERVER_URL); + }); + }); + + describe("createBranchLink", () => { + test("builds a leading-newline markdown link to the branch tree", () => { + const result = createBranchLink( + "anthropics", + "claude-code-action", + "feature/x", + ); + expect(result).toBe( + `\n[View branch](${GITHUB_SERVER_URL}/anthropics/claude-code-action/tree/feature/x)`, + ); + }); + + test("prefixes the link with a newline so it renders on its own line", () => { + expect(createBranchLink("o", "r", "main").startsWith("\n")).toBe(true); + }); + }); + + describe("createCommentBody", () => { + test("includes the spinner, the working message, and the job run link", () => { + const jobRunLink = createJobRunLink("o", "r", "7"); + const body = createCommentBody(jobRunLink); + + expect(body).toContain(SPINNER_HTML); + expect(body).toContain("Claude Code is working…"); + expect(body).toContain(jobRunLink); + }); + + test("omits the branch link when none is provided (defaults to empty)", () => { + const body = createCommentBody(createJobRunLink("o", "r", "7")); + expect(body).not.toContain("View branch"); + // No trailing branch content: body ends with the job run link. + expect(body.endsWith(")")).toBe(true); + }); + + test("appends the branch link when provided", () => { + const jobRunLink = createJobRunLink("o", "r", "7"); + const branchLink = createBranchLink("o", "r", "feature/x"); + const body = createCommentBody(jobRunLink, branchLink); + + expect(body).toContain(jobRunLink); + expect(body).toContain(branchLink); + // The branch link (with its leading newline) comes after the job run link. + expect(body.indexOf(branchLink)).toBeGreaterThan( + body.indexOf(jobRunLink), + ); + }); + }); +}); diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 44e510c..046a78f 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -6,8 +6,10 @@ import { getEventTypeAndContext, buildAllowedToolsString, buildDisallowedToolsString, + prepareContext, } from "../src/create-prompt"; import type { PreparedContext } from "../src/create-prompt"; +import { createMockContext } from "./mockContext"; beforeAll(() => { process.env.GITHUB_ACTION_PATH = "/test/action/path"; @@ -1270,3 +1272,83 @@ describe("buildDisallowedToolsString", () => { expect(result).toBe("BadTool1,BadTool2"); }); }); + +describe("prepareContext validation errors", () => { + const commentId = "12345"; + + test("throws on an unsupported event type", () => { + const context = createMockContext({ + eventName: "deployment_status" as any, + }); + + expect(() => prepareContext(context, commentId)).toThrow( + "Unsupported event type: deployment_status", + ); + }); + + test("pull_request event requires a PR number (isPR must be true)", () => { + const context = createMockContext({ + eventName: "pull_request", + eventAction: "opened", + isPR: false, + }); + + expect(() => prepareContext(context, commentId)).toThrow( + "PR_NUMBER is required for pull_request event", + ); + }); + + test("pull_request_review event requires a PR number", () => { + const context = createMockContext({ + eventName: "pull_request_review", + isPR: false, + payload: { + review: { body: "please fix", user: { login: "user1" } }, + } as any, + }); + + expect(() => prepareContext(context, commentId)).toThrow( + "PR_NUMBER is required for pull_request_review event", + ); + }); + + test("issues event requires an event action", () => { + const context = createMockContext({ + eventName: "issues", + eventAction: "", + isPR: false, + payload: { issue: { user: { login: "user1" } } } as any, + }); + + expect(() => prepareContext(context, commentId)).toThrow( + "GITHUB_EVENT_ACTION is required for issues event", + ); + }); + + test("issues event rejects an unsupported action", () => { + const context = createMockContext({ + eventName: "issues", + eventAction: "deleted", + isPR: false, + payload: { issue: { user: { login: "user1" } } } as any, + }); + + expect(() => + prepareContext(context, commentId, "main", "claude/issue-1"), + ).toThrow("Unsupported issue action: deleted"); + }); + + test("issue_comment on an issue requires a claude branch", () => { + const context = createMockContext({ + eventName: "issue_comment", + isPR: false, + payload: { + comment: { id: 999, body: "@claude help", user: { login: "user1" } }, + } as any, + }); + + expect(() => prepareContext(context, commentId)).toThrow( + "CLAUDE_BRANCH is required for issue_comment event", + ); + }); +}); diff --git a/test/data-fetcher.test.ts b/test/data-fetcher.test.ts index f92fc14..c054039 100644 --- a/test/data-fetcher.test.ts +++ b/test/data-fetcher.test.ts @@ -1499,4 +1499,42 @@ describe("filterCommentsByActor", () => { const filtered = filterCommentsByActor(comments, "user1", ""); expect(filtered).toHaveLength(0); }); + + test("does not crash on comments from deleted (null-author) accounts", () => { + // GitHub's GraphQL returns author: null for comments whose account was + // deleted. With an exclude filter set (the exact `*[bot]` config we + // recommend), the null author must not throw when dereferenced. + const comments = [ + { author: { login: "user1" }, body: "comment1" }, + { author: null, body: "from a deleted account" }, + { author: { login: "bot[bot]" }, body: "comment3" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + const filtered = filterCommentsByActor(comments, "", "*[bot]"); + // ghost comment is retained (it matches no exclude pattern); the bot is dropped. + expect(filtered).toHaveLength(2); + expect(filtered.map((c: any) => c.body)).toEqual([ + "comment1", + "from a deleted account", + ]); + }); + + test("treats null author as the 'ghost' login for include/exclude", () => { + const comments = [ + { author: null, body: "from a deleted account" }, + { author: { login: "user1" }, body: "comment2" }, + ]; + + const { filterCommentsByActor } = require("../src/github/data/fetcher"); + // Excluding "ghost" removes the deleted-account comment. + expect(filterCommentsByActor(comments, "", "ghost")).toHaveLength(1); + expect(filterCommentsByActor(comments, "", "ghost")[0].body).toBe( + "comment2", + ); + // Including only "ghost" keeps just the deleted-account comment. + const onlyGhost = filterCommentsByActor(comments, "ghost", ""); + expect(onlyGhost).toHaveLength(1); + expect(onlyGhost[0].body).toBe("from a deleted account"); + }); }); diff --git a/test/data-formatter.test.ts b/test/data-formatter.test.ts index 5f7aad3..f72539d 100644 --- a/test/data-formatter.test.ts +++ b/test/data-formatter.test.ts @@ -159,6 +159,21 @@ Issue State: OPEN Issue Labels: architecture, agent-sdk, drift:functional`, ); }); + + test("renders a deleted (null-author) issue author as 'ghost'", () => { + const issueData: GitHubIssue = { + title: "Test Issue", + body: "Issue body", + author: null, + createdAt: "2023-01-01T00:00:00Z", + state: "OPEN", + labels: { nodes: [] }, + comments: { nodes: [] }, + }; + + const result = formatContext(issueData, false); + expect(result).toContain("Issue Author: ghost"); + }); }); describe("formatBody", () => { @@ -257,6 +272,24 @@ describe("formatComments", () => { expect(result).toBe(""); }); + test("renders deleted (null-author) comments as 'ghost'", () => { + // GitHub returns author: null for comments from deleted accounts. + const comments: GitHubComment[] = [ + { + id: "1", + databaseId: "100001", + body: "From a deleted account", + author: null, + createdAt: "2023-01-01T00:00:00Z", + }, + ]; + + const result = formatComments(comments); + expect(result).toBe( + "[ghost at 2023-01-01T00:00:00Z]: From a deleted account", + ); + }); + test("replaces image URLs in comments", () => { const comments: GitHubComment[] = [ { @@ -528,6 +561,29 @@ describe("formatReviewComments", () => { ); }); + test("renders deleted (null-author) reviews as 'ghost'", () => { + const reviewData = { + nodes: [ + { + id: "review1", + databaseId: "300099", + author: null, + body: "Left before deleting the account", + state: "COMMENTED", + submittedAt: "2023-01-01T00:00:00Z", + comments: { + nodes: [], + }, + }, + ], + }; + + const result = formatReviewComments(reviewData); + expect(result).toBe( + `[Review by ghost at 2023-01-01T00:00:00Z]: COMMENTED\nLeft before deleting the account`, + ); + }); + test("returns empty string for null reviewData", () => { const result = formatReviewComments(null); expect(result).toBe(""); diff --git a/test/format-turns.test.ts b/test/format-turns.test.ts index e6ac058..7b59bbe 100644 --- a/test/format-turns.test.ts +++ b/test/format-turns.test.ts @@ -484,4 +484,34 @@ describe("system_other handling", () => { ]); expect(markdown).toContain("## ⚙️ System Message"); }); + + test("filters out thinking_tokens system messages", () => { + const data: Turn[] = [ + { type: "system", subtype: "init", tools: [{ name: "tool1" }] }, + { type: "system", subtype: "thinking_tokens" }, + { type: "system", subtype: "thinking_tokens" }, + { type: "system", subtype: "other_subtype" }, + ]; + + const grouped = groupTurnsNaturally(data); + + // Should have init and other_subtype, but not thinking_tokens + expect(grouped).toHaveLength(2); + expect(grouped[0]?.type).toBe("system_init"); + expect(grouped[1]?.type).toBe("system_other"); + expect(grouped[1]?.data?.subtype).toBe("other_subtype"); + }); + + test("thinking_tokens does not appear in formatted output", () => { + const data: Turn[] = [ + { type: "system", subtype: "init", tools: [] }, + { type: "system", subtype: "thinking_tokens" }, + { type: "system", subtype: "thinking_tokens" }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).not.toContain("thinking_tokens"); + expect(result).toContain("## 🚀 System Initialization"); + }); }); diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index e69a399..2cb7e30 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -59,6 +59,22 @@ describe("stripMarkdownImageAltText", () => { it("should handle empty alt text", () => { expect(stripMarkdownImageAltText("![](image.png)")).toBe("![](image.png)"); }); + + it("should remove alt text from reference-style images", () => { + expect(stripMarkdownImageAltText("![example alt text][img1]")).toBe( + "![][img1]", + ); + expect( + stripMarkdownImageAltText("Text ![description][ref] more text"), + ).toBe("Text ![][ref] more text"); + }); + + it("should preserve the reference label of a reference-style image", () => { + // the [ref] label must survive so the image definition still resolves; + // only the alt text (the injection channel) is removed + expect(stripMarkdownImageAltText("![alt][my-ref]")).toBe("![][my-ref]"); + expect(stripMarkdownImageAltText("![][keep]")).toBe("![][keep]"); + }); }); describe("stripMarkdownLinkTitles", () => { @@ -276,6 +292,16 @@ describe("redactGitHubTokens", () => { ); }); + it("should redact user-to-server tokens (ghu_)", () => { + const token = "ghu_16C7e42F292c6912E7710c838347Ae178B4a"; + expect(redactGitHubTokens(`User token: ${token}`)).toBe( + "User token: [REDACTED_GITHUB_TOKEN]", + ); + expect( + redactGitHubTokens(`In a URL: x-access-token:${token}@github.com`), + ).toBe("In a URL: x-access-token:[REDACTED_GITHUB_TOKEN]@github.com"); + }); + it("should redact installation tokens (ghs_)", () => { const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"; expect(redactGitHubTokens(`Install token: ${token}`)).toBe( diff --git a/test/validate-branch-name.test.ts b/test/validate-branch-name.test.ts index fe03ce4..6ee26a0 100644 --- a/test/validate-branch-name.test.ts +++ b/test/validate-branch-name.test.ts @@ -74,6 +74,16 @@ describe("validateBranchName", () => { expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow(); expect(() => validateBranchName("agent/task@abc123")).not.toThrow(); }); + + it("should accept branch names starting with underscore (git-valid, common for release branches)", () => { + // Leading underscores are valid per git check-ref-format and a common + // convention for release/internal branches. Rejecting them broke the + // action on any open PR whose base branch was e.g. "_release/v1.2.3", + // since setupBranch validates the PR's baseRefName after checkout. + expect(() => validateBranchName("_release/v1.2.3")).not.toThrow(); + expect(() => validateBranchName("_internal")).not.toThrow(); + expect(() => validateBranchName("_wip/feature-x")).not.toThrow(); + }); }); describe("command injection attempts", () => {