# Authentication

> API keys, OAuth login, credential storage, auth checks, ambient auth, and refresh hang failure modes.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human docs: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1
- Complete Markdown: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1/llms-full.txt

## Source Files

- `packages/coding-agent/src/cli/auth-command.ts`
- `packages/coding-agent/src/cli/auth-check.ts`
- `packages/coding-agent/src/core/auth-storage.ts`
- `packages/coding-agent/src/core/auth-guidance.ts`
- `packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts`
- `packages/coding-agent/test/auth-storage.test.ts`

---

---
title: "Authentication"
description: "API keys, OAuth login, credential storage, auth checks, ambient auth, and refresh hang failure modes."
---

Pi authenticates providers through `ModelRuntime`: interactive `/login` and `/logout`, CLI `pi auth` subcommands, environment variables, `auth.json`, runtime API-key overrides, and ambient cloud credentials. Provider auth is BYOK/BYOC — credentials stay on the local machine (or in your process environment) and are never assumed from a hosted pi service.

## Credential resolution

When `ModelRuntime` resolves request auth for a provider, priority is:

| Order | Source | Persistence |
|------:|--------|-------------|
| 1 | CLI `--api-key` → `setRuntimeApiKey()` | Process only (not written to disk) |
| 2 | `auth.json` (API key or OAuth) | `~/.pi/agent/auth.json` by default |
| 3 | Provider environment variables (for example `ANTHROPIC_API_KEY`) | Process environment |
| 4 | `models.json` / extension fallback keys | Config file / extension registration |

`getProviderAuthStatus()` labels the active source as `runtime`, `stored`, `environment`, `fallback`, `models_json_key`, or `models_json_command`.

Auth-related guidance messages point users at `/login` and the local providers/models docs when no models or keys are available.

## Interactive login and logout

In interactive mode:

| Command | Behavior |
|---------|----------|
| `/login` | Choose auth type (OAuth vs API key), then provider |
| `/login <provider>` | Jump to that provider (match by id or display name) |
| `/logout` | Remove **stored** credentials only |

`/logout` does not clear environment variables or `models.json` keys. Stored credentials write to `getAuthPath()` (`~/.pi/agent/auth.json` by default).

Login paths:

- **OAuth** — browser / device-code / paste-redirect flows via `LoginDialogComponent` and provider-owned `oauth.login`
- **API key with `login` method** — prompt for a key (or provider-specific setup) and persist `{ type: "api_key", key }`
- **Ambient** — providers that are configured outside pi (for example AWS profiles for Bedrock, or ADC for Vertex) open an informational ambient-auth dialog instead of saving a secret

After a successful login, pi:

1. Synchronizes local provider composition and availability (`CredentialSynchronizationError` if credentials committed but local sync fails)
2. Tries to select the provider’s default model when available
3. Starts a **bounded 15s** background catalog `refresh` for that provider; on abort or error it warns and keeps cached models

Subscription OAuth examples include Codex, Claude Pro/Max, GitHub Copilot, xAI subscription, OpenRouter PKCE (mints a user-controlled API key), and Radius. API-key providers use env vars or stored keys; full provider tables live under Providers docs in the package.

## Auth file storage

Default path: `~/.pi/agent/auth.json` (`getAuthPath()` / `AuthStorage.create()`).

```json
{
  "anthropic": { "type": "api_key", "key": "sk-ant-..." },
  "openai-codex": {
    "type": "oauth",
    "access": "...",
    "refresh": "...",
    "expires": 1735689600000
  },
  "cloudflare-ai-gateway": {
    "type": "api_key",
    "key": "$CLOUDFLARE_API_KEY",
    "env": {
      "CLOUDFLARE_API_KEY": "...",
      "CLOUDFLARE_ACCOUNT_ID": "account-id",
      "CLOUDFLARE_GATEWAY_ID": "gateway-id"
    }
  }
}
```

### Credential shapes

| Type | Required fields | Notes |
|------|-----------------|-------|
| `api_key` | `type`, optional `key`, optional `env` | `key` may be literal, `$ENV` / `${ENV}`, or `!command` |
| `oauth` | `type`, `access`, `refresh`, `expires` (finite number) | Managed by provider OAuth refresh |

`api_key` resolution:

- **Literal** — used as-is
- **Env interpolation** — `$VAR` / `${VAR}`; missing vars leave the key unresolved (auth check → not configured)
- **Command** — `!…` runs once and uses stdout (process-lifetime cache via config-value resolution)
- **Escapes** — `$$` → `$`, `$!` → `!`
- **Credential-scoped `env`** — preferred over process env when resolving that credential’s key, headers, and provider config

### Storage behavior (`AuthStorage`)

- Parent dir created as `0700`; file written as `0600`
- File lock with retries (sync and async); async locks treat stale locks (~30s) and honor `AbortSignal`
- Concurrent readers coalesce reloads by file revision
- `modify` / `delete` re-read under lock so external concurrent edits to other providers are preserved
- `AuthStorage.inMemory()` and injectable `CredentialStore` backends for tests and SDK embeds
- `ReadOnlyAuthStorage` — validates shape, never creates or mutates the file (used by `auth check --no-refresh`)
- `RuntimeCredentials` — overlays non-persistent `setRuntimeApiKey` / `removeRuntimeApiKey` on top of a store

One-time migration (`migrateAuthToAuthJson`) runs only when `auth.json` is missing: merges legacy `oauth.json` and `settings.json` `apiKeys`, then renames oauth to `oauth.json.migrated` and rewrites settings without `apiKeys`.

## Ambient auth

Some providers authenticate without a pi-owned API key string:

| Pattern | Examples |
|---------|----------|
| Cloud SDK / profiles | Amazon Bedrock (`AWS_PROFILE`, IAM keys, IRSA, ECS roles); Google Vertex ADC |
| Skip-auth proxies | `AWS_BEDROCK_SKIP_AUTH=1` against a corporate Bedrock proxy |
| Provider resolve without key | Extension/provider `resolve` returns headers or ambient identity |

Interactive `/login` for an API-key method that has no interactive `login` handler shows the ambient-auth dialog (“configured outside pi”). Branch summarization and other internal model calls must tolerate request auth with **no** `apiKey` (ambient providers stream with headers/env only).

Ambient configuration is independent of `/logout`: logout only deletes stored `auth.json` entries.

## CLI auth commands

```bash
pi auth print-api-key --provider <provider> [--model <model>]
pi auth print-bearer-token --provider <provider> [--model <model>] [--min-expiry <duration>]
pi auth check --provider <provider> | --model <model> [--json] [--credentials] [--no-refresh]
```

All three require at least one of `--provider` or `--model`. They accept only those flags (no free-form messages, files, or `--api-key` on the auth command itself).

### `print-api-key` / `print-bearer-token`

| | print-api-key | print-bearer-token |
|--|---------------|--------------------|
| Credential type | Non-OAuth (API key path) | OAuth only |
| Refresh | Via normal `getAuth()` (OAuth refreshed when remaining validity is low) | Same; default minimum remaining validity **30 minutes** (`--min-expiry` like `30m`, `1h`) |
| Timeout | 15s `AbortSignal` on the whole resolve path | Same |
| Output | Raw secret on stdout | Access token on stdout |
| Ambiguity | Error if multiple providers match a bare `--model` | Same |

### `auth check`

Preflight without starting a full session. Default path **may refresh** expired OAuth; `--no-refresh` uses `ReadOnlyAuthStorage` and returns the stored access token without calling refresh.

| Flag | Effect |
|------|--------|
| `--json` | Emit status object (and optional credentials) as JSON |
| `--credentials` | Include resolved secret when status is `ready` |
| `--no-refresh` | Do not refresh OAuth; do not create missing auth files |

**Exit codes:** `0` ready · `1` not_ready · `2` invalid / check failure

| `status` | Typical `reason` | Meaning |
|----------|------------------|---------|
| `ready` | — | Provider known and credentials usable; `authType` is `api_key` or `oauth` |
| `not_ready` | `provider_not_found`, `credentials_not_configured`, `credential_not_available` | Missing provider, unresolved key, or credential extract failed |
| `invalid` | `invalid_state` | Malformed auth file, runtime composition error, or unexpected throw |

Human mode prints `ready` / `not_ready` / `invalid`, or the raw credential when `--credentials` is set. JSON mode:

```json
{"status":"ready","provider":"openai","authType":"api_key","credentials":"sk-..."}
```

Auth-check runtimes use an in-memory models store, `allowModelNetwork: false`, and `refreshOnCreate: false` so checks stay offline and do not touch catalog storage.

## SDK and embedding

```typescript
import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create(); // ~/.pi/agent/auth.json + models.json
await modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key"); // not persisted

const customRuntime = await ModelRuntime.create({
  authPath: "/tmp/my-app/auth.json",
  modelsPath: "/tmp/my-app/models.json",
});

const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
  modelRuntime: customRuntime,
});
```

Important `ModelRuntime` surfaces:

| Method | Role |
|--------|------|
| `checkAuth(providerId)` | Presence / type without necessarily minting request headers |
| `getAuth(providerId \| model, overrides?)` | Full request auth; OAuth refresh when remaining validity &lt; `minOAuthValidityMs` (default **five minutes**) |
| `login` / `logout` | Persist credentials, then synchronize local provider snapshot |
| `setRuntimeApiKey` / `removeRuntimeApiKey` | Process-only override |
| `listCredentials` | Provider ids and types |
| `getProviderAuthStatus` | Configured source label |

`login`, `logout`, and runtime key mutations queue **per-provider** so concurrent credential ops do not interleave. They wait for local catalog/composition consistency, not remote network freshness. If the credential write succeeded but local sync failed, they throw `CredentialSynchronizationError` (`providerId`, `operation`, `credential`, `cause`) — do not blindly retry the mutation.

Pass `AbortSignal` on create, refresh, and auth ops when the embedder needs deadlines. Credential print paths use a 15s timeout; interactive post-login catalog refresh uses the same bound.

## OAuth refresh and export

- Normal request auth refreshes OAuth when remaining lifetime is below the five-minute default (`minOAuthValidityMs`).
- `pi auth print-bearer-token` defaults to requiring **30 minutes** of remaining validity so exported tokens are usable by external clients.
- `pi auth check` refreshes by default; `--no-refresh` returns the stored access token as-is.
- OpenRouter login mints a user-controlled API key (not a short-lived OAuth access token with auto-expiry semantics).

## Failure modes

### Credential refresh hang (issues #7027 / #7113)

**Symptom:** `/login` or credential mutation appears stuck behind a stalled network model catalog refresh.

**Expected behavior:**

- Login / credential ops do **not** wait on an older stalled network `refresh`
- A new provider generation can publish local availability without waiting for the hung network call
- Interactive post-login catalog refresh is capped at 15s; timeout yields a warning and cached models, without undoing the saved credential

**Mitigations for embeds:** pass a timeout signal to `refresh({ providers, signal })`; treat `result.aborted` and `result.errors` as non-fatal after a successful login.

### Auth check invalid state

Malformed `auth.json`, unresolved `$MISSING_VAR` keys, or composition errors surface as `invalid` / `not_ready` rather than crashing. `--no-refresh` never creates parent dirs or the auth file.

### CredentialSynchronizationError

Credentials may already be on disk while models/availability are stale. Inspect the error fields; re-run `refresh({ providers: [id], signal })` rather than re-login unless the stored credential itself is wrong.

### Ambient vs API-key assumptions

Do not require `options.apiKey` for internal streams (branch summaries, custom compaction, etc.). Providers may resolve only headers or cloud SDK auth.

### Experimental CLI auth input

Experimental command plumbing accepts mutually exclusive `--auth-token` / `--auth-token-file` (`parseAuthInput`). That path is separate from `pi auth check|print-*` and the interactive `/login` surface.

## Security notes

- `auth.json` is mode `0600`; agent dir parent creation uses `0700`
- Mounting host `~/.pi/agent` into containers exposes sessions **and** credentials
- Project trust gates loading project extensions/settings; it does **not** sandbox tool execution or isolate credentials
- Prefer short-lived keys or ambient cloud roles for unattended automation

## Next

<CardGroup>
  <Card title="Providers and models" href="/providers-and-models">
    Built-in catalogs, models.json, and provider-specific env vars.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    First /login or API-key session.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Full flag surface including auth subcommands.
  </Card>
  <Card title="SDK" href="/sdk">
    ModelRuntime, custom authPath, and createAgentSession wiring.
  </Card>
  <Card title="SDK examples" href="/sdk-examples">
    Recipe including 09-api-keys-and-oauth.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Network retries, credential refresh hangs, and process cleanup.
  </Card>
</CardGroup>
