Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:55:15.861Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

openagents_binding.rs

1232 lines · 45.4 KB · rust
1//! OMEGA-SW-01: one-time binding of an Omega Nostr public key to an OpenAgents
2//! account.
3//!
4//! Spec: docs/omega/2026-07-24-sarah-workroom-mvp-spec.md §4, §9.2.
5//!
6//! This is a recorded relation, not a per-request session and not a merge of
7//! the two identities. Conversation transport uses NIP-42; metering and ledger
8//! rows need the OpenAgents account id that this binding supplies.
9//!
10//! Client id is `openagents-omega` (never `openagents-desktop`). Public binding
11//! facts live under the Omega data root. Token material stays in Omega isolated
12//! keychain custody and never enters a log, crash record, or UI projection.
13
14use std::{
15    fmt,
16    fs,
17    io::{ErrorKind, Read, Write},
18    net::{Ipv4Addr, TcpListener, TcpStream},
19    path::{Path, PathBuf},
20    sync::{Arc, Mutex},
21    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
22};
23
24use anyhow::{Context as _, Result, anyhow};
25use base64::Engine as _;
26use credentials_provider::CredentialsProvider;
27use gpui::{App, AsyncApp, Global};
28use http_client::{AsyncBody, HttpClient, Method, Request, StatusCode};
29use rand::RngCore as _;
30use serde::{Deserialize, Serialize};
31use sha2::{Digest as _, Sha256};
32use smol::io::AsyncReadExt as _;
33use url::{Url, form_urlencoded};
34
35/// Distinct Omega OpenAuth client identity. Never reuse the Electron client.
36pub const OPENAGENTS_OMEGA_CLIENT_ID: &str = "openagents-omega";
37pub const OPENAGENTS_AUTHORIZE_URL: &str = "https://auth.openagents.com/authorize";
38pub const OPENAGENTS_TOKEN_URL: &str = "https://auth.openagents.com/token";
39pub const OPENAGENTS_AUTH_SESSION_URL: &str = "https://openagents.com/api/mobile/auth/session";
40pub const OPENAGENTS_SARAH_OWNER_URL: &str = "https://openagents.com/api/mobile/sarah";
41pub const OPENAGENTS_CALLBACK_PATH: &str = "/auth/callback";
42/// Keychain url for binding credentials. Omega-namespaced by the provider.
43pub const OPENAGENTS_BINDING_CREDENTIAL_KEY: &str = "omega://openagents/account-binding/v1";
44/// On-disk public relation schema (no secrets).
45pub const BINDING_RECORD_SCHEMA: &str = "openagents.omega.account-binding.v1";
46pub const BINDING_RECORD_FILE: &str = "account-binding.json";
47pub const BINDING_RECORD_SUBDIR: &str = "openagents";
48
49/// Honest owner-scope gate copy. Must not look like a network fault.
50pub const OWNER_SCOPE_REFUSED_MESSAGE: &str = "The Sarah workroom is owner-scoped today. This OpenAgents account is not admitted for the MVP owner gate. This is not a network fault.";
51
52const OPENAGENTS_REFRESH_HEADER: &str = "x-openagents-refresh-token";
53const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
54const MAX_HTTP_BODY_BYTES: u64 = 64 * 1024;
55const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
56
57/// Visible binding states for UI and session_status projection.
58#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum BindingState {
61    #[default]
62    Unbound,
63    Bound,
64    Refused,
65}
66
67impl BindingState {
68    pub fn label(self) -> &'static str {
69        match self {
70            Self::Unbound => "unbound",
71            Self::Bound => "bound",
72            Self::Refused => "refused",
73        }
74    }
75
76    /// UI-facing status line. Refused carries the owner-scope message.
77    pub fn status_line(self) -> &'static str {
78        match self {
79            Self::Unbound => "OpenAgents account unbound",
80            Self::Bound => "OpenAgents account bound",
81            Self::Refused => OWNER_SCOPE_REFUSED_MESSAGE,
82        }
83    }
84}
85
86/// Public-safe projection. Never carries tokens, refresh material, or secrets.
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct BindingProjection {
90    pub state: BindingState,
91    pub omega_public_key_hex: Option<String>,
92    pub openagents_account_id: Option<String>,
93    pub account_label: Option<String>,
94    /// Set only when `state == Refused`. Honest owner-scope copy.
95    pub gate_message: Option<String>,
96    pub bound_at: Option<String>,
97    pub client_id: String,
98}
99
100impl BindingProjection {
101    pub fn unbound() -> Self {
102        Self {
103            state: BindingState::Unbound,
104            omega_public_key_hex: None,
105            openagents_account_id: None,
106            account_label: None,
107            gate_message: None,
108            bound_at: None,
109            client_id: OPENAGENTS_OMEGA_CLIENT_ID.to_string(),
110        }
111    }
112
113    /// Serialize for UI / logs. Asserted free of secret-shaped keys.
114    pub fn public_json(&self) -> Result<String> {
115        serde_json::to_string(self).context("serialize binding projection")
116    }
117}
118
119/// On-disk public relation. Identities stay distinct fields (never merged).
120#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122struct BindingRecord {
123    schema: String,
124    schema_version: u8,
125    state: BindingState,
126    omega_public_key_hex: String,
127    openagents_account_id: String,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    account_label: Option<String>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    gate_message: Option<String>,
132    bound_at: String,
133    client_id: String,
134}
135
136impl BindingRecord {
137    fn validate(&self) -> Result<()> {
138        if self.schema != BINDING_RECORD_SCHEMA {
139            return Err(anyhow!("binding record schema mismatch"));
140        }
141        if self.schema_version != 1 {
142            return Err(anyhow!("binding record schema version unsupported"));
143        }
144        if self.client_id != OPENAGENTS_OMEGA_CLIENT_ID {
145            return Err(anyhow!("binding record client id is not openagents-omega"));
146        }
147        if self.omega_public_key_hex.trim().is_empty()
148            || self.openagents_account_id.trim().is_empty()
149        {
150            return Err(anyhow!("binding record identities incomplete"));
151        }
152        // Falsifier: identities must remain related, not merged into one field.
153        if self.omega_public_key_hex == self.openagents_account_id {
154            return Err(anyhow!("binding record merges distinct identities"));
155        }
156        match self.state {
157            BindingState::Bound => {
158                if self.gate_message.is_some() {
159                    return Err(anyhow!("bound record must not carry a gate message"));
160                }
161            }
162            BindingState::Refused => {
163                if self.gate_message.as_deref() != Some(OWNER_SCOPE_REFUSED_MESSAGE) {
164                    return Err(anyhow!("refused record missing owner-scope gate message"));
165                }
166            }
167            BindingState::Unbound => {
168                return Err(anyhow!("binding record must not persist unbound"));
169            }
170        }
171        Ok(())
172    }
173
174    fn projection(&self) -> BindingProjection {
175        BindingProjection {
176            state: self.state,
177            omega_public_key_hex: Some(self.omega_public_key_hex.clone()),
178            openagents_account_id: Some(self.openagents_account_id.clone()),
179            account_label: self.account_label.clone(),
180            gate_message: self.gate_message.clone(),
181            bound_at: Some(self.bound_at.clone()),
182            client_id: self.client_id.clone(),
183        }
184    }
185}
186
187/// Credential custody payload. Never serialized into UI structs or logs.
188#[derive(Clone, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190struct BindingCredential {
191    schema_version: u8,
192    openagents_account_id: String,
193    access_token: String,
194    refresh_token: String,
195}
196
197impl fmt::Debug for BindingCredential {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.debug_struct("BindingCredential")
200            .field("schema_version", &self.schema_version)
201            .field("openagents_account_id", &self.openagents_account_id)
202            .field("access_token", &"<redacted>")
203            .field("refresh_token", &"<redacted>")
204            .finish()
205    }
206}
207
208#[derive(Deserialize)]
209struct TokenResponse {
210    access_token: String,
211    refresh_token: String,
212    expires_in: Option<f64>,
213}
214
215#[derive(Deserialize)]
216struct VerifiedSessionResponse {
217    authenticated: bool,
218    user: VerifiedSessionUser,
219    tokens: Option<RotatedTokens>,
220}
221
222#[derive(Deserialize)]
223#[serde(rename_all = "camelCase")]
224struct VerifiedSessionUser {
225    user_id: String,
226}
227
228#[derive(Deserialize)]
229#[serde(rename_all = "camelCase")]
230struct RotatedTokens {
231    access: String,
232    refresh: String,
233    expires_in: f64,
234}
235
236enum CallbackResult {
237    Code(String),
238    Cancelled,
239    Unavailable,
240}
241
242/// Result of the owner-scope gate check after a verified OpenAgents account.
243#[derive(Clone, Copy, Debug, Eq, PartialEq)]
244enum OwnerScopeDecision {
245    Admitted,
246    Refused,
247    Unavailable,
248}
249
250#[derive(Clone)]
251pub struct OpenAgentsBinding {
252    data_root: PathBuf,
253    credentials: Arc<dyn CredentialsProvider>,
254    http_client: Arc<dyn HttpClient>,
255    phase: Arc<Mutex<BindingState>>,
256}
257
258struct OpenAgentsBindingGlobal(OpenAgentsBinding);
259impl Global for OpenAgentsBindingGlobal {}
260
261pub fn binding_record_path(data_root: &Path) -> PathBuf {
262    data_root
263        .join(BINDING_RECORD_SUBDIR)
264        .join(BINDING_RECORD_FILE)
265}
266
267/// Default Omega data root for binding records (channel-local, never Zed).
268pub fn default_binding_data_root() -> PathBuf {
269    paths::data_dir().clone()
270}
271
272pub fn init_openagents_binding(cx: &mut App) {
273    if cx.try_global::<OpenAgentsBindingGlobal>().is_some() {
274        return;
275    }
276    let binding = OpenAgentsBinding::new(
277        default_binding_data_root(),
278        zed_credentials_provider::system_keychain(cx),
279        cx.http_client(),
280    );
281    // Load any existing public record so the visible state is honest at boot.
282    let _ = binding.refresh_phase_from_disk();
283    cx.set_global(OpenAgentsBindingGlobal(binding));
284}
285
286pub fn openagents_binding(cx: &App) -> OpenAgentsBinding {
287    cx.global::<OpenAgentsBindingGlobal>().0.clone()
288}
289
290pub fn try_openagents_binding(cx: &App) -> Option<OpenAgentsBinding> {
291    cx.try_global::<OpenAgentsBindingGlobal>()
292        .map(|global| global.0.clone())
293}
294
295impl OpenAgentsBinding {
296    pub fn new(
297        data_root: PathBuf,
298        credentials: Arc<dyn CredentialsProvider>,
299        http_client: Arc<dyn HttpClient>,
300    ) -> Self {
301        Self {
302            data_root,
303            credentials,
304            http_client,
305            phase: Arc::new(Mutex::new(BindingState::Unbound)),
306        }
307    }
308
309    pub fn data_root(&self) -> &Path {
310        &self.data_root
311    }
312
313    pub fn record_path(&self) -> PathBuf {
314        binding_record_path(&self.data_root)
315    }
316
317    pub fn state(&self) -> BindingState {
318        self.phase.lock().map(|state| *state).unwrap_or_default()
319    }
320
321    fn set_state(&self, state: BindingState) {
322        if let Ok(mut current) = self.phase.lock() {
323            *current = state;
324        }
325    }
326
327    pub fn refresh_phase_from_disk(&self) -> BindingProjection {
328        let projection = self.load_projection();
329        self.set_state(projection.state);
330        projection
331    }
332
333    pub fn load_projection(&self) -> BindingProjection {
334        match self.read_record() {
335            Ok(Some(record)) => record.projection(),
336            Ok(None) | Err(_) => BindingProjection::unbound(),
337        }
338    }
339
340    /// Resolve the OpenAgents account id for metering given an Omega pubkey.
341    /// Returns None when unbound, refused, or pubkey does not match the record.
342    pub fn resolve_account_id(&self, omega_public_key_hex: &str) -> Option<String> {
343        let record = self.read_record().ok().flatten()?;
344        if record.state != BindingState::Bound {
345            return None;
346        }
347        if record.omega_public_key_hex != omega_public_key_hex {
348            return None;
349        }
350        Some(record.openagents_account_id)
351    }
352
353    /// Run the one-time OpenAuth PKCE loopback flow and record the relation.
354    pub async fn bind(
355        &self,
356        omega_public_key_hex: &str,
357        cx: &mut AsyncApp,
358    ) -> BindingProjection {
359        let pubkey = omega_public_key_hex.trim();
360        if pubkey.is_empty() {
361            let projection = BindingProjection::unbound();
362            self.set_state(BindingState::Unbound);
363            return projection;
364        }
365
366        match self.bind_inner(pubkey, cx).await {
367            Ok(projection) => {
368                self.set_state(projection.state);
369                projection
370            }
371            Err(_) => {
372                // Network / exchange failures are not owner-scope refusal.
373                // Leave any prior durable record alone; surface unbound only
374                // when nothing was written in this attempt.
375                let projection = self.load_projection();
376                self.set_state(projection.state);
377                projection
378            }
379        }
380    }
381
382    /// Clear the public relation and isolated binding credentials.
383    pub async fn clear(&self, cx: &mut AsyncApp) -> BindingProjection {
384        let _ = self.delete_credential(cx).await;
385        let _ = self.remove_record();
386        self.set_state(BindingState::Unbound);
387        BindingProjection::unbound()
388    }
389
390    async fn bind_inner(
391        &self,
392        omega_public_key_hex: &str,
393        cx: &mut AsyncApp,
394    ) -> Result<BindingProjection> {
395        let state = random_base64_url();
396        let verifier = random_base64_url();
397        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
398            .context("OpenAgents binding callback listener unavailable")?;
399        listener.set_nonblocking(true)?;
400        let port = listener.local_addr()?.port();
401        if port < 1024 {
402            return Err(anyhow!("OpenAgents binding callback listener unavailable"));
403        }
404        let redirect_uri = format!("http://127.0.0.1:{port}{OPENAGENTS_CALLBACK_PATH}");
405        let authorize_url = build_authorize_url(&redirect_uri, &state, &verifier)?;
406        cx.update(|cx| cx.open_url(authorize_url.as_str()));
407        let background_executor = cx.background_executor().clone();
408        let code =
409            match wait_for_callback(&listener, &state, CALLBACK_TIMEOUT, &background_executor)
410                .await?
411            {
412                CallbackResult::Code(code) => code,
413                CallbackResult::Cancelled => {
414                    return Ok(self.load_projection());
415                }
416                CallbackResult::Unavailable => {
417                    return Err(anyhow!("OpenAgents binding authorization timed out"));
418                }
419            };
420        let tokens = self.exchange_code(&code, &verifier, &redirect_uri).await?;
421        let mut credential = BindingCredential {
422            schema_version: 1,
423            openagents_account_id: String::new(),
424            access_token: tokens.access_token.trim().to_string(),
425            refresh_token: tokens.refresh_token.trim().to_string(),
426        };
427        if credential.access_token.is_empty()
428            || credential.refresh_token.is_empty()
429            || credential.access_token.len() > MAX_ACCESS_TOKEN_BYTES
430            || tokens
431                .expires_in
432                .is_some_and(|expires| !expires.is_finite() || expires <= 0.0)
433        {
434            return Err(anyhow!("OpenAgents binding token exchange was invalid"));
435        }
436
437        let verified = self
438            .verify_credential(&credential)
439            .await
440            .context("OpenAgents binding session verification failed")?;
441        credential = verified;
442
443        let scope = self.check_owner_scope(&credential).await;
444        let bound_at = now_iso8601();
445        let projection = match scope {
446            OwnerScopeDecision::Admitted => {
447                let record = BindingRecord {
448                    schema: BINDING_RECORD_SCHEMA.to_string(),
449                    schema_version: 1,
450                    state: BindingState::Bound,
451                    omega_public_key_hex: omega_public_key_hex.to_string(),
452                    openagents_account_id: credential.openagents_account_id.clone(),
453                    account_label: None,
454                    gate_message: None,
455                    bound_at,
456                    client_id: OPENAGENTS_OMEGA_CLIENT_ID.to_string(),
457                };
458                record.validate()?;
459                self.write_record(&record)?;
460                self.save_credential(&credential, cx).await?;
461                record.projection()
462            }
463            OwnerScopeDecision::Refused => {
464                // Durable refused relation so the UI stays honest across restarts.
465                // Do not keep tokens for a refused owner-scope account.
466                let _ = self.delete_credential(cx).await;
467                let record = BindingRecord {
468                    schema: BINDING_RECORD_SCHEMA.to_string(),
469                    schema_version: 1,
470                    state: BindingState::Refused,
471                    omega_public_key_hex: omega_public_key_hex.to_string(),
472                    openagents_account_id: credential.openagents_account_id.clone(),
473                    account_label: None,
474                    gate_message: Some(OWNER_SCOPE_REFUSED_MESSAGE.to_string()),
475                    bound_at,
476                    client_id: OPENAGENTS_OMEGA_CLIENT_ID.to_string(),
477                };
478                record.validate()?;
479                self.write_record(&record)?;
480                record.projection()
481            }
482            OwnerScopeDecision::Unavailable => {
483                // Not a refusal. Do not write a refused record for a network fault.
484                return Err(anyhow!("OpenAgents owner-scope gate unavailable"));
485            }
486        };
487        Ok(projection)
488    }
489
490    async fn exchange_code(
491        &self,
492        code: &str,
493        verifier: &str,
494        redirect_uri: &str,
495    ) -> Result<TokenResponse> {
496        let body = form_urlencoded::Serializer::new(String::new())
497            .append_pair("client_id", OPENAGENTS_OMEGA_CLIENT_ID)
498            .append_pair("code", code)
499            .append_pair("code_verifier", verifier)
500            .append_pair("grant_type", "authorization_code")
501            .append_pair("redirect_uri", redirect_uri)
502            .finish();
503        let request = Request::builder()
504            .method(Method::POST)
505            .uri(OPENAGENTS_TOKEN_URL)
506            .header("content-type", "application/x-www-form-urlencoded")
507            .body(AsyncBody::from(body))?;
508        let (status, body) = send_json(&self.http_client, request).await?;
509        if !status.is_success() {
510            return Err(anyhow!("OpenAgents binding token exchange failed ({status})"));
511        }
512        serde_json::from_slice(&body).context("OpenAgents binding token response was invalid")
513    }
514
515    async fn verify_credential(&self, credential: &BindingCredential) -> Result<BindingCredential> {
516        let request = authenticated_request(Method::GET, OPENAGENTS_AUTH_SESSION_URL, credential)?;
517        let (status, body) = send_json(&self.http_client, request).await?;
518        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
519            return Err(anyhow!("OpenAgents binding session denied"));
520        }
521        if !status.is_success() {
522            return Err(anyhow!("OpenAgents binding session unavailable ({status})"));
523        }
524        let session: VerifiedSessionResponse = serde_json::from_slice(&body)
525            .context("OpenAgents binding session response was invalid")?;
526        let account_id = session.user.user_id.trim();
527        if !session.authenticated || account_id.is_empty() {
528            return Err(anyhow!("OpenAgents binding session was not authenticated"));
529        }
530        let mut verified = BindingCredential {
531            schema_version: 1,
532            openagents_account_id: account_id.to_string(),
533            access_token: credential.access_token.clone(),
534            refresh_token: credential.refresh_token.clone(),
535        };
536        if let Some(tokens) = session.tokens {
537            if tokens.access.trim().is_empty()
538                || tokens.refresh.trim().is_empty()
539                || tokens.access.len() > MAX_ACCESS_TOKEN_BYTES
540                || !tokens.expires_in.is_finite()
541                || tokens.expires_in <= 0.0
542            {
543                return Err(anyhow!("OpenAgents binding token rotation was invalid"));
544            }
545            verified.access_token = tokens.access.trim().to_string();
546            verified.refresh_token = tokens.refresh.trim().to_string();
547        }
548        Ok(verified)
549    }
550
551    async fn check_owner_scope(&self, credential: &BindingCredential) -> OwnerScopeDecision {
552        let request =
553            match authenticated_request(Method::GET, OPENAGENTS_SARAH_OWNER_URL, credential) {
554                Ok(request) => request,
555                Err(_) => return OwnerScopeDecision::Unavailable,
556            };
557        let (status, _body) = match send_json(&self.http_client, request).await {
558            Ok(response) => response,
559            Err(_) => return OwnerScopeDecision::Unavailable,
560        };
561        if status.is_success() {
562            return OwnerScopeDecision::Admitted;
563        }
564        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
565            return OwnerScopeDecision::Refused;
566        }
567        OwnerScopeDecision::Unavailable
568    }
569
570    fn read_record(&self) -> Result<Option<BindingRecord>> {
571        let path = self.record_path();
572        if !path.exists() {
573            return Ok(None);
574        }
575        let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
576        // Public record must never contain token material.
577        if bytes_look_secret_shaped(&bytes) {
578            return Err(anyhow!("binding record contains secret-shaped material"));
579        }
580        let record: BindingRecord =
581            serde_json::from_slice(&bytes).context("binding record decode failed")?;
582        record.validate()?;
583        Ok(Some(record))
584    }
585
586    fn write_record(&self, record: &BindingRecord) -> Result<()> {
587        record.validate()?;
588        let path = self.record_path();
589        if let Some(parent) = path.parent() {
590            fs::create_dir_all(parent)
591                .with_context(|| format!("create {}", parent.display()))?;
592        }
593        let bytes = serde_json::to_vec_pretty(record)?;
594        if bytes_look_secret_shaped(&bytes) {
595            return Err(anyhow!("refusing to write secret-shaped binding record"));
596        }
597        // Path hygiene: never Zed or ~/.codex.
598        let rendered = path.to_string_lossy();
599        if rendered.contains("/.codex")
600            || rendered.contains("\\.codex")
601            || rendered.contains("/Zed/")
602            || rendered.contains("\\Zed\\")
603            || rendered.contains("/zed/")
604        {
605            return Err(anyhow!(
606                "binding record path escapes Omega data root: {rendered}"
607            ));
608        }
609        let tmp = path.with_extension("json.tmp");
610        fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp.display()))?;
611        fs::rename(&tmp, &path).with_context(|| format!("publish {}", path.display()))?;
612        Ok(())
613    }
614
615    fn remove_record(&self) -> Result<()> {
616        let path = self.record_path();
617        match fs::remove_file(&path) {
618            Ok(()) => Ok(()),
619            Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
620            Err(error) => Err(error.into()),
621        }
622    }
623
624    async fn save_credential(
625        &self,
626        credential: &BindingCredential,
627        cx: &AsyncApp,
628    ) -> Result<()> {
629        if credential.openagents_account_id.trim().is_empty()
630            || credential.access_token.trim().is_empty()
631            || credential.refresh_token.trim().is_empty()
632        {
633            return Err(anyhow!("binding credential incomplete"));
634        }
635        let secret = serde_json::to_vec(credential)?;
636        self.credentials
637            .write_credentials(
638                OPENAGENTS_BINDING_CREDENTIAL_KEY,
639                &credential.openagents_account_id,
640                &secret,
641                cx,
642            )
643            .await
644    }
645
646    async fn delete_credential(&self, cx: &AsyncApp) -> Result<()> {
647        self.credentials
648            .delete_credentials(OPENAGENTS_BINDING_CREDENTIAL_KEY, cx)
649            .await
650    }
651}
652
653/// Apply a pure state-machine transition used by tests and offline recovery.
654pub fn apply_binding_transition(
655    current: BindingState,
656    event: BindingEvent,
657) -> (BindingState, Option<&'static str>) {
658    match (current, event) {
659        (_, BindingEvent::Clear) => (BindingState::Unbound, None),
660        (BindingState::Unbound | BindingState::Bound | BindingState::Refused, BindingEvent::BindAdmitted) => {
661            (BindingState::Bound, None)
662        }
663        (BindingState::Unbound | BindingState::Bound | BindingState::Refused, BindingEvent::BindRefused) => {
664            (BindingState::Refused, Some(OWNER_SCOPE_REFUSED_MESSAGE))
665        }
666        (_, BindingEvent::BindCancelled | BindingEvent::BindUnavailable) => (current, None),
667    }
668}
669
670#[derive(Clone, Copy, Debug, Eq, PartialEq)]
671pub enum BindingEvent {
672    BindAdmitted,
673    BindRefused,
674    BindCancelled,
675    BindUnavailable,
676    Clear,
677}
678
679fn authenticated_request(
680    method: Method,
681    uri: &str,
682    credential: &BindingCredential,
683) -> Result<http_client::Request<AsyncBody>> {
684    Ok(Request::builder()
685        .method(method)
686        .uri(uri)
687        .header(
688            "authorization",
689            format!("Bearer {}", credential.access_token),
690        )
691        .header(OPENAGENTS_REFRESH_HEADER, &credential.refresh_token)
692        .body(AsyncBody::empty())?)
693}
694
695async fn send_json(
696    client: &Arc<dyn HttpClient>,
697    request: http_client::Request<AsyncBody>,
698) -> Result<(StatusCode, Vec<u8>)> {
699    let mut response = client.send(request).await?;
700    let status = response.status();
701    let mut body = Vec::new();
702    response
703        .body_mut()
704        .take(MAX_HTTP_BODY_BYTES)
705        .read_to_end(&mut body)
706        .await?;
707    Ok((status, body))
708}
709
710fn random_base64_url() -> String {
711    let mut bytes = [0_u8; 32];
712    rand::rng().fill_bytes(&mut bytes);
713    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
714}
715
716fn pkce_challenge(verifier: &str) -> String {
717    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
718}
719
720fn build_authorize_url(redirect_uri: &str, state: &str, verifier: &str) -> Result<Url> {
721    let mut authorize = Url::parse(OPENAGENTS_AUTHORIZE_URL)?;
722    authorize
723        .query_pairs_mut()
724        .append_pair("client_id", OPENAGENTS_OMEGA_CLIENT_ID)
725        .append_pair("code_challenge", &pkce_challenge(verifier))
726        .append_pair("code_challenge_method", "S256")
727        .append_pair("provider", "github")
728        .append_pair("redirect_uri", redirect_uri)
729        .append_pair("response_type", "code")
730        .append_pair("state", state);
731    Ok(authorize)
732}
733
734async fn wait_for_callback(
735    listener: &TcpListener,
736    expected_state: &str,
737    timeout: Duration,
738    background_executor: &gpui::BackgroundExecutor,
739) -> Result<CallbackResult> {
740    let deadline = Instant::now() + timeout;
741    loop {
742        match listener.accept() {
743            Ok((mut stream, _)) => {
744                if let Some(result) = read_callback(&mut stream, expected_state)? {
745                    return Ok(result);
746                }
747            }
748            Err(error) if error.kind() == ErrorKind::WouldBlock => {}
749            Err(error) => return Err(error.into()),
750        }
751        if Instant::now() >= deadline {
752            return Ok(CallbackResult::Unavailable);
753        }
754        background_executor.timer(Duration::from_millis(50)).await;
755    }
756}
757
758fn read_callback(stream: &mut TcpStream, expected_state: &str) -> Result<Option<CallbackResult>> {
759    stream.set_read_timeout(Some(Duration::from_secs(2)))?;
760    let mut buffer = [0_u8; 8192];
761    let size = stream.read(&mut buffer)?;
762    let request = std::str::from_utf8(&buffer[..size])?;
763    let Some(request_line) = request.lines().next() else {
764        write_callback_response(stream, 400)?;
765        return Ok(None);
766    };
767    let mut parts = request_line.split_whitespace();
768    if parts.next().unwrap_or_default() != "GET" {
769        write_callback_response(stream, 400)?;
770        return Ok(None);
771    }
772    let target = parts.next().unwrap_or_default();
773    let url = Url::parse(&format!("http://127.0.0.1{target}"))?;
774    if url.path() != OPENAGENTS_CALLBACK_PATH {
775        write_callback_response(stream, 404)?;
776        return Ok(None);
777    }
778    let parameter = |name: &str| {
779        url.query_pairs()
780            .find_map(|(key, value)| (key == name).then(|| value.into_owned()))
781    };
782    if parameter("state").as_deref() != Some(expected_state) {
783        write_callback_response(stream, 400)?;
784        return Ok(None);
785    }
786    if parameter("error").is_some_and(|error| !error.trim().is_empty()) {
787        write_callback_response(stream, 200)?;
788        return Ok(Some(CallbackResult::Cancelled));
789    }
790    let code = parameter("code").unwrap_or_default();
791    if code.trim().is_empty() {
792        write_callback_response(stream, 400)?;
793        return Ok(None);
794    }
795    write_callback_response(stream, 200)?;
796    Ok(Some(CallbackResult::Code(code)))
797}
798
799fn write_callback_response(stream: &mut TcpStream, status: u16) -> Result<()> {
800    let reason = match status {
801        200 => "OK",
802        404 => "Not Found",
803        _ => "Bad Request",
804    };
805    let body = "<!doctype html><meta charset=utf-8><meta name=referrer content=no-referrer><title>Omega</title><p>You can return to Omega.</p>";
806    write!(
807        stream,
808        "HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nContent-Security-Policy: default-src 'none'\r\nReferrer-Policy: no-referrer\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n{body}",
809        body.len()
810    )?;
811    stream.flush()?;
812    Ok(())
813}
814
815fn now_iso8601() -> String {
816    let secs = SystemTime::now()
817        .duration_since(UNIX_EPOCH)
818        .map(|d| d.as_secs())
819        .unwrap_or(0);
820    // Compact UTC stamp; sufficient for public relation metadata.
821    format!("{secs}")
822}
823
824fn bytes_look_secret_shaped(bytes: &[u8]) -> bool {
825    let lower = String::from_utf8_lossy(bytes).to_ascii_lowercase();
826    lower.contains("access_token")
827        || lower.contains("accesstoken")
828        || lower.contains("refresh_token")
829        || lower.contains("refreshtoken")
830        || lower.contains("\"bearer ")
831        || lower.contains("authorization")
832}
833
834/// Pure helpers used by the state machine and offline store tests without GPUI.
835#[cfg(test)]
836mod store {
837    use super::*;
838
839    pub fn write_public_record(data_root: &Path, record: &BindingRecord) -> Result<()> {
840        let binding = OpenAgentsBinding {
841            data_root: data_root.to_path_buf(),
842            credentials: Arc::new(NoopCredentials),
843            http_client: Arc::new(NoopHttp),
844            phase: Arc::new(Mutex::new(BindingState::Unbound)),
845        };
846        binding.write_record(record)
847    }
848
849    pub fn read_public_record(data_root: &Path) -> Result<Option<BindingRecord>> {
850        let binding = OpenAgentsBinding {
851            data_root: data_root.to_path_buf(),
852            credentials: Arc::new(NoopCredentials),
853            http_client: Arc::new(NoopHttp),
854            phase: Arc::new(Mutex::new(BindingState::Unbound)),
855        };
856        binding.read_record()
857    }
858
859    pub fn make_bound_record(
860        omega_public_key_hex: &str,
861        openagents_account_id: &str,
862    ) -> BindingRecord {
863        BindingRecord {
864            schema: BINDING_RECORD_SCHEMA.to_string(),
865            schema_version: 1,
866            state: BindingState::Bound,
867            omega_public_key_hex: omega_public_key_hex.to_string(),
868            openagents_account_id: openagents_account_id.to_string(),
869            account_label: None,
870            gate_message: None,
871            bound_at: "0".to_string(),
872            client_id: OPENAGENTS_OMEGA_CLIENT_ID.to_string(),
873        }
874    }
875
876    pub fn make_refused_record(
877        omega_public_key_hex: &str,
878        openagents_account_id: &str,
879    ) -> BindingRecord {
880        BindingRecord {
881            schema: BINDING_RECORD_SCHEMA.to_string(),
882            schema_version: 1,
883            state: BindingState::Refused,
884            omega_public_key_hex: omega_public_key_hex.to_string(),
885            openagents_account_id: openagents_account_id.to_string(),
886            account_label: None,
887            gate_message: Some(OWNER_SCOPE_REFUSED_MESSAGE.to_string()),
888            bound_at: "0".to_string(),
889            client_id: OPENAGENTS_OMEGA_CLIENT_ID.to_string(),
890        }
891    }
892
893    struct NoopCredentials;
894    impl CredentialsProvider for NoopCredentials {
895        fn read_credentials<'a>(
896            &'a self,
897            _url: &'a str,
898            _cx: &'a AsyncApp,
899        ) -> std::pin::Pin<
900            Box<dyn std::future::Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>,
901        > {
902            Box::pin(async { Ok(None) })
903        }
904
905        fn write_credentials<'a>(
906            &'a self,
907            _url: &'a str,
908            _username: &'a str,
909            _password: &'a [u8],
910            _cx: &'a AsyncApp,
911        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>> {
912            Box::pin(async { Ok(()) })
913        }
914
915        fn delete_credentials<'a>(
916            &'a self,
917            _url: &'a str,
918            _cx: &'a AsyncApp,
919        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>> {
920            Box::pin(async { Ok(()) })
921        }
922    }
923
924    struct NoopHttp;
925    impl HttpClient for NoopHttp {
926        fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
927            None
928        }
929
930        fn proxy(&self) -> Option<&http_client::Url> {
931            None
932        }
933
934        fn send(
935            &self,
936            _req: http_client::Request<AsyncBody>,
937        ) -> futures::future::BoxFuture<'static, Result<http_client::Response<AsyncBody>>> {
938            use futures::FutureExt as _;
939            async { Err(anyhow!("noop http client")) }.boxed()
940        }
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use tempfile::tempdir;
948
949    #[test]
950    fn authorize_url_uses_omega_client_not_desktop() {
951        let url = build_authorize_url(
952            "http://127.0.0.1:49152/auth/callback",
953            "state-fixture",
954            "verifier-fixture-with-enough-entropy-for-the-test",
955        )
956        .expect("authorize URL");
957        assert_eq!(
958            url.as_str().split('?').next(),
959            Some(OPENAGENTS_AUTHORIZE_URL)
960        );
961        let values = url
962            .query_pairs()
963            .collect::<std::collections::HashMap<_, _>>();
964        assert_eq!(
965            values.get("client_id").map(|value| value.as_ref()),
966            Some("openagents-omega")
967        );
968        assert_ne!(
969            values.get("client_id").map(|value| value.as_ref()),
970            Some("openagents-desktop")
971        );
972        assert_eq!(
973            values.get("provider").map(|value| value.as_ref()),
974            Some("github")
975        );
976        assert_eq!(
977            values
978                .get("code_challenge_method")
979                .map(|value| value.as_ref()),
980            Some("S256")
981        );
982        assert_eq!(
983            values.get("redirect_uri").map(|value| value.as_ref()),
984            Some("http://127.0.0.1:49152/auth/callback")
985        );
986    }
987
988    #[test]
989    fn state_machine_covers_unbound_bound_refused() {
990        assert_eq!(
991            apply_binding_transition(BindingState::Unbound, BindingEvent::BindAdmitted),
992            (BindingState::Bound, None)
993        );
994        let (state, message) =
995            apply_binding_transition(BindingState::Unbound, BindingEvent::BindRefused);
996        assert_eq!(state, BindingState::Refused);
997        assert_eq!(message, Some(OWNER_SCOPE_REFUSED_MESSAGE));
998        let message = message.unwrap();
999        assert!(message.contains("owner-scoped today"));
1000        // Must explicitly deny a network-fault reading, and must not claim timeout/unreachable.
1001        assert!(message.contains("not a network fault"));
1002        assert!(!message.to_ascii_lowercase().contains("timeout"));
1003        assert!(!message.to_ascii_lowercase().contains("unreachable"));
1004        assert!(!message.to_ascii_lowercase().contains("connection failed"));
1005
1006        // Cancel / unavailable must not look like refusal.
1007        assert_eq!(
1008            apply_binding_transition(BindingState::Unbound, BindingEvent::BindCancelled),
1009            (BindingState::Unbound, None)
1010        );
1011        assert_eq!(
1012            apply_binding_transition(BindingState::Unbound, BindingEvent::BindUnavailable),
1013            (BindingState::Unbound, None)
1014        );
1015        assert_eq!(
1016            apply_binding_transition(BindingState::Bound, BindingEvent::Clear),
1017            (BindingState::Unbound, None)
1018        );
1019        assert_eq!(
1020            apply_binding_transition(BindingState::Refused, BindingEvent::Clear),
1021            (BindingState::Unbound, None)
1022        );
1023    }
1024
1025    #[test]
1026    fn public_record_stores_distinct_identities_without_tokens() {
1027        let root = tempdir().expect("tempdir");
1028        // Simulate Omega RC data root shape.
1029        let data_root = root.path().join("Omega RC");
1030        let record = store::make_bound_record(
1031            "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899",
1032            "user.openagents.fixture",
1033        );
1034        store::write_public_record(&data_root, &record).expect("write");
1035        let path = binding_record_path(&data_root);
1036        let rendered = path.to_string_lossy();
1037        assert!(rendered.contains("Omega RC"));
1038        assert!(rendered.contains("openagents/account-binding.json"));
1039        assert!(!rendered.contains(".codex"));
1040        assert!(!rendered.contains("/Zed/"));
1041        assert!(!rendered.contains("/zed/"));
1042
1043        let disk = fs::read_to_string(&path).expect("read disk");
1044        assert!(!disk.to_ascii_lowercase().contains("access_token"));
1045        assert!(!disk.to_ascii_lowercase().contains("refresh_token"));
1046        assert!(!disk.contains("Bearer"));
1047        assert!(disk.contains("openagents-omega"));
1048        assert!(disk.contains("user.openagents.fixture"));
1049        assert!(disk.contains("aabbccddeeff00112233445566778899"));
1050
1051        let loaded = store::read_public_record(&data_root)
1052            .expect("read")
1053            .expect("present");
1054        assert_eq!(loaded.state, BindingState::Bound);
1055        assert_eq!(
1056            loaded.omega_public_key_hex,
1057            "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
1058        );
1059        assert_eq!(loaded.openagents_account_id, "user.openagents.fixture");
1060        // Distinct fields: relation, not merge.
1061        assert_ne!(loaded.omega_public_key_hex, loaded.openagents_account_id);
1062    }
1063
1064    #[test]
1065    fn refused_record_carries_owner_scope_message_not_network_fault() {
1066        let root = tempdir().expect("tempdir");
1067        let record = store::make_refused_record(&"pk".repeat(32), "user.not-owner");
1068        store::write_public_record(root.path(), &record).expect("write");
1069        let loaded = store::read_public_record(root.path())
1070            .expect("read")
1071            .expect("present");
1072        assert_eq!(loaded.state, BindingState::Refused);
1073        assert_eq!(
1074            loaded.gate_message.as_deref(),
1075            Some(OWNER_SCOPE_REFUSED_MESSAGE)
1076        );
1077        let projection = loaded.projection();
1078        let json = projection.public_json().expect("json");
1079        assert!(json.contains("refused"));
1080        assert!(json.contains("owner-scoped today"));
1081        assert!(!json.to_ascii_lowercase().contains("access_token"));
1082        assert!(!json.to_ascii_lowercase().contains("refresh"));
1083        assert_eq!(projection.state.status_line(), OWNER_SCOPE_REFUSED_MESSAGE);
1084    }
1085
1086    #[test]
1087    fn resolve_account_id_only_when_bound_and_pubkey_matches() {
1088        let root = tempdir().expect("tempdir");
1089        let pubkey = "11".repeat(32);
1090        let other = "22".repeat(32);
1091        store::write_public_record(
1092            root.path(),
1093            &store::make_bound_record(&pubkey, "account.metering"),
1094        )
1095        .expect("write");
1096        let binding = OpenAgentsBinding::new(
1097            root.path().to_path_buf(),
1098            Arc::new(TestNoopCredentials),
1099            Arc::new(TestNoopHttp),
1100        );
1101        assert_eq!(
1102            binding.resolve_account_id(&pubkey).as_deref(),
1103            Some("account.metering")
1104        );
1105        assert_eq!(binding.resolve_account_id(&other), None);
1106
1107        store::write_public_record(
1108            root.path(),
1109            &store::make_refused_record(&pubkey, "account.metering"),
1110        )
1111        .expect("write refused");
1112        assert_eq!(
1113            binding.resolve_account_id(&pubkey),
1114            None,
1115            "refused must not resolve for metering"
1116        );
1117    }
1118
1119    #[test]
1120    fn credential_debug_and_ui_projection_never_contain_tokens() {
1121        let credential = BindingCredential {
1122            schema_version: 1,
1123            openagents_account_id: "owner.fixture".to_string(),
1124            access_token: "secret-access-token-value".to_string(),
1125            refresh_token: "secret-refresh-token-value".to_string(),
1126        };
1127        let debug = format!("{credential:?}");
1128        assert!(debug.contains("<redacted>"));
1129        assert!(!debug.contains("secret-access-token-value"));
1130        assert!(!debug.contains("secret-refresh-token-value"));
1131
1132        let projection = BindingProjection {
1133            state: BindingState::Bound,
1134            omega_public_key_hex: Some("pk".into()),
1135            openagents_account_id: Some("owner.fixture".into()),
1136            account_label: Some("owner@example.com".into()),
1137            gate_message: None,
1138            bound_at: Some("1".into()),
1139            client_id: OPENAGENTS_OMEGA_CLIENT_ID.to_string(),
1140        };
1141        let json = projection.public_json().expect("json");
1142        assert!(!json.contains("access"));
1143        assert!(!json.contains("refresh"));
1144        assert!(!json.contains("token"));
1145        assert!(!json.contains("secret"));
1146        assert!(json.contains("\"state\":\"bound\""));
1147    }
1148
1149    #[test]
1150    fn callback_requires_exact_path_state_and_hides_code_from_response() {
1151        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("listener");
1152        let address = listener.local_addr().expect("address");
1153        let client = std::thread::spawn(move || {
1154            let mut stream = TcpStream::connect(address).expect("connect");
1155            write!(
1156                stream,
1157                "GET /auth/callback?state=exact&code=private-code HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
1158            )
1159            .expect("write");
1160            let mut response = String::new();
1161            stream.read_to_string(&mut response).expect("response");
1162            response
1163        });
1164        let (mut stream, _) = listener.accept().expect("accept");
1165        let result = read_callback(&mut stream, "exact").expect("callback");
1166        assert!(matches!(result, Some(CallbackResult::Code(code)) if code == "private-code"));
1167        drop(stream);
1168        let response = client.join().expect("client");
1169        assert!(!response.contains("private-code"));
1170        assert!(response.contains("return to Omega"));
1171    }
1172
1173    #[test]
1174    fn merged_identities_are_rejected() {
1175        let mut record = store::make_bound_record("same-id", "same-id");
1176        assert!(record.validate().is_err());
1177        record.openagents_account_id = "different".into();
1178        record.client_id = "openagents-desktop".into();
1179        assert!(record.validate().is_err());
1180    }
1181
1182    struct TestNoopCredentials;
1183    impl CredentialsProvider for TestNoopCredentials {
1184        fn read_credentials<'a>(
1185            &'a self,
1186            _url: &'a str,
1187            _cx: &'a AsyncApp,
1188        ) -> std::pin::Pin<
1189            Box<dyn std::future::Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>,
1190        > {
1191            Box::pin(async { Ok(None) })
1192        }
1193
1194        fn write_credentials<'a>(
1195            &'a self,
1196            _url: &'a str,
1197            _username: &'a str,
1198            _password: &'a [u8],
1199            _cx: &'a AsyncApp,
1200        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>> {
1201            Box::pin(async { Ok(()) })
1202        }
1203
1204        fn delete_credentials<'a>(
1205            &'a self,
1206            _url: &'a str,
1207            _cx: &'a AsyncApp,
1208        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>> {
1209            Box::pin(async { Ok(()) })
1210        }
1211    }
1212
1213    struct TestNoopHttp;
1214    impl HttpClient for TestNoopHttp {
1215        fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
1216            None
1217        }
1218
1219        fn proxy(&self) -> Option<&http_client::Url> {
1220            None
1221        }
1222
1223        fn send(
1224            &self,
1225            _req: http_client::Request<AsyncBody>,
1226        ) -> futures::future::BoxFuture<'static, Result<http_client::Response<AsyncBody>>> {
1227            use futures::FutureExt as _;
1228            async { Err(anyhow!("test noop http")) }.boxed()
1229        }
1230    }
1231}
1232
Served at tenant.openagents/omega Member data and write actions are omitted.