Merge nucleic/sleek-river-urchin-2nzm into dev

This commit is contained in:
2026-07-17 22:16:34 -07:00
parent 7526dc0dd4
commit 45c62b58ef
+304
View File
@@ -0,0 +1,304 @@
# Nucleic — Remote Agent Sign-In (iOS → Mac / Cloud Runner)
> **Status (2026-07-18): plan, nothing built.** 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.
## 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.