92. OpenAI WIF credential delivery — runner-side exchange, run-scoped provider
Date: 2026-08-27
Status
Accepted
Context
The pi runtime (#6464) serves Claude, Grok and Gemini on Vertex, but no OpenAI model (#6532). OpenAI now offers Workload Identity Federation: a GitHub Actions job exchanges its OIDC JWT at POST https://auth.openai.com/oauth/token for an opaque Bearer access token that lives at most one hour — and no longer than the GitHub assertion it was exchanged from, which is minutes.
The token is a plain header credential, which means fullsend can run GPT models with no OpenAI credential inside the sandbox at all — ADR 0025 credential-delivery tier 2, one tier above today's Vertex path (which puts a real GitHub OIDC token and an external_account file on the sandbox filesystem).
Alternatives considered
inference.local— at OpenShell 0.0.115 (the pinned version) this is a cluster-global route: one provider and one model per gateway, applied to every sandbox. fullsend runs on shared gateways (the GitLab runner VM's persistent gateway, local dev, parallel eval cases), so concurrent runs with different models or service accounts would race. Rejected.Dynamic
token_grant— the supervisor mints a SPIFFE JWT-SVID as an RFC 7523 client assertion; OpenAI's exchange needs a GitHub-issued JWT plusidentity_provider_id/service_account_idin a JSON body. Rejected.In-sandbox exchange —
ACTIONS_ID_TOKEN_REQUEST_URL/_TOKENare runner-only by design (oidcDenyKeys,internal/cli/run.go, #5832 / ADR 0073), and pi's built-inopenaiprovider has no WIF concept. Rejected.
Decision
Runner-side exchange with a run-scoped OpenShell provider:
- The runner exchanges the GitHub OIDC JWT for an OpenAI access token before sandbox creation (
internal/inference/openaiwif/). - A run-scoped provider (
openai-<suffix>) carries the token as a bare-key credential, never expanded throughos.ExpandEnv(the opaque token may contain$). The provider has--credential-expires-atset (the token's own expiry, or a bounded lifetime for a staticOPENAI_API_KEYfrom the runner environment) and is deleted in deferred cleanup regardless of--keep-sandbox; a provider whose expiry cannot be set is deleted immediately. - The
fullsend-openaiOpenShell profile scopes egress toPOST /v1/responsesonapi.openai.comfor**/nodebinaries. - The pi runtime gate writes the placeholder the sandbox environment carries for
OPENAI_API_KEYinto pi'sauth.json(anopenaiapi_keyentry) before the agent-writable.envis sourced, and pi is started without--api-key. pi'sAuthStoragere-reads that file whenever its revision changes and resolves the key per request (packages/coding-agent/src/core/auth-storage.ts,model-registry.tsat 0.84.3), so after each refresh the runner re-runs the same seed throughsandbox exec— an exec'd shell's environment holds the new placeholder, the runner never learns the opaque revision — and the running iteration's next request carries the new credential. This is the only hand-off that works on OpenShell 0.0.115, where a revision-scoped placeholder stays pinned to the value of the generation it was issued for and the unrevisioned alias is refused for an endpoint-bound credential (see Consequences). A config-dir integrity guard (exit 98 whenmodels.jsonexists, orauth.jsonis anything but pi's own{}or exactly the seeded placeholder entry) closes the redirect vector even with hooks disabled; it runs before.envis sourced and again after it behindunset -f test command grep tr, with the runner-owned config paths re-exported in between, so neither a sourced shell function nor a rewrittenPI_CODING_AGENT_DIRcan defeat it. - Before the agent starts, the run reads the sandbox's effective policy and refuses to continue when any rule admits
api.openai.com:443without L7 inspection (noprotocol, ortls: skip): since OpenShell 0.0.110 the proxy will not inject a provider credential over such a route, and the base sandbox image's default policy ships exactly that shape in itscodexrule. The fleet's harnesses carrypolicy: policies/base.yaml, which has no network rules of its own, so the provider's inspected entry is the only route; a custom harness must do the same. - The token value is redacted by exact match in run output and provider errors;
::add-mask::is emitted on GitHub Actions;FULLSEND_OPENAI_*are inoidcDenyKeys.
Accepted residual
Static placeholders are endpoint-bound at OpenShell 0.0.115: the proxy substitutes the placeholder only on a request to the host, port and path of the profile endpoint that issued it (providers-v2.mdx "Static Credential Endpoint Binding"), so the cross-provider risk ADR 0025 documents — a compromised agent having the gateway resolve the placeholder on another allowed endpoint — no longer applies to this credential. What remains:
auth.jsonis agent-writable during an iteration (pi's config directory is not a permission boundary), and pi re-reads it per request. An agent can therefore change the placeholder pi sends — to nothing that resolves anywhere else — or plant a real key of its own; both are self-sabotage, not a leak of the run's credential. The integrity guard runs at iteration start, before and after.env, not continuously.- An iteration starting in the same seconds as a refresh seeds
auth.jsonfrom its own exec environment; the refresher verifies the file after its re-seed and seeds once more if the iteration's write landed last (one poll interval of exposure at most). - The token lifetime is bounded by the GitHub assertion (minutes) and by
expires_in(at most 1 h); the provider is run-scoped, carries that expiry and is deleted at run end; the mapping grantsapi.model.requestonly.
Consequences
- GPT models on pi are usable via
openai/<model-id>with no OpenAI credential inside the sandbox. - The runner refreshes the credential for the life of the run: a WIF token is re-exchanged from a fresh GitHub assertion before
expires_in(at a margin capped to half the token's lifetime, so a minutes-long token refreshes every couple of minutes) and updated into the run-scoped provider in one call with its new expiry, then waits (up to 90 s; ~20 s measured) for the sandbox to hand new processes the new placeholder and re-seeds pi'sauth.jsonwith it (a settle timeout fails the attempt rather than re-seeding the old placeholder); a static key's provider expiry is pushed out on the same schedule and re-seeded the same way, because that update is a new generation as well. When the bounded retries fail, the running pi keeps the generation it holds, which stops resolving when that token expires — the run fails visibly rather than silently outliving its credential. - Verified against OpenShell 0.0.115 on a live gateway (2026-08-27), re-verify on every bump. (a) The placeholder in the sandbox environment is a placeholder in the
openshell:resolve:envnamespace with an opaque revision (v<opaque>_OPENAI_API_KEY) and resolves; (b) the canonical, unrevisioned form (OPENAI_API_KEYin that namespace) returns500 credential_unavailablefor this endpoint-bound credential (crates/openshell-core/src/secrets.rsresolve_placeholder); (c) afterprovider update, a placeholder issued for the previous generation keeps resolving to the previous value (OpenShell retains up to eight generations,MAX_RETAINED_CREDENTIAL_GENERATIONS; only once a generation ages out does its placeholder fall back to the current credential — eleven quick updates were observed to leave the old value resolvable, the supervisor's ~10 s poll coalescing them into fewer generations), while the sandbox hands new processes a new placeholder within ~20 s (one poll); (d) expiring the credential in place makes every generation fail closed, and an expiry-only update is a new generation too, whose predecessor keeps the expiry it was built with. (c) and (d) are why the file hand-off exists; it also contradicts the 0.0.115providers-v2documentation ("the proxy resolves existing placeholders against current credentials … so rotation … take effect without restarting the process"), which describes gateway-managed refresh handles, notstatic/externalcredentials.TestPiOpenAIAuthSeed,TestReseedOpenAIAuth_*andTestRefreshOpenAIProvider_ReseedsOnlyOnce…pin the runner side; pi's side —AuthStoragere-readingauth.jsonwhen its file revision changes andprepareRequestresolving auth per request — is verified in the pi 0.84.3 source (packages/coding-agent/src/core/auth-storage.ts,packages/ai/src/auth/resolve.ts,model-runtime.ts) and exercised across iteration starts locally, but a rotation inside one running iteration has not been observed live yet: the first WIF run is that check. If it fails, the failure is visible (401after the old token's expiry, with the refresher's log lines to go on) and the fallback is to shorten iterations below the token lifetime. - OpenShell 0.0.110+ resets any model request whose body contains the contiguous placeholder prefix (
openshell:resolve:envfollowed by a colon) — it is treated as credential-bearing traffic that cannot be rewritten. An agent therefore cannot read a file, diff or PR body that spells the prefix out; this repository builds it from two parts in source and tests and never writes it contiguously in docs. Verified in the v0.0.115 source: the guard is a plain substring test on the prefix (crates/openshell-core/src/secrets.rs:44-55contains_raw_reserved_marker, streamed bycrates/openshell-supervisor-network/src/l7/rest.rs:1169-1182), it never parses what follows, and it applies to every inspected REST endpoint as soon as the sandbox holds any static provider credential (l7/mod.rs:317-318,l7/relay.rs:842) — so a credential-less endpoint such as Vertex is guarded too oncegithub-rois attached. Intended fail-closed design (OpenShell PR #2162: a live placeholder must never leave the proxy unresolved); the bare-prefix false positive is the open upstream issue NVIDIA/OpenShell#2904.request_body_credential_rewrite: trueis not an opt-out (an unresolvable marker still fails,rest.rs:1698); the only one isallow_uninspected_credentials: trueon the model endpoint, which forwards such a body raw while the bearer header is still injected and the method/path rules still apply (verified: a body carrying the prefix, and even the real placeholder string, gets 200;GET /v1/modelsstays 403). A literal placeholder — a reserved token, not a secret — would then reach the model API. Adopted for the two default model profiles fullsend ships (profiles/fullsend-openai.yamlandprofiles/fullsend-vertex-ai.yaml); the fleet's own agents resolve their Vertex profile from fullsend-ai/agents (harness/*.yaml→profiles/fullsend-vertex-ai.yaml), which needs the same flag to protect fullsend's review/code agents, and the upstream fix is NVIDIA/OpenShell#2904. - Static credentials are endpoint-bound at 0.0.115: the proxy substitutes a placeholder only at the host, port and path of the profile endpoint that issued it (
providers-v2.mdx"Static Credential Endpoint Binding"). The residual this ADR inherited from ADR 0025 — a compromised agent resolving the placeholder against another allowed host — is closed for this credential. - A harness without
policy:fails the egress preflight on the base image (its default policy allowsapi.openai.com:443as a raw tunnel for/usr/bin/node); the fleet'spolicies/base.yamlis the documented answer for custom and local harnesses. - The three identifiers live in the committed
inference.openaiblock of.fullsend/config.yaml, written byfullsend github setup --openai-*like the Vertex project and provider; they are not secrets (a token is issued only to a caller whose GitHub OIDC claims match the mapping, and pull-request events read the config from the base branch, ADR 0033). Withinconfig.yamlthe three resolve through the layers field by field, so a base preset can carry the organization's audience and identity provider and each repository its service account. The reusable workflows also pass theFULLSEND_OPENAI_*repository variables into the runner environment for installations that prefer not to commit them; when any is set they replace the resolved block, never merge with it. On a machine without a GitHub OIDC endpoint a setOPENAI_API_KEYwins over the block, so a developer's local run of a repository that committed it still works. - Live end-to-end verification is gated on external access and run by a maintainer after merge.
Related
- ADR 0025 — provider-based credential delivery (tier definitions)
- ADR 0073 —
oidcDenyKeys(#5832) - #6532 — GPT / Azure OpenAI / Bedrock providers for pi
- #6464 — pi runtime tracker
- #1952 — Anthropic WIF sibling design
- ADR 0095 — mapping scope: repository-only by default (#6782)
