* 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.
Treat subtype success with is_error:true as a failed run so CI does not
show a misleading green check when the review never actually ran.
Fixes#1495
Co-authored-by: syf2211 <syf2211@users.noreply.github.com>
* fix(parse-sdk-options): prevent shell-quote from collapsing unquoted Bash(X:*) rules to bare Bash
shell-quote's parse() tokenizes unquoted `(`, `)` as control operators
and barewords containing `*` as glob ops, all returned as non-string
objects. parseClaudeArgsToExtraArgs filtered those out, so an unquoted
`--allowedTools View,Bash(gh:*),Bash(cat:*)` collapsed to bare `Bash` —
silently widening scoped permission rules to unrestricted Bash(*).
Escape shell control metachars to Unicode private-use placeholders
before parse() and restore after; extract .pattern from glob ops.
Preserves existing quote/whitespace handling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ci: retrigger
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
In some workflow contexts — reliably reproducible for us on
pull_request-triggered runs of this action — the Claude Agent SDK
query() async iterator does not close after the terminal result
message is emitted. The for-await loop in runClaudeWithSdk blocks
indefinitely after Claude has finished its work, until the workflow's
timeout-minutes cap kills the job.
Symptoms observed in production (4× in our scan-reviewer workflow):
- Claude completes successfully: SDK emits { type: "result",
subtype: "success", ... } with the cost / turns / duration set.
- The action then sits with zero log output for the rest of
timeout-minutes (we measured 18-19 min of dead time after result).
- The job is cancelled at timeout. writeExecutionFile is never
called → no claude-execution-output.json → cost-tracker and other
post-steps see nothing.
- Run shows as cancelled, even though Claude did its work and any
verdict it posted via gh tools already landed.
Author-mode (workflow_dispatch) runs from the same codebase
terminate cleanly the same day, so the hang is specific to certain
event triggers.
By SDK contract the result message is terminal — no further messages
follow. Break out of the loop immediately after capturing it,
regardless of whether the upstream iterator ever closes. If the SDK
is later fixed to close cleanly in all contexts, this break becomes
a no-op.