Skip to repository content1232 lines · 45.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:55:15.861Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
openagents_binding.rs
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