Files
claude-code-action/base-action/src/index.ts
T
KeisukeYamashitaandGitHub b00a3414fd fix: share one exchanged WIF credential across spawned Claude processes (#1407)
* fix: share one exchanged WIF credential across spawned Claude processes

GitHub OIDC tokens are single-use at the Anthropic token-exchange
endpoint (the same jti cannot be exchanged twice). With plugins
configured, the action spawns several short-lived claude processes
(plugin marketplace add, one plugin install per plugin, then the main
query). Each resolved federation from bare env vars and exchanged the
same identity-token file independently: the first exchange succeeded
and every later process got 401 (jti_reused), which the main query
retried for ~3 minutes before failing the job.

The SDK only enables its on-disk credentials cache when federation is
loaded from a profile config file, not from bare env vars. Write a
profile pointing at the identity-token file and select it via
ANTHROPIC_CONFIG_DIR / ANTHROPIC_PROFILE so the first process exchanges
once and the rest reuse the cached access token. The env vars are kept
as a fallback for CLIs that predate profile support.

* fix: scope the WIF credential cache per federation config

Address review feedback on the shared-credentials-cache fix:

- Embed a fingerprint of the federation inputs (rule, org, service
  account, workspace, base URL, scope) in the config dir name. The SDK
  cache reuses a token on expires_at alone and RUNNER_TEMP is per-job,
  so a later step with different federation inputs would silently reuse
  the first step's token. service_account_id and scope are included
  beyond the reviewed list because both are sent in the exchange
  request body and change which credential is minted.
- Skip the action-managed profile with a warning when the operator has
  already set ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE.
- Shrink the profile to the minimal file-backed form; the CLI's bundled
  SDK gap-fills the federation fields from the env vars the action
  already exports (verified against the pinned 2.1.173 binary).
- Remove the token dir in stop() so the identity token and the cached
  exchanged credential don't outlive the step.
- Document that cache sharing relies on the plugin subprocesses
  spawning sequentially.
2026-07-22 07:02:41 -07:00

87 lines
3.1 KiB
TypeScript

#!/usr/bin/env bun
import * as core from "@actions/core";
import { preparePrompt } from "./prepare-prompt";
import { runClaude } from "./run-claude";
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { validateEnvironmentVariables } from "./validate-env";
import { installPlugins } from "./install-plugins";
import { setExecutionFileOutputIfPresent } from "./execution-file";
import { setupWorkloadIdentity } from "./workload-identity";
import type { WorkloadIdentityHandle } from "./workload-identity";
async function run() {
let workloadIdentity: WorkloadIdentityHandle | undefined;
try {
// When workload identity federation is configured, fetch the GitHub OIDC
// identity token and expose it to the CLI before validating auth env vars.
workloadIdentity = await setupWorkloadIdentity();
validateEnvironmentVariables();
// The composite action's "Install Claude Code" step writes the binary to
// ~/.local/bin/claude. Pass that path explicitly so the Agent SDK doesn't
// fall back to its bundled platform package, which bun may resolve to the
// wrong libc variant on Linux.
const claudeExecutable =
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE ||
`${process.env.HOME}/.local/bin/claude`;
await setupClaudeCodeSettings(
process.env.INPUT_SETTINGS,
undefined, // homeDir
);
// Install Claude Code plugins if specified
await installPlugins(
process.env.INPUT_PLUGIN_MARKETPLACES,
process.env.INPUT_PLUGINS,
claudeExecutable,
);
const promptConfig = await preparePrompt({
prompt: process.env.INPUT_PROMPT || "",
promptFile: process.env.INPUT_PROMPT_FILE || "",
});
const result = await runClaude(promptConfig.path, {
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS,
mcpConfig: process.env.INPUT_MCP_CONFIG,
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL,
pathToClaudeCodeExecutable: claudeExecutable,
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
});
// Set outputs for the standalone base-action
core.setOutput("conclusion", result.conclusion);
if (result.executionFile) {
core.setOutput("execution_file", result.executionFile);
}
if (result.sessionId) {
core.setOutput("session_id", result.sessionId);
}
if (result.structuredOutput) {
core.setOutput("structured_output", result.structuredOutput);
}
} catch (error) {
setExecutionFileOutputIfPresent();
core.setFailed(`Action failed with error: ${error}`);
core.setOutput("conclusion", "failure");
process.exit(1);
} finally {
// 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();
}
}
if (import.meta.main) {
run();
}