## Keychain Token Registry

Tokens live in the platform-native credential store (macOS Keychain / Linux libsecret / Windows Credential Manager). Key names are resolved via **preferences mapping**  -  never hardcoded.

### Rule 1  -  Never prompt for a token *value* mid-run

The pipeline NEVER prompts the user mid-phase to type or paste a token *value* into chat (that leaks the secret into the transcript). It MAY ask a *decision* question mid-run (regenerate / use another token / skip) - the answer is a choice, never the secret. The value always enters through the clipboard Save Flow. If a token is needed for the current operation:

1. Read the mapped key from `prefs.global.keychainMapping.<service>`.
2. Resolve via `~/.claude/lib/credential-store.sh get "$KEY_NAME"`.
3. **If the credential store returns empty / missing**: pause the current adapter, surface a structured error (`{ status: "blocked", reason: "missing-token", service: "<id>", expected_key: "<name>" }`), and run the inline Token Save Flow (`setup.md`). Resume from the same step after setup. **Do NOT** ask the user to paste the token in chat.
4. **If the credential returns 401 / invalid**: surface the same structured error with `reason: "expired-token"` and run the Expired-token decision below.

#### Expired-token decision (runs in-flow on 401 / 403)

When a token resolves but the service rejects it (401 / 403), do not silently skip and do not silently fall through to a lower tier. Surface a single `AskUserQuestion` (`question` + `description` in `outputLanguage`; `label` + `header` English):

- **Regenerate** - the existing token is dead; open Save Flow Step B to replace `<KEY_NAME>` in place (same key name, clipboard path), then retry the operation.
- **Use a different token** - map a new or existing Keychain entry for this service (Save Flow Step B + identity binding), then retry.
- **Skip and continue** - drop this token's contribution. Non-critical services (Confluence enrichment, Fortify scan, Graylog log fetch, Firebase events, Figma fallback tiers) continue with reduced evidence and a one-line warning. A token that is structurally required for the input (e.g. the Jira PAT for a Jira-ID input) halts instead, since the run cannot proceed without it.

The question asks a *choice*; the replacement value still flows through the clipboard, never chat. `smoke-no-token-prompt.sh` greps for value-prompts (`enter token`, `paste token`, `API key:`) - decision labels like `Regenerate` / `Skip and continue` do not trip it.

Expiry is also checked **proactively at init**: Phase 0 Step 0.7 (`$HOME/.claude/multi-agent-refs/phases/phase-0-init.md`) probes every token the run will need with one cheap call each (cached via `global.serviceStatus`, TTL 300s) and runs this same decision at init instead of waiting for the first mid-run 401. The mid-run 401 path stays as the safety net.

#### Figma MCP token lifecycle (critical)

`figma_mcp` is the only OAuth token in the registry (`figu_` prefix, ~90-day expiry); everything else is a long-lived PAT. A dead MCP token silently degrades every Figma consumer to Tier 2/3, so its expiry is handled specially:

1. **Refresh token**: the generation flow stores a refresh token under `<keychainMapping.figma_mcp>_Refresh`. `~/.claude/lib/figma-mcp-refresh.sh` performs a silent renewal (OAuth refresh grant; client credentials read from the `.figma-oauth.json` next to `prefs.global.tokenScripts.figma_mcp`). Exit 0 = renewed in place, no user interaction. This ALWAYS runs before any question is asked.
2. **Generation script**: `prefs.global.tokenScripts.figma_mcp` may point to a user-owned script that produces a fresh MCP token via Dynamic Client Registration + browser OAuth and saves it to the Keychain. When silent renewal fails, the expired-token question offers **Regenerate now (script)** which runs it (interactive runs only  -  it opens a browser).
3. **Fallbacks**: Token Save Flow (clipboard) or continue degraded on Tier 2 (REST PAT), per the Phase 0 Step 0.7 decision table.

`prefs.global.tokenScripts` maps service ids to user-owned generation scripts (`{"figma_mcp": "/path/to/start_mcp.sh"}`); paths are personal and live only in the local preferences file  -  never in synced command files. Script output is status lines only  -  token values never enter chat or logs.

Reasons:
- Asking for a token in chat leaks it into the conversation transcript and any audit log.
- The user has already configured the keychain; missing/expired is a setup issue, not a per-run issue.
- The structured error gives the orchestrator a clear path: pause → setup → resume.

The smoke gate `pipeline/scripts/smoke-no-token-prompt.sh` greps the pipeline source for hardcoded "enter token" / "paste token" / "API key:" prompts and fails the build if any are introduced. Token I/O is exclusively `credential-store.sh` calls.

### Rule 2  -  MUST: never ask the user for what a mapped credential can fetch (BLOCKING)

Before asking the user for data that an external system holds - a stack trace, a log
line, a page body, a finding, a design frame - inventory the credentials first:

```bash
bash "$HOME/.claude/lib/credential-inventory.sh" --json            # what is configured
bash "$HOME/.claude/lib/credential-inventory.sh" --json --probe    # and what actually answers
bash "$HOME/.claude/lib/credential-inventory.sh" --key firebase     # exit 0 = usable
```

The inventory reads `prefs.global.keychainMapping`, probes each logical key through
`credential-store.sh get` with the value discarded, and reports `present` /
`mapped-but-missing` / `unmapped` alongside what each key unlocks. Values are never
printed.

The answer then decides the shape of the question:

| Inventory says | The question must be |
|---|---|
| `present` | ask for the **pointer**, not the payload, and say you will fetch it: "Give me the Crashlytics issue URL - I have the Firebase service account mapped and will pull the stack frames, affected versions and device spread myself." |
| `mapped-but-missing` | say the credential is configured but not resolving, name the logical key, and offer the Save Flow: "`firebase` is mapped but the Keychain item is not resolving - refresh it, or paste the trace and I continue without it." |
| `unmapped` | say the capability is not configured and offer setup: "No `firebase` key is mapped, so I cannot reach Crashlytics. Onboard it via `/multi-agent:setup`, or paste the trace." |

**Never** present "paste it yourself" as the first and most efficient option when a
credential is present. That is the defect this rule exists for: a run asked the user to
paste a Crashlytics stack trace, offering the manual path as "the fastest and most
certain route", while a valid Firebase service-account JSON sat in the Keychain mapped
as `firebase`. The user had configured that key precisely so the pipeline would not ask.

**State the limit honestly too.** A credential is not omniscience: the Crashlytics
fetcher resolves a specific issue and needs its URL, so "I have the key" does not mean
"I can find the crash from an exception message alone". Say which piece is missing and
why, rather than implying either more or less capability than exists.

#### Present is not the same as working

`--probe` makes one cheap authenticated request per configured credential, because
"the key is in the Keychain" and "the service answers" are different claims and the user
fixes them in different ways:

| Verdict | What it means | What to tell the user |
|---|---|---|
| `reachable` | the service answered and accepted the credential | nothing - proceed |
| `auth-rejected` | 401/403: the credential is dead | name the logical key, run the Expired-token decision (Rule 1) |
| `unreachable` | no response at all | on a corporate host this is almost always the VPN: "`{FORTIFY_HOST}` is not answering - connect the VPN and I will retry, or I continue without the finding" |
| `no-host-configured` | a token exists but `global.hosts.<service>` was never recorded | "I hold the `jira` token but no Jira host is configured, so I cannot build a request URL - set it via `/multi-agent:setup`" |
| `well-formed` | structurally valid, liveness not checkable yet | say what is still needed (the Crashlytics issue URL) |
| `not-probeable` | no cheap probe exists for this credential type | do not claim it works, do not claim it fails |

Never collapse these into one "failed" bucket. A dead token, a closed VPN and a missing
host look identical in a `{status: "failed"}` field and lead the user to three different
wrong actions.

Probe only what is configured. An unmapped key is a capability the user chose not to
enable - reporting it as a problem trains them to ignore the report. Cache the result in
`prefs.global.serviceStatus.<service>` (`{ok, checkedAt, error?}`, TTL
`settings.serviceStatusCacheSeconds`) so a phase chain probes once, not per fetcher.

Record what the inventory found in `agent-state.json.credentialInventory`
(`{usable: [...], needsAttention: [...], at: <ts>}`) so a later phase can tell "the
source was unreachable" from "nobody looked".

**Retrieve pattern (always use mapping):**

```bash
PREFS_FILE="$HOME/.claude/multi-agent-preferences.json"

# Read mapped key name for a service, fallback to standard name
KEY_NAME=$(jq -r '.global.keychainMapping.<service_id> // "<standard_key_name>"' "$PREFS_FILE")
TOKEN=$(~/.claude/lib/credential-store.sh get "$KEY_NAME" 2>/dev/null)
```

The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Linux for deterministic behaviour (writes both `-l` (label) and `-s` (service) attributes; reads tolerate either convention so manually-added personal tokens are findable).

**Standard key names (convention for new tokens):**

| Service ID         | Context            | Standard Key Name                  | Type              |
| ------------------ | ------------------ | ---------------------------------- | ----------------- |
| `jira`             | Jira issue fetch   | `${USER}_Jira_Access_Token`        | PAT               |
| `bitbucket_token`  | Bitbucket PR/push  | `${USER}_Bitbucket_Access_Token`   | App Password      |
| `bitbucket_user`   | Bitbucket username | `${USER}_Bitbucket_Username`       | Plain text        |
| `github`           | GitHub issue/PR    | `${USER}_Github_Access_Token`      | PAT (+ `gh auth`) |
| `confluence`       | Confluence docs    | `${USER}_Confluence_Access_Token`  | PAT               |
| `figma`            | Figma              | `${USER}_Figma_Access_Token`       | PAT               |
| `figma_mcp`        | Figma MCP          | `${USER}_Figma_Mcp_Access_Token`   | OAuth             |
| `fortify`          | Fortify            | `${USER}_Fortify_Access_Token`     | API Token         |
| `graylog`          | Graylog (prod)     | `${USER}_Graylog_Access_Token`     | API Token         |
| `graylog_test`     | Graylog (test)     | `${USER}_Graylog_Test_Access_Token` | API Token, optional  -  unset falls back to `graylog` |
| `firebase`         | Firebase           | `${USER}_Firebase_Access_Json`     | JSON (base64). One key per Firebase project; extras are named `..._Json_<projectId>` and listed in `global.firebase.accounts[]` |
| `jenkins`          | Jenkins CI         | `${USER}_Jenkins_Access_Token`     | API Token         |
| `appstore_connect_key_id` | App Store Connect | `${USER}_AppStoreConnect_Key_Id` | Identifier, not a secret |
| `appstore_connect_issuer_id` | App Store Connect | `${USER}_AppStoreConnect_Issuer_Id` | Identifier, not a secret |
| `appstore_connect_apple_id` | App Store Connect | `${USER}_AppStoreConnect_Apple_Id` | Email address |
| `appstore_connect_password_item` | App Store Connect | `${USER}_AppStoreConnect_Password_Item` | Keychain ITEM NAME, not a password |
|  -                   | Git Identity       | Stored in preferences JSON         | Not Keychain      |

**Key name mapping lives in preferences:**

```json
{
  "global": {
    "keychainMapping": {
      "jira": "${USER}_Jira_Access_Token",
      "bitbucket_token": "${USER}_Bitbucket_Access_Token",
      "bitbucket_user": "${USER}_Bitbucket_Username",
      "github": "${USER}_Github_Access_Token",
      "confluence": "${USER}_Confluence_Access_Token",
      "figma": "${USER}_Figma_Access_Token",
      "figma_mcp": "${USER}_Figma_Mcp_Access_Token",
      "fortify": "${USER}_Fortify_Access_Token",
      "graylog": "${USER}_Graylog_Access_Token",
      "graylog_test": null,
      "firebase": "${USER}_Firebase_Access_Json",
      "jenkins": "${USER}_Jenkins_Access_Token"
    }
  }
}
```

**Key naming rule:**
- **Existing tokens**: actual key names may differ from standard names (e.g. `MyGithubPAT` instead of `${USER}_Github_Access_Token`). These are NEVER renamed  -  the mapping in preferences resolves the difference. Run `/multi-agent setup` to discover and map existing keys.
- **New tokens**: ALWAYS created with the standard key name from the table above. This ensures cross-machine consistency for new installations.

**Multiple keys for same service**: If Keychain contains more than one entry for a service (e.g. multiple Firebase keys), list all found entries and ask the user which one to use. Never assume  -  always ask.

**Adding a new token (uses standard key name automatically):**

```bash
bash "$HOME/.claude/scripts/keychain-save.sh" <service-id>
```
