# Nucleic — Remote Agent Sign-In (iOS → Mac / Cloud Runner)

> **Status (2026-07-18): M1–M6 built; live vendor E2E pending.** Design for signing in to agent CLIs (Claude, Codex) **from the iPhone
> app**, with the OAuth flow *brokered by* a mesh host — either a connected Mac or a Covalence
> Cloud Runner — and the resulting credential propagated to every other mesh member by the
> existing credential mesh. Closes the gap called out in [CLOUD_RUNTIME.md](CLOUD_RUNTIME.md)
> §7.3: *"tokens expire and Codex device-auth is interactive… sealed re-injection from the
> phone is the recovery path. Design this in Phase 3."* Builds directly on
> [COVALENCE_RUNNER.md](COVALENCE_RUNNER.md) §6 (credential mesh) and the in-app OAuth brokers
> that already exist on the Mac.
>
> **What shipped (this branch):**
> - **Wire** (`Sources/NucleicProtocol/Sync/AgentLoginMessages.swift`): the §3 surface as
>   designed, plus `AgentLoginProvider.forBackend` and a per-provider `canBrokerLogin` on
>   `WireProviderAuthStatus`; `WireCapabilities.canBrokerAgentLogin`; `SyncClient.Event` cases.
>   Tests: `AgentLoginMessageTests`.
> - **Host** (`Sources/NucleicCore/AgentLoginCoordinator.swift` + broker `loginRemotely` on
>   both brokers): the §4 shape landed as a *capture closure* rather than a named driver
>   protocol — `RemoteLoginChallenge`/`RemoteLoginCode`/`RemoteLoginCapture` + a deadline race
>   (`remoteCaptureWithDeadline`); the coordinator correlates challenge↔callback per requestID,
>   one flow per provider, cancellation-handler backstop. ConnectionHandler dispatches the
>   three verbs off its serial loop; `SyncHost` pushes `agentAuthStatus` post-hello and fans
>   out changes; AppStore rebroadcasts after local logins too and notes remote sign-ins.
>   Tests: `AgentLoginCoordinatorTests` (E2E through the real Codex broker, fake transport).
> - **Runner** (`Sources/nucleicd/Nucleicd.swift`): brokers constructed remote-only; a
>   first-landed login propagates via the existing digest-gated mirror-back sweep (its digest
>   table starts empty per boot, so first-landing counts as change — no new hook needed).
> - **iOS** (`Models/RemoteStore.swift` AgentLoginState, `Net/OAuthRedirectListener.swift`,
>   `Views/AgentAccountsView.swift`): Settings ▸ Agent Accounts, the root-level sign-in sheet
>   (SFSafariViewController + loopback auto-capture / paste bar), and the in-chat auth-failure
>   banner (`AgentAuthErrorBanner`).
>
> - **M5 — API keys from the phone (§8), built** with two deviations from the sketch: no Mac
>   "vault-lite" type was needed — the Mac's existing device sealing keypair
>   (`CredentialProvider`/`DeviceSealingKeyStore`) is the recipient, with API-key landings
>   added to `landUpdate` (Anthropic → `ControlAPIKeyStore`, OpenAI →
>   `CodexControlAPIKeyStore`; xAI stays runner-only — the Mac has no store for it). And the
>   phone learns a host's sealing key from the existing `credentialNeeded` push, now sent
>   whenever the host can ingest (a cockpit Mac pushes `kinds: []` so no peer answers; a
>   runner still lists what it's missing). Every AppStore host now advertises
>   `canReceiveSealedCredentials`; a landed provision triggers the `agentAuthStatus`
>   rebroadcast — the sender's implicit ack (`method` now populated: "oauth" | "apiKey").
>   iOS: per-row "Use API key…" (key icon) in Agent Accounts → SecureField sheet → sealed
>   `credentialProvision`; the phone never stores the key.
>
> - **Phone as credential HOLDER + mesh-wide key deletion (the spun-out follow-up), built.**
>   iOS `PhoneCredentialVault` (actor): the two mirrorable kinds (claude-oauth, codex-auth)
>   at rest under ChaChaPoly with a device-local key, SE-P-256-wrapped where available
>   (`kSecAttrTokenIDSecureEnclave`); the Curve25519 sealing keypair is Keychain-held — the SE
>   protects only the at-rest wrap (CLOUD_RUNTIME §5 honesty rule). The phone gossips a
>   `CredentialManifest` post-welcome to hosts advertising `canReceiveSealedCredentials`,
>   answers `credentialNeeded` for *requested* kinds only (empty kinds still solicit nothing —
>   the M5 contract), and lands `credentialUpdate` via the SAME comparators as the Mac hubs
>   (`ClaudeCredentialFormat`/`CodexCredentialFormat`, moved to NucleicProtocol; the Keychain/
>   file hubs delegate to them). Leases: the phone records, never acquires (`merged` prefers
>   stable holders). Deletion: `CredentialTombstone` (`deletedAt`-monotonic per kind, like mesh
>   roster tombstones) rides `ClientMsg.credentialRevoke` (gated on
>   `WireCapabilities.canRevokeCredentials`, control scope), `HostMsg.credentialRevoked`
>   (fan-out + post-hello table push), and the manifest's new `tombstones` field — so a stale
>   holder can't resurrect a deleted kind. A holder applying a stone *absorbs* its copy's
>   freshness (Claude stamps records with the token's future `expiresAt`; the bump keeps the
>   stone covering it), and suppression is judged on the credential's own clock, never the
>   mirror-back's "now" wire stamp. Landing a stone: runner deletes the landed file + unsets
>   the env var (`RunnerCredentialVault.remove`); a Mac clears
>   `ClaudeLoginKeychain`/`~/.codex/auth.json`/`ControlAPIKeyStore`/`CodexControlAPIKeyStore`
>   (`CredentialProvider.applyTombstones` + `CredentialTombstoneStore`); everyone rebroadcasts
>   `agentAuthStatus` (the implicit ack). UI: iOS Agent Accounts row context menu ("Sign Out /
>   Delete API Key on All Devices…"), Mac Settings ▸ Nucleic Control ("Sign out of … on all
>   devices…"), both confirm first. Tests: `RunnerMessagesTests` (verbs, capability default,
>   merge/suppression/absorb, comparators — green on Linux) + `CredentialMeshTests`
>   (store monotonicity, vault remove, tombstone-suppressed landing).
>
> **Still open:** a live E2E against real vendor OAuth (phone→Mac and phone→runner over the
> production relay, per §10 M3/M4 proof columns) plus a manual pass of the phone-set API key
> landing in the Mac Keychain stores (not automatable hermetically — the stores have no
> injectable seam, deliberately); a manual multi-device pass of holder gossip + deletion
> (phone vault ↔ runner mirror-back ↔ Mac stores over the production relay); Mac-side
> "signed in via iPhone" provenance in Settings is nice-to-have.

## 0. Goal and user stories

1. **Re-auth from the couch.** A session on the Mac (or a runner) dies with "OAuth token has
   expired · please run /login". The phone shows the same "Log in" affordance the Mac's
   transcript shows (`AuthErrorRow`), the user taps it, completes the vendor's consent page in
   an in-app browser, and the session can be retried — without touching the Mac.
2. **First sign-in on a fresh runner, no Mac required.** An iPhone-primary user provisions a
   cloud runner and signs in to Claude/Codex against it directly. Today a runner can only be
   credentialed by a Mac that already holds a login (`CredentialProvider` answering
   `credentialNeeded`); this feature makes the phone able to *originate* a login.
3. **Sign in once, credentialed everywhere.** Wherever the flow executes, the credential lands
   in that host's canonical store and the existing mesh machinery (mirror-back sweep +
   `credentialNeeded`/`credentialProvision` + newest-wins reconciliation) spreads it to the
   other members. No new propagation machinery.

Providers in scope: **Claude** (subscription OAuth) and **Codex** (ChatGPT OAuth). API keys
(Anthropic/OpenAI/xAI) are a first-class adjacent path (§8) per CLOUD_RUNTIME §7.3's ToS
posture. Grok/ACP agents stay env-key-only.

## 1. What already exists (the load-bearing inventory)

- **Nucleic already owns both OAuth flows — no CLI login, no terminal.**
  - `ClaudeOAuth` (`Sources/NucleicCore/Claude/ClaudeOAuth.swift`): Authorization-Code+PKCE,
    authorize at `claude.ai/oauth/authorize`, exchange at
    `console.anthropic.com/v1/oauth/token`, **two capture modes**: localhost loopback on an
    *ephemeral* port (`OAuthLoopback`) or the console paste-back redirect
    (`ClaudeOAuth.consoleRedirect` renders `code#state` for the user to copy).
  - `CodexOAuth` (`Sources/NucleicCore/Codex/CodexOAuth.swift`): PKCE against
    `auth.openai.com`, client only permits the **fixed** redirect
    `http://localhost:1455/auth/callback` (`loopbackPort = 1455`). No paste fallback exists.
  - Both flows' pure surfaces (URL building, exchange parsing) are injectable-transport and
    unit-tested; both compile on Linux (`FoundationNetworking`/`Crypto` guards). The
    *loopback* is `#if canImport(Network)` — absent on Linux, which today makes runner-local
    login impossible. The remote flow below sidesteps that entirely.
- **Brokers with injected UX seams.** `ClaudeCredentialBroker.loginInteractively()`
  (`Sources/NucleicCore/Claude/ClaudeCredentialBroker.swift:183`) takes `openURL:
  (URL) -> Void` and `promptForCode: (URL) async -> String?`; the Mac app injects
  `NSWorkspace.open` + an `NSAlert` paste prompt (`Sources/NucleicApp/NucleicApp.swift:77-88`,
  `ClaudeLoginPrompt.swift`). `CodexCredentialBroker.loginInteractively()` likewise. An
  explicit FIFO gate already serializes concurrent logins/refreshes per provider.
- **Canonical stores + per-turn seeding.** Claude: Nucleic's own Keychain item via
  `ClaudeLoginKeychain` (macOS) / `~/.claude/.credentials.json` honoring `CLAUDE_CONFIG_DIR`
  (Linux); sessions receive only `CLAUDE_CODE_OAUTH_TOKEN`. Codex: `~/.codex/auth.json` 0600,
  newest-wins by `last_refresh` (`CodexAuthFile`). Seeding into session homes is
  `SessionController`'s job and needs no change.
- **The credential mesh (both directions) is done** (COVALENCE_RUNNER §6):
  `HostMsg.credentialNeeded {kinds, sealingPublicKey}` → `ClientMsg.credentialProvision`
  (sealed via `SealedCredentialBox`), landing table in
  `RunnerCredentialVault.landings` (`Sources/NucleicCore/Sync/RunnerCredentialVault.swift:96`),
  30 s digest-gated mirror-back sweep → `HostMsg.credentialUpdate` → device Keychain
  reconciliation (`CredentialProvider.landUpdate`). **Propagation after a remote login is
  therefore free** — landing the credential in the executing host's canonical store is enough.
- **iOS is greenfield here.** No WebView/`ASWebAuthenticationSession` anywhere in `ios/`; the
  phone's credential-mesh verbs are inert no-op stubs
  (`ios/NucleicRemote/NucleicRemote/Models/HostConnection.swift:803` — "Inert here until the
  phone-side vault lands"); there is no per-agent settings surface and no login verb.
- **Auth-failure detection** exists only on the Mac UI: `isAuthError(_:)`
  (`Sources/NucleicApp/TranscriptRow.swift:613`) string-matches `401` /
  `authentication_error` / `please run /login` / `oauth token has expired` and renders the
  "Log in" row.
- **Additive-verb discipline** (COVALENCE_RUNNER §11.4): new `ClientMsg` verbs are gated on a
  `WireCapabilities` bit (unknown client tags **throw** on old hosts; `HostMsg` decodes
  unknown tags to `.unknown`, so new host→phone messages are automatically tolerable).

## 2. Decision — host-brokered login, phone-captured redirect

Two candidate shapes were considered:

| | A. Phone-native OAuth (phone runs PKCE + exchange, becomes a `CredentialProvider`) | **B. Host-brokered (chosen)** — host runs PKCE + exchange; phone opens the URL and captures the redirect |
|---|---|---|
| Where plaintext tokens live | On the phone (new iOS vault, SE wrap, new attack surface) | Only on the executing host, in stores that already exist |
| Code motion | Extract `ClaudeOAuth`/`CodexOAuth` out of NucleicCore into an iOS-shippable module (iOS links only NucleicProtocol/NucleicTailnet) | None — brokers stay in NucleicCore where both Mac app and `nucleicd` already link them |
| Propagation | Phone must implement provider-side mesh sealing (today inert) | Free — existing mirror-back + provision paths |
| Matches the ask | Indirect | **Directly**: "route through either a connected Mac or a cloud runner" |
| Refresh ownership | Phone would hold refresh tokens it can't reliably run (backgrounded) | Refresh lease stays with hosts (Macs/runners), as designed |

**B wins on every axis.** The one thing the phone must contribute is the piece only it has: a
browser next to the user, and (for Codex) a loopback listener the vendor's fixed
`localhost:1455` redirect can land on — because the browser runs *on the phone*, `localhost`
resolves to the phone, so redirect capture **must** happen phone-side in any design. The host
contributes everything secret: PKCE verifier, token exchange, persistence.

The trust posture is unchanged: the auth code transits only the Noise-encrypted E2EE channel
(relay sees ciphertext); the code is single-use and worthless without the PKCE verifier, which
never leaves the host.

## 3. Wire protocol additions (`Sources/NucleicProtocol/Sync/AgentLoginMessages.swift`)

New file mirroring `RunnerMessages.swift` conventions (tolerant `init(from:)`, CBOR-friendly,
tested on Linux).

```swift
public enum AgentLoginProvider: String { case claude, codex }

/// How the phone should capture the vendor redirect for this attempt.
public enum AgentLoginCapture {
    /// Bind 127.0.0.1:port, wait for GET path?code=…&state=…, auto-forward.
    case loopback(port: UInt16, path: String)
    /// Consent page renders `code#state` for the user to copy-paste (Claude console redirect).
    case pasteCode
}

// ClientMsg additions (phone → host), all gated on the new capability bit:
case agentLoginBegin(WireAgentLoginBegin)     // {requestID, provider, boundLoopbackPort: UInt16?}
case agentLoginCallback(WireAgentLoginCallback) // {requestID, code: String, state: String?}
case agentLoginCancel(requestID: String)

// HostMsg additions (host → phone), forward-compatible via .unknown:
case agentLoginChallenge(WireAgentLoginChallenge) // {requestID, authorizeURL, capture, expiresAt}
case agentLoginResult(WireAgentLoginResult)       // {requestID, ok, accountLabel?, message?}
case agentAuthStatus([WireProviderAuthStatus])    // push, see §7
```

- **Capability bit**: `WireCapabilities.canBrokerAgentLogin` (`WireMessages.swift:130` struct;
  stored prop + `decodeIfPresent ?? false`, same contract as `canMintPairingCode`). Set by the
  Mac app and by `nucleicd` when the matching broker is constructible. The *provider list* the
  host can broker rides `agentAuthStatus` (§7), not the bit, so adding Grok later needs no new
  capability.
- **Scope**: `.control` required (same gate as `startChat`/`createProject`) in the
  `ConnectionHandler.swift:395` switch.
- **Port negotiation**: the phone binds *before* asking — `agentLoginBegin.boundLoopbackPort`
  carries what it got. Codex requires exactly 1455; if the phone couldn't bind 1455 it omits
  the port and the host answers `agentLoginResult{ok:false, message:"port busy"}` (retry
  affordance phone-side). Claude accepts any localhost port, so the host builds the authorize
  URL against the phone's bound port when present, else falls back to `consoleRedirect` +
  `.pasteCode`.
- **State handling**: phone forwards `state` verbatim; host verifies `state == pkce.state`
  (exactly what `loginInteractively()` does at `ClaudeCredentialBroker.swift:195`).
- One in-flight attempt per (provider, host); `requestID` is one-shot; host expires the PKCE
  state at `expiresAt` (10 min) and pushes a failure result.

## 4. Host side (`NucleicCore`) — refactor the brokers around a *driver*

`loginInteractively()` is today hard-wired to local UX (open browser here, bind loopback here,
prompt here). Factor the flow so local and remote are two drivers over one core:

```swift
protocol AgentLoginDriver: Sendable {
    /// Present the consent URL and return the captured (code, state).
    func capture(authorizeURL: URL, capture: AgentLoginCapture) async throws -> (String, String?)
}
```

- **LocalDriver** (Mac app, unchanged behavior): open via `NSWorkspace`, capture via
  `OAuthLoopback` with paste fallback — i.e., today's `loginInteractively()` body.
- **RemoteDriver** (new): sends `HostMsg.agentLoginChallenge` to the initiating peer and
  suspends on a continuation that `ClientMsg.agentLoginCallback` / `agentLoginCancel` /
  timeout resumes. Lives beside `SyncHostBridge`; wired through `AppStore` the same way
  `respondMacPair` round-trips today.
- The broker keeps: PKCE mint, FIFO gate, `exchange`, validation, persistence
  (`ClaudeLoginKeychain.write` / `CodexCredentialBroker.persist`), error mapping. Net effect:
  `loginInteractively()` becomes `login(driver:)`, and the existing Mac UI passes the
  LocalDriver — no behavior change for the Mac's own "Log in" buttons
  (`AppStore.loginClaude():1366`, `loginCodex():1389`, `login(forBackend:):1412`).
- **ConnectionHandler**: `case .agentLoginBegin` → scope guard → `AppStore.beginRemoteLogin`
  which constructs the RemoteDriver bound to that connection and calls the broker. Multiple
  clients: results go only to the initiating connection; `agentAuthStatus` (§7) goes to all.
- **Audit surface on the Mac**: post a passive user notification + activity-feed line
  ("iPhone 'X' signed in to Claude on this Mac") — control scope already authorizes the act
  (a phone can delete sessions); visibility, not consent, is the requirement. Newest-wins
  reconciliation already governs overwrite semantics.

## 5. Runner side (`nucleicd`)

- Construct both brokers at boot (no `openURL`/`promptForCode` — remote-only). Advertise
  `canBrokerAgentLogin` iff the provider CLI is present in the image (it is:
  `containers/nucleic-runner` carries claude/codex/grok).
- Persistence on Linux already goes to the exact vault landing paths
  (`ClaudeLoginKeychain` `#else` branch → `~/.claude/.credentials.json`;
  `CodexCredentialBroker.persist` → `~/.codex/auth.json` 0600). The 30 s digest-gated
  mirror-back sweep (`Nucleicd.swift:228`) then pushes `credentialUpdate` to every device —
  **so signing in against a runner automatically credentials the user's Macs and other
  runners.** Verify the sweep treats a *first* landing (not just rotation) as a change; add
  the inventory/digest hook in `RunnerCredentialVault` if login writes bypass it.
- The remote flow removes the Linux blocker outright: no `Network` framework needed because
  the loopback lives on the phone.
- Signing in against a **Mac** propagates runner-ward through the existing
  `credentialNeeded` → `CredentialProvider` provision path next time a runner asks (fresh
  boot/expiry), plus the proactive push-after-refresh path noted in
  `SyncHostBridge.swift:178`.

## 6. iOS side (`ios/NucleicRemote`)

New pieces (all greenfield):

1. **Agent Accounts UI** — a new section in `SettingsView` listing each provider per connected
   host (or aggregated by mesh with a host picker): status chip (signed in / not signed in /
   API-key mode, from §7), "Sign in" button, "Sign in on…" host selector when >1
   `canBrokerAgentLogin` host is connected. Default host choice: the session's host when
   launched from an in-chat auth error; else the user's pick. Gate the section behind the
   app's existing biometric gate (UX_IOS discipline).
2. **`AgentLoginFlowController`** (phone-side state machine in `RemoteStore`/`HostConnection`):
   - For Codex: attempt `NWListener` bind on `127.0.0.1:1455` first; for Claude: bind an
     ephemeral port. Include the bound port in `agentLoginBegin`.
   - On `agentLoginChallenge`: present the authorize URL in **`SFSafariViewController`**
     (not `ASWebAuthenticationSession` — its callback API can't intercept a plain
     `http://localhost` redirect, and keeping the app foreground keeps the listener alive;
     Safari-on-device resolves `localhost` to the phone, which is the whole trick).
   - `.loopback` capture: tiny HTTP responder on the listener — parse
     `GET <path>?code&state`, reply with a static "Return to Nucleic" success page, dismiss
     the Safari sheet, auto-send `agentLoginCallback`.
   - `.pasteCode` capture: after the Safari sheet, show a paste field ("Paste the code shown
     by Anthropic"), send on submit.
   - Timeout/cancel → `agentLoginCancel`; result toast from `agentLoginResult`.
3. **In-chat re-auth CTA**: port the `isAuthError(_:)` matcher into shared code the phone can
   use (either duplicate the small matcher in `ios/` or host it in `NucleicProtocol` so Mac
   and phone stay in lockstep) and render an `AuthErrorRow` equivalent in the iOS transcript
   with "Log in" → the flow above, pre-targeted at the erroring session's host.
4. **Plumbing**: `SyncClient.Event` cases for the two new `HostMsg`s, `HostConnection.Callbacks`
   closures, `RemoteStore.beginAgentLogin(host:provider:)`. Outbound verbs strictly gated on
   `capabilities.canBrokerAgentLogin`.

Backgrounding note: `SFSafariViewController` keeps the app active, so the `NWListener` and the
Noise socket stay alive for the whole consent flow. If the user hops to Safari.app anyway, the
attempt times out gracefully host-side and is retryable.

## 7. Auth-status projection (so the phone can *see* who's signed in)

The Mac already computes this — `ProviderAvailability` ("Installed, but not signed in",
`Sources/NucleicCore/ProviderAvailability.swift:30`). Surface it:

```swift
struct WireProviderAuthStatus { provider; installed: Bool; authenticated: Bool;
                                method: oauth|apiKey|none; accountLabel: String? }
```

Pushed as `HostMsg.agentAuthStatus` post-hello and on every change (login, mirror-back
landing, key entry), throttled like other pushes. This also doubles as the "which providers
can this host broker" list (§3). `accountLabel` comes from the credential where cheap (Codex
`id_token` email; Claude account uuid) — optional, nice for multi-account clarity.

## 8. API keys from the phone (adjacent, small, ToS-defensive)

CLOUD_RUNTIME §7.3 wants Console API keys first-class. The mesh already defines the kinds
(`anthropic-api-key`, `openai-api-key`, `xai-api-key` → files + env vars via
`RunnerCredentialVault.landings`). Add a `SecureField` beside each provider in the Agent
Accounts UI that seals the entered key to the target host:

- **Runner target**: works today — runners advertise `canReceiveSealedCredentials`; implement
  the currently-inert phone-side seal (`SealedCredentialBox.seal` is in NucleicProtocol, which
  iOS already links; the stub to replace is `HostConnection.swift:803`).
- **Mac target**: Macs don't hold a sealing keypair today. Give the Mac host a vault-lite —
  instantiate the same `RunnerCredentialVault` sealing identity and advertise
  `canReceiveSealedCredentials`, landing into `ControlAPIKeyStore`/`CodexControlAPIKeyStore`
  instead of dot-files. This is deliberately the same verb set — no new wire surface.

This piece is separable; ship it after M3 if schedule pressure hits.

## 9. Security posture (what we will and won't claim)

- Auth codes, tokens, and API keys transit **only** the Noise E2EE channel; relay and control
  plane carry ciphertext (same claim as the credential mesh — keep the honesty note from
  COVALENCE_RUNNER §9: a runner *is* a trusted endpoint like your Mac; never claim E2EE for
  cloud execution).
- PKCE verifier and refresh tokens never leave the executing host. The phone sees the
  authorize URL and the single-use code only.
- Phone loopback binds `127.0.0.1` only, serves one static page, validates nothing itself
  (state is verified host-side), and shuts down with the attempt.
- `.control` scope required to initiate; Mac posts a visible audit trail; overwrites follow
  existing newest-wins reconciliation; `NUCLEIC_DISABLE_LOGIN_SYNC=1` remains the kill switch.
- ToS: unchanged BYO posture (CLOUD_RUNTIME §7.3) — unmodified vendor CLIs, user-owned
  credentials, never proxied or pooled; API-key path first-class as the fallback.

## 10. Milestones

| # | Deliverable | Proof |
|---|---|---|
| **M1 Protocol** | `AgentLoginMessages.swift`, `canBrokerAgentLogin`, `agentAuthStatus`, envelope encode/decode + `SyncClient.Event` cases | Protocol tests green on macOS **and** Linux (extend `RunnerMessagesTests` pattern); old-peer compat: verbs never sent without the bit |
| **M2 Host broker** | Driver refactor (`login(driver:)`), RemoteDriver + continuation plumbing, `ConnectionHandler` dispatch, Mac audit notification, auth-status push | Unit tests with injected `Transport` fakes (no vendor network); Mac local login regression-free (existing `loginClaude/loginCodex` paths) |
| **M3 iOS flow** | Agent Accounts UI, `AgentLoginFlowController`, `SFSafariViewController` + `NWListener` capture, paste fallback, in-chat auth-error CTA | Manual E2E phone→Mac for both providers on LAN + relay; auth-status chips live-update |
| **M4 Runner** | `nucleicd` broker wiring + vault inventory hook | E2E over the **production relay**: phone→runner Claude sign-in, then a real authenticated turn (`nucleic-smoke` extension), then `credentialUpdate` observed landing on a Mac |
| **M5 API keys** (separable) | Phone-side sealing (de-stub `HostConnection.swift:803`), Mac vault-lite | Sealed key from phone lands + exports on runner and Mac; session uses it |
| **M6 Docs** | Update COVALENCE_RUNNER §6 (phone as login originator), UX_IOS (new flow), CLOUD_RUNTIME §7.3 (mark designed) | — |

Sequencing: M1→M2→M3 is the critical path; M4 is small once M2 exists (the RemoteDriver is
host-agnostic); M5 is independent after M1.

## 11. Open questions

1. **On-Mac consent?** Current stance: control scope suffices + audit notification. Revisit if
   we ever grant `.control` to less-trusted devices.
2. **Codex port 1455 collisions on the phone** are near-impossible (no other app binds it in
   our process space — iOS sandboxes per-app loopback? *No — loopback is shared device-wide;
   still overwhelmingly unlikely, and the failure is graceful + retryable*). If it ever
   matters, investigate whether the Codex client also registers a device-code grant.
3. **Multi-account**: one credential per provider per mesh today (newest-wins). Signing in
   with a different account from the phone silently rotates every member. `accountLabel` in
   the status push is the mitigation (show *who* you're signed in as before overwriting);
   true multi-account is out of scope.
4. **Phone-originated first runner provision** (the pool credential rides the mesh already;
   the composer gate needs a host-advertised "runner-enabled" capability — COVALENCE_RUNNER
   status note). Adjacent, not blocking: story 2 assumes the runner exists; provisioning from
   the phone is its own follow-up.
