Merge nucleic/sleek-river-urchin-2nzm into dev
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user