AileronControlPlane

ADR-0025: Vault-backed Agent Authentication Injection

StatusAccepted
Date2026-06-10
Tracking#969, #747, #983

Context

Sandbox launches today land in a fresh /home/agent/.claude/ and /home/agent/.codex/ every time. Each agent’s first-launch wizard fires on every invocation. Subscription auth (the dominant auth mode for both Claude Code and Codex CLI) has no path through the launcher.

The vault SPI at internal/vault/spi.go already enumerates oauth_refresh_token and api_key as Metadata.Type values. Nothing in internal/launch/ consults it. The user-facing shape: the vault is durable, the container is ephemeral, and a writable bind-mount is the conduit between them.

The decision below pins the implementation contract: a new AuthSpec descriptor on the Agent interface, a daemon-brokered vault credentials API, per-agent specs for Claude and Codex, and a single seeding path that runs entirely inside the sandbox container.

Decision

The Agent interface gains AuthSpec() AuthSpec, returning a static declarative description of the agent’s vault-backed credential bindings:

AuthSpec {
  EnvBindings:  [ { VaultPath, Required, Render(Secret) -> map[string]string } ]
  FileBindings: [ { VaultPath, ContainerPath, Mode, Required, MountAsFile,
                    Render(Secret) -> []byte, Capture([]byte) -> Secret,
                    PreLaunchRefresh?(Secret, RefreshDeps) -> Secret } ]
  StaticFiles:  [ { ContainerPath, Mode, Content []byte } ]
}

The launcher consumes the spec around the sandbox lifecycle in four phases:

  1. Render before container start. For each binding, GET the vault entry at the binding’s VaultPath through a new daemon HTTP endpoint (/v1/vault/agents/{name}/credentials). Run the binding’s Render function. EnvBindings produce env vars that merge into the agent process’s environment. FileBindings produce bytes written into a host-side transient directory chmod 0700, then bind-mounted into the container at the binding’s parent directory (the default) or as an individual file at ContainerPath (when MountAsFile = true). StaticFiles land in the same transient directory regardless of vault state.

  2. Optional pre-launch refresh. When a FileBinding declares PreLaunchRefresh, the hook runs after the GET and before Render. The hook exchanges the refresh token for a new access token against the vendor’s auth server, persists the rotated bundle through the daemon’s PUT /v1/vault/agents/{name}/credentials BEFORE returning successfully, and hands Render the new Secret. A failed persist aborts the launch. Codex uses this hook to refresh against auth.openai.com; Claude self-refreshes inside the container.

  3. Run. The launcher invokes sandboxcontainer.Builder.Run with the merged env and mounts. The in-container agent finds its credentials at the documented paths and starts silently.

  4. Capture on clean exit. When Builder.Run returns nil, the launcher reads each FileBinding’s host-side file, runs the binding’s Capture function (which validates the envelope schema and rejects malformed bytes), and PUTs the result back through the daemon.

  5. Graceful-shutdown salvage on SIGINT/SIGTERM. The one-shot container runs in the foreground, so a Ctrl-C or a SIGTERM reaches the container process group before the clean-exit Capture gate runs. The launcher names the container aileron-sbx-<sessionID> so it is addressable, installs a signal handler around the run, and on the first SIGINT/SIGTERM best-effort stops the named container with a bounded 10-second grace window. After the stop returns it runs the same Capture the clean-exit gate uses, guarded by a sync.Once so a signal that races a clean exit writes the rotation back exactly once. A failed stop is non-fatal and Capture still runs. SIGKILL stays uncatchable and runtime crashes still skip Capture, so the prior vault entry is retained and the next launch self-heals.

    For an interactive run -t/exec -t on a real terminal, aileron owns the PTY rather than handing the runtime child the terminal’s foreground process group (issue #1029, superseding the #1028 Foreground+Ctty handoff). Aileron allocates a pseudo-terminal, puts the host terminal in raw mode (restoring it on exit), and gives the docker child a PTY slave it makes its own controlling terminal (Setsid+Setctty) so the in-container agent’s raw-mode tcsetattr targets that slave rather than the host terminal. Aileron stays in the host terminal’s foreground process group the whole time, so the terminal Ctrl-C reaches aileron and the salvage handler above, not the in-container agent, and aileron is the foreground process available to host the #802 approval TUI. The child keeps its own-process-group isolation; only the foreground handoff is dropped. On Windows and on a non-terminal stdin (CI), there is no PTY to own and the runner falls through to the plain stdio path.

Freshness gating and the malformed-current carve-out. A FileBinding may declare an optional Fresher hook. When the vault entry was present at both Render and Capture, the launcher gates the Capture-side PUT on Fresher: the write happens only when the freshly captured envelope is strictly newer than the stored one, so a stale concurrent capture cannot clobber a fresher rotation. A nil Fresher preserves last-writer-wins. Two failure modes are handled asymmetrically. If the captured envelope does not parse, the Fresher returns a plain error and the launcher skips the PUT, because writing unvalidated bytes is strictly worse than retaining the prior entry. If instead the captured envelope parses but the current vault entry does not, the Fresher wraps ErrCurrentEnvelopeMalformed and the launcher overwrites the corrupt entry with the valid capture. A corrupt current entry is already unusable, since Render rejects it on the next launch, so retaining it would strand the user behind a re-login while discarding a valid credential they just produced. The launcher emits a one-line stderr warning in both cases so the operator sees which path fired.

The daemon’s new endpoints are namespace-scoped at the routing layer: {name} translates internally to agents/<name>/oauth, so other vault paths are unreachable through this surface. The endpoint returns named error codes in the response body so the launcher discriminates vault_not_found (drives the in-container-login bootstrap path) from vault_locked (drives the unlock prompt) by code rather than HTTP status alone.

Seeding was exclusively in-container in v1. First launch with an empty vault prints [launcher] no credentials in vault for <agent>; agent will prompt for login to stderr and starts the container with the writable bind-mount empty. The in-container agent performs its normal interactive login (paste-the-code OAuth fallback for Claude, device-auth for Codex). Capture on clean exit seeds the vault. Every subsequent launch renders silently. This in-container seeding path remains the fallback for every agent and the only path for agents that declare no host-side acquirer.

Host-side acquirer phase (amendment, umbrella #1267). The “host-side credential import is deferred” stance above is now narrowed for the cold-launch case. A FileBinding may declare an optional HostAcquire hook. The launcher invokes it when three conditions hold together: the vault GET for the binding misses, the binding is not Required, and host-login is enabled (the default, overridable per launch via --host-login or the AILERON_LAUNCH_HOST_LOGIN env var). The hook runs the credential acquisition on the host, returns a vault.Secret, and the launcher PUTs that Secret to agents/<agent>/oauth through the daemon and renders it into the bind-mount before the container starts. First launch is then silent rather than dropping into the in-container login wizard. A returned empty Secret, a cancelled acquire, or any acquirer error is non-fatal. The launcher logs a warning and falls back to the in-container login path that the paragraph above still describes. Only a daemon PUT failure or a Render failure of a successfully acquired Secret aborts the launch.

Vault-not-env invariant holds. The acquirer returns a vault.Secret that the launcher seeds into the vault and renders to a file, exactly as a vault-resident credential is rendered. No acquired token is ever placed in the agent’s environment. HostAcquireDeps carries no PutAgentCredentials helper, so the launcher owns both the PUT and the validating Render. An acquirer cannot skip seeding and cannot seed an envelope its own binding’s Render would reject. This preserves ADR-0025’s standing rule that the daemon is the single writer of vault credentials.

Per-provider mechanism asymmetry. The two shipped acquirers use different host-side flows. Claude uses an authorization-code grant against its hosted callback at https://platform.claude.com/oauth/code/callback, with the public client id 9d1c250a-... and PKCE. The user completes consent in the host browser, copies the code the hosted callback displays, and pastes it back on the host terminal. A loopback-listener redirect is rejected by the provider with 400 invalid_grant, which is why the paste flow exists. Claude also tries an opportunistic claude setup-token shortcut first when the claude binary is on the host PATH, falling back to the hosted-callback paste when it is absent. Codex uses the OpenAI device-authorization grant against auth.openai.com. The device flow needs no loopback callback and no paste step, so Codex’s acquirer is pure Go end to end, showing a verification URL plus a one-time user code and polling to completion. Both providers ship a live host-side acquirer as of umbrella #1267.

Two distinct host-seed paths for two distinct populations. The daemon-driven cold-launch acquirer described here handles the never-logged-in or not-installed user, acquiring a fresh credential on the host at first launch. It is a different mechanism from aileron auth <agent> --import-from-host (described under Alternatives Considered below), which reads an already-authenticated host CLI install into the vault. Import-from-host serves the user who already logged the host CLI in. The cold-launch acquirer serves the user who never has. They are complementary and neither supersedes the other.

Descriptor-driven acquisition (amendment, umbrella #1290). This amendment generalizes the standalone user-level acquisition path, aileron auth github, so a tool’s login flow ships as data rather than as compiled-in Go. The unit of that data is a capture descriptor, which is distinct from the AuthSpec FileBinding “descriptor” used elsewhere in this ADR. The AuthSpec FileBinding descriptor governs the binding side: rendering a stored vault credential into the running sandbox and capturing in-container rotations back. A capture descriptor governs the acquisition side: how a one-time login is run in a container to mint a credential the vault did not yet hold. The two senses are kept separate throughout this file.

aileron auth github resolves the gh capture descriptor by name through a registry, then drives the generic capture.Driver from the descriptor’s fields. The descriptor supplies the login flow, the container image, the vault path, and the credential kind. No provider knowledge is compiled into core. Adding a second tool is a new shipped YAML descriptor under the capture defaults, never new Go.

The gh capture descriptor was originally a trusted YAML default loaded by the auth domain’s own embed and loader in internal/auth/capture (embed.go, loader.go, and the by-name Registry in registry.go), alongside a matching proxybinding github.yaml binding default. As of umbrella #1319 (cutover sub-issue #1323) both central files were removed: gh’s complete credential story (acquisition + sealing) now ships in one CLI-capability unit carried by gh’s devcontainer Feature (images/sandbox-features/gh/devcontainer-feature.json, under customizations.aileron.cli). The host reads that unit from the resolved sandbox image’s devcontainer.metadata OCI label (internal/cli/unitloader) and projects it into the same two layers as before: a capture-descriptor layer applied between the auth domain’s built-in defaults and the user layer, and a proxybinding-entry layer applied the same way. The auth domain still owns acquisition and proxybinding still owns binding; the two loaders remain independent, and a user descriptor can still override either by name. The projection is byte-identical to the former central defaults (pinned by internal/app’s TestGHUnitDriftGuard). Because aileron auth github runs gh inside the base image, the dispatch and driver inspect that image for gh’s capture descriptor; an image whose label cannot be read contributes no unit and gh is not dispatched.

The GitHub one-command UX is unchanged. aileron auth github still runs gh auth login and gh auth token in one container, prints the device URL, stores the token at user/github, and returns the same exit codes. The in-container interactive login remains the documented acquisition path the descriptor drives; the descriptor expresses that flow as data rather than replacing it.

This acquisition-side generalization mirrors the binding and egress-side generalization that routed GitHub through the uniform proxybinding descriptor path (issues #1244 and #1245).

Sandbox-only in v1. Host launch (aileron launch <agent> with no sandbox flag) is deliberately not vault-backed. Host launch already resolves a working install’s credentials from the host user’s own ~/.claude/ and ~/.codex/. prepareAuthSpec runs only on the sandbox path and is intentionally not wired into host launch. Intervening on the host path risks breaking a working install, so the host path stays untouched in v1. Vault-backed host auth would render vault entries into host paths and capture rotations back to the vault. That work warrants a separate PR with explicit user testing, and is deferred. The host-parity question was evaluated under issue #983: three options were weighed, extending AuthSpec to host launch, a hybrid, and documenting the sandbox-only stance. Documenting the sandbox-only stance was chosen for v1. Host-launch parity for AuthSpec stays architecturally clean to extend later, and is not part of this decision.

Consequences

Positive

  • Subscription auth survives across launches. The user logs in once per agent, inside the sandbox. Every later launch renders silently.
  • Rotation persistence is free. Claude’s mid-session OAuth rotation lands in the bind-mounted file; Capture snapshots it back so the next launch picks up the new access token.
  • The launcher never opens the vault file. The daemon is the single trust boundary for unlocked credentials, matching ADR-0011 and ADR-0012.
  • The contract is independent of seeding. Render and Capture never know whether bytes arrived via in-container login, via aileron vault put, or via any future host-import path.
  • Agents that have no vault-backed credentials return the zero-value AuthSpec{} and pay no overhead. Goose, Pi, and OpenCode ship that shape in v1.

Negative

  • The launcher gains a Render-and-Capture lifecycle around sandboxcontainer.Builder.Run. The added surface is bounded (one new file under internal/launch/) and gated entirely behind sandbox mode.
  • One new HTTP endpoint pair on the daemon’s surface. Same auth posture as existing vault routes; namespace-scoped at routing so a stolen bearer token cannot reach arbitrary vault paths through this endpoint.
  • Two well-known vault paths land: agents/claude/oauth and agents/codex/oauth. The agents/<name>/<purpose> scheme is documented here for future agents to extend cleanly.
  • Host import (aileron auth <agent> --import-from-host) on macOS reads Codex’s ~/.codex/auth.json first and only consults the Codex Auth Keychain when the file is absent. This carries an accepted gap: a stale file masks a fresher Keychain entry. Active detection is rejected because comparing the two sources on every import would force the macOS Keychain access dialog each time and defeat the non-interactive file read. The gap fails loud (a stale, unrefreshable token surfaces a clear re-login error, not a silently-wrong credential), and the recovery is to remove the stale file. The same file-first stance applies to the in-sandbox config below.
  • The macOS Keychain and Linux secret-tool keyring topologies that Codex CLI supports natively are unreachable in the sandbox. Codex’s sandbox config.toml emits cli_auth_credentials_store = "file" so the in-container CLI reads auth.json instead. The host CLI is unchanged.
  • Concurrent launches against the same agent are not in the v1 ICP, and refresh tokens survive the race because both writers exchanged the same upstream token. The optional Fresher hook on FileBinding (described above) gates the Capture-side write so a stale capture cannot clobber a fresher rotation; bindings that leave it nil keep last-writer-wins.

Trust-model deltas vs host launch

  • The vault entry holds a usable OAuth credential. The daemon already protects this surface per ADR-0011; the new endpoints inherit the same posture without widening it.
  • The writable host-side transient directory is chmod 0700, so OAuth bytes do not leak through a shared host’s default umask. The launcher removes it on Launch exit.
  • Host launch’s credentials stay entirely host-resolved and are untouched by the vault path. The vault-to-container conduit exists only on the sandbox path, so a host launch reads the same ~/.claude/ and ~/.codex/ files it always has.

Alternatives Considered

Render-only contract (no Capture)

Ship the descriptor with Render bindings only. Bootstrap is aileron vault put manually. Rotation persistence is the user’s problem.

Rejected. Rotation persistence is the load-bearing simplification of the in-container snapshot model. Without Capture, every rotation drops on container exit and the user re-imports every few hours. Render-only would technically work but discards most of the value.

Direct vault file open from the launcher

Have the launcher call OpenLocalVault itself, avoiding the new daemon HTTP API.

Rejected. The daemon is the trust boundary for the unlocked vault per ADR-0011 and ADR-0012. A second vault opener fragments that boundary and complicates concurrent-unlock semantics.

Per-agent capture hook on Agent

Instead of a descriptor, give Agent a Capture(ctx, hostPath) error method and let each agent implement its own capture lifecycle.

Rejected. The descriptor approach keeps the contract declarative and the per-agent code small. The lifecycle is fixed (read file at known path, vault put) and should not be pluggable per agent.

Aileron-initiated OAuth dance

Aileron runs the OAuth dance with the vendor’s auth server, exchanges the authorization code, persists the bundle to vault, and avoids the in-container login entirely.

Adopted for the cold-launch case under umbrella #1267. The “host-side acquirer phase” amendment above implements exactly this dance on the host: Claude via an authorization-code grant with a hosted-callback paste, Codex via the device-authorization grant. Vendor client-id policy resolved in favor of the public Claude Code client id and the public Codex device-auth client, so neither flow requires vendor cooperation. The in-container login path is retained as the non-fatal fallback for a cancelled or failed acquire.

aileron auth <agent> --import-from-host CLI

Reach into the host’s existing Claude or Codex CLI install, extract the credential bytes, and PUT them to vault. Per-platform code paths for Linux files, macOS Keychain, and Windows files.

Deferred. The in-container login path covers bootstrap without the host-OS extraction matrix. Macos and Windows users with an existing host CLI install pay one paste-the-code dance per agent per machine. That cost is small and lands on a surface (the in-container terminal) where consent is explicit by construction. The host-import surface is a candidate follow-up when real users surface the ask.

Future composition with v4 HTTPS data plane (#896)

The current decision stores refresh tokens in the vault and hands them to the agent via the AuthSpec FileBinding. When the v4 HTTPS data plane lands and the daemon proxies credentialed network calls at the proxy boundary, the AuthSpec contract stays unchanged. The EnvBinding Render adapts to return vault-binding references rather than raw access tokens, and the daemon-side proxy bears the credential. The current shape is a transitional posture that #896 narrows, not a competing topology.

References

  • Issue #969. Vault-backed agent auth injection (this ADR’s tracking issue)
  • Issue #1267. Host-side cold-launch credential acquirer umbrella (the host-acquirer-phase amendment)
  • Issue #1290. Descriptor-driven acquisition umbrella (the capture-descriptor amendment)
  • Issue #1244. Generalize the emit mechanism so GitHub is just another binding descriptor (the binding-side sibling)
  • Issue #1245. Route GitHub through the uniform binding descriptor path (the binding-side sibling)
  • Issue #747. Milestone v4 umbrella
  • ADR-0011. Vault is the daemon’s trust boundary
  • ADR-0012. Daemon owns the unlocked vault
  • ADR-0023. v4 vault-centric encryption schema
  • ADR-0024. Sandbox MCP parity (this ADR builds on the same writable-bind-mount lifecycle)
  • docs/development/sandbox-agent-auth. Operator-facing walkthrough for vault-backed sandbox auth