fix: share one exchanged WIF credential across spawned Claude processes#1407
fix: share one exchanged WIF credential across spawned Claude processes#1407KeisukeYamashita wants to merge 2 commits into
Conversation
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.
ashwin-ant
left a comment
There was a problem hiding this comment.
Nice find, and the diagnosis holds up. buildProvider in @anthropic-ai/sdk/lib/credentials/credential-chain.mjs only wraps the exchange in cachedExchangeProvider when a credentials_path is set, and that only happens for file-backed configs. So env vars can't share a token across processes and a profile can. Your profile also matches AnthropicConfig exactly.
Two changes before merge.
The cache path needs a config discriminator. configDir is a constant under RUNNER_TEMP, which is per-job, but the federation rule / org / workspace inputs are per-step. cachedExchangeProvider reuses a cached token based on expires_at alone, with no fingerprint of the config that minted it, and the workspace is bound at mint time. So two steps in one job with different workspaces will silently reuse the first step's token: wrong scope, wrong billing, no error. Today's uncached path exchanges fresh each step and gets this right, so it's a regression. The SDK comment right above the cache says this is exactly why env-only configs skip it. Hashing federation_rule_id + organization_id + workspace_id + base_url into the config dir name fixes it.
Don't clobber an operator-set ANTHROPIC_PROFILE / ANTHROPIC_CONFIG_DIR. You flag this under limitations, but it's free to fix rather than a tradeoff: if ANTHROPIC_PROFILE is already set, the disk cache is already on, so just core.warning() and skip writeFederationProfile. Same shape as the ANTHROPIC_API_KEY guard above it. Worth a test.
Smaller stuff:
workspace_id,base_url, andservice_account_idlook redundant.loadConfiggap-fills them from env for file-backed profiles, and the resolved base URL prefers the env var anyway.{ version, authentication: { type: "oidc_federation" } }should do it.stop()couldrmSyncthe token dir, so the exchanged token doesn't outlive the step.- Worth a docstring line that this depends on the plugin subprocesses spawning sequentially.
cachedExchangeProviderisn't cross-process serialized, and its "exchanges are idempotent" assumption is what single-usejtibreaks. Parallelize that loop later and the bug comes back.
One question. Single-use jti is server side and I can't confirm it from the client. Your mock assumes it and the Console events point that way, but let me check with the token exchange owners before we call this a correctness fix. If reuse is allowed it's still worth landing, just as a latency fix, and the description should say so.
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.
|
@ashwin-ant Hi, thank you for your kind review. All four changes are in: 34d3228 Config discriminator. The config dir is now Operator-set Minimal profile. It is now exactly: {
"version": "1.0",
"authentication": {
"type": "oidc_federation"
}
}Before shrinking it, I verified that the gap-fill exists in the CLI version pinned by this action (
Docstring. I added the sequential-spawn dependency note to On the
|
Fixes #1406
Problem
GitHub OIDC tokens are single-use at the Anthropic token-exchange endpoint (the same
jticannot be exchanged twice). When theplugins/plugin_marketplacesinputs are configured, the action spawns several short-livedclaudeprocesses per job (claude plugin marketplace add, oneclaude plugin installper plugin, then the main query). Each process resolved workload identity federation from the 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. Details, eBPF process traces, and request-ids in #1406.Fix
The SDK only enables its on-disk credentials cache (
<config_dir>/credentials/<profile>.json, shared across processes) when federation is loaded from a profile config file, not from bare env vars.setupWorkloadIdentity()now additionally writes a profile pointing at the identity-token file and selects it viaANTHROPIC_CONFIG_DIR+ANTHROPIC_PROFILE, so the first process exchanges once and every other process reuses the cached access token.RUNNER_TEMP(0700 dir / 0600 file), next to the identity token.ANTHROPIC_IDENTITY_TOKEN_FILEand the federation env vars are kept as a fallback for CLIs that predate profile support — older CLIs degrade to today's behavior, never worse.jti) token from the same file path recorded in the profile.Verification
bun test: 702/702 pass (includes 2 new unit tests for the profile file),bun run typecheck,bun run format:checkall green.jti: two sequentialclaudeprocesses sharing one identity token — env-var path: second process retriesjti_reusedfor ~3 minutes and dies (matches production failures exactly); profile path: second process reuses the cached credential with zero additional exchanges and succeeds. Verified with both Claude Code 2.1.167 and 2.1.173.is_error: false, main query completes in ~6s. An eBPF trace shows the same fiveclaudeprocesses contactingapi.anthropic.com, now with a single token exchange.Notes / limitations
ANTHROPIC_CONFIG_DIR/ANTHROPIC_PROFILE, they are overridden while federation inputs are configured (federation inputs are an explicit opt-in, and previously the env-var path was similarly authoritative).