-
Notifications
You must be signed in to change notification settings - Fork 1
chore: Cherry-picked changes from upstream #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"]; | ||
|
Comment on lines
+204
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This inverts CLI precedence: an explicit
Scenario: a workflow sets Suggest |
||
|
|
||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new diagnostic is unreachable when Before this change Moving the |
||
| 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" | ||
| }`, | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,14 +51,72 @@ 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. | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fingerprint omits Two invocations in one job with identical rule/org/service-account/workspace/scope but different audiences land in the same
Suggested change
…plus |
||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||
| * the file stays valid for long executions. | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * 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); | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An in-flight refresh can recreate the directory right after
Suggest a let stopped = false;
const writeIdentityToken = async () => {
const identityToken = await fetchIdentityToken(audience);
if (stopped) return; // cleanup already ran; don't resurrect the token file
core.setSecret(identityToken);
mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
writeFileSync(tokenFile, identityToken, { mode: 0o600 });
};…and set |
||||||||||||||||||||||||||||
| // 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 }); | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If it throws, the rest of the
Suggested change
|
||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment now documents an invariant the code doesn't hold:
process.exit(1)at line 126 doesn't unwind the stack, so on every failure path of the standalone base-action thisfinallynever runs and the token dir is left behind.That was tolerable before (the leftover was a single-use OIDC JWT that fails with
jti_reused), but this PR makes the same directory hold the SDK's cached exchanged Anthropic credential — a reusable bearer token valid untilexpires_at.RUNNER_TEMPis per-job, so any later step in the same job can read it.core.setFailedalready forces a non-zero exit, so dropping the explicitprocess.exit(1)is the smallest fix. (Analways()cleanup step inaction.yml— the convention this repo already uses for token revocation and SSH signing — would also cover SIGTERM/cancellation, which nofinallycatches in either entrypoint.)