feat(cli): wire OS keychain multi-origin token retrieval and fix issue endpoint routes (fixes #88)

37b68bb88b1d · AtlantisPleb · · parent d3ee1888df70

feat(cli): wire OS keychain multi-origin token retrieval and fix issue endpoint routes (fixes #88)
Fixes
#88

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/auth.rs
  • modified crates/openagents-cli/src/tracker.rs

Diff

2 files changed, +119 -14

crates/openagents-cli/src/auth.rs modified +79 -6

@@ -1,9 +1,10 @@

1
//! Authentication, credential store, device pairing and persistent state
1
//! Authentication, credential store, OS keychain / secret-tool adapter, and persistent state
2 2
3 3
use serde::{Deserialize, Serialize};
4 4
use std::collections::HashMap;
5 5
use std::fs;
6 6
use std::path::PathBuf;
7
use std::process::Command;
7 8
8 9
#[derive(Debug, Clone, Serialize, Deserialize)]
9 10
pub struct AuthConfig {

@@ -62,9 +63,63 @@ impl CredentialStore {

62 63
                return Some(env_token.trim().to_string());
63 64
            }
64 65
        }
65
        let config = self.load().ok()?;
66
        let profile_key = config.default_profile.unwrap_or_else(|| "default".to_string());
67
        config.profiles.get(&profile_key).and_then(|p| p.token.clone())
66
67
        if let Ok(config) = self.load() {
68
            let profile_key = config.default_profile.unwrap_or_else(|| "default".to_string());
69
            if let Some(token) = config.profiles.get(&profile_key).and_then(|p| p.token.clone()) {
70
                if !token.trim().is_empty() {
71
                    return Some(token);
72
                }
73
            }
74
        }
75
76
        // Try OS Keychain on macOS with explicit origin account keys
77
        #[cfg(target_os = "macos")]
78
        {
79
            for origin in ["https://openagents.com", "http://localhost:4000", "https://staging.openagents.com"] {
80
                if let Ok(output) = Command::new("security")
81
                    .args(["find-generic-password", "-a", origin, "-s", "openagents-cli", "-w"])
82
                    .output()
83
                {
84
                    if output.status.success() {
85
                        let token_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
86
                        if token_str.starts_with("oa_pat_") || token_str.starts_with("smct_") {
87
                            return Some(token_str);
88
                        }
89
                    }
90
                }
91
            }
92
93
            if let Ok(output) = Command::new("security")
94
                .args(["find-generic-password", "-s", "openagents-cli", "-w"])
95
                .output()
96
            {
97
                if output.status.success() {
98
                    let token_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
99
                    if token_str.starts_with("oa_pat_") || token_str.starts_with("smct_") {
100
                        return Some(token_str);
101
                    }
102
                }
103
            }
104
        }
105
106
        // Try secret-tool on Linux
107
        #[cfg(target_os = "linux")]
108
        {
109
            if let Ok(output) = Command::new("secret-tool")
110
                .args(["lookup", "service", "openagents-cli"])
111
                .output()
112
            {
113
                if output.status.success() {
114
                    let token_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
115
                    if !token_str.is_empty() {
116
                        return Some(token_str);
117
                    }
118
                }
119
            }
120
        }
121
122
        None
68 123
    }
69 124
70 125
    pub fn set_token(&self, token: &str) -> Result<(), Box<dyn std::error::Error>> {

@@ -75,7 +130,16 @@ impl CredentialStore {

75 130
        let profile_key = config.default_profile.clone().unwrap_or_else(|| "default".to_string());
76 131
        let profile = config.profiles.entry(profile_key).or_insert_with(Default::default);
77 132
        profile.token = Some(token.to_string());
78
        self.save(&config)
133
        self.save(&config)?;
134
135
        #[cfg(target_os = "macos")]
136
        {
137
            let _ = Command::new("security")
138
                .args(["add-generic-password", "-U", "-a", "https://openagents.com", "-s", "openagents-cli", "-w", token])
139
                .output();
140
        }
141
142
        Ok(())
79 143
    }
80 144
81 145
    pub fn clear_token(&self) -> Result<(), Box<dyn std::error::Error>> {

@@ -88,6 +152,15 @@ impl CredentialStore {

88 152
                profile.token = None;
89 153
            }
90 154
        }
91
        self.save(&config)
155
        self.save(&config)?;
156
157
        #[cfg(target_os = "macos")]
158
        {
159
            let _ = Command::new("security")
160
                .args(["delete-generic-password", "-a", "https://openagents.com", "-s", "openagents-cli"])
161
                .output();
162
        }
163
164
        Ok(())
92 165
    }
93 166
}
crates/openagents-cli/src/tracker.rs modified +40 -8

@@ -1,7 +1,7 @@

1 1
//! Real tracker client implementation for OpenAgents Issues, Projects, Comments, and Milestones
2 2
//! Talking to real `/api/v1` routes with authenticated requests
3 3
4
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
4
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
5 5
use serde::{Deserialize, Serialize};
6 6
7 7
#[derive(Debug, Clone, Serialize, Deserialize)]

@@ -41,6 +41,7 @@ impl TrackerClient {

41 41
    fn headers(&self) -> HeaderMap {
42 42
        let mut map = HeaderMap::new();
43 43
        map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
44
        map.insert(ACCEPT, HeaderValue::from_static("application/json"));
44 45
        if let Some(tok) = &self.token {
45 46
            if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", tok)) {
46 47
                map.insert(AUTHORIZATION, val);

@@ -50,8 +51,14 @@ impl TrackerClient {

50 51
    }
51 52
52 53
    pub async fn list_issues(&self, repo: &str) -> Result<Vec<Issue>, Box<dyn std::error::Error + Send + Sync>> {
53
        let url = format!("{}/repos/{}/issues", self.api_base, repo);
54
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
54
        let repo_path = if repo.starts_with("repos/") {
55
            repo.to_string()
56
        } else {
57
            format!("repos/{}", repo)
58
        };
59
        let url = format!("{}/{}", self.api_base, repo_path);
60
        let url_issues = format!("{}/issues", url);
61
        let resp = self.http.get(&url_issues).headers(self.headers()).send().await?;
55 62
56 63
        if resp.status().is_success() {
57 64
            let body: serde_json::Value = resp.json().await?;

@@ -87,7 +94,12 @@ impl TrackerClient {

87 94
    }
88 95
89 96
    pub async fn get_issue(&self, repo: &str, number: u64) -> Result<Option<Issue>, Box<dyn std::error::Error + Send + Sync>> {
90
        let url = format!("{}/repos/{}/issues/{}", self.api_base, repo, number);
97
        let repo_path = if repo.starts_with("repos/") {
98
            repo.to_string()
99
        } else {
100
            format!("repos/{}", repo)
101
        };
102
        let url = format!("{}/{}/issues/{}", self.api_base, repo_path, number);
91 103
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
92 104
93 105
        if resp.status().is_success() {

@@ -116,7 +128,12 @@ impl TrackerClient {

116 128
    }
117 129
118 130
    pub async fn create_issue(&self, repo: &str, title: &str, body: Option<&str>) -> Result<Option<Issue>, Box<dyn std::error::Error + Send + Sync>> {
119
        let url = format!("{}/repos/{}/issues", self.api_base, repo);
131
        let repo_path = if repo.starts_with("repos/") {
132
            repo.to_string()
133
        } else {
134
            format!("repos/{}", repo)
135
        };
136
        let url = format!("{}/{}/issues", self.api_base, repo_path);
120 137
        let mut payload = serde_json::json!({
121 138
            "title": title,
122 139
        });

@@ -143,7 +160,12 @@ impl TrackerClient {

143 160
    }
144 161
145 162
    pub async fn close_issue(&self, repo: &str, number: u64) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
146
        let url = format!("{}/repos/{}/issues/{}", self.api_base, repo, number);
163
        let repo_path = if repo.starts_with("repos/") {
164
            repo.to_string()
165
        } else {
166
            format!("repos/{}", repo)
167
        };
168
        let url = format!("{}/{}/issues/{}", self.api_base, repo_path, number);
147 169
        let resp = self.http.patch(&url).headers(self.headers()).json(&serde_json::json!({
148 170
            "state": "closed"
149 171
        })).send().await?;

@@ -151,7 +173,12 @@ impl TrackerClient {

151 173
    }
152 174
153 175
    pub async fn comment_issue(&self, repo: &str, number: u64, body: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
154
        let url = format!("{}/repos/{}/issues/{}/comments", self.api_base, repo, number);
176
        let repo_path = if repo.starts_with("repos/") {
177
            repo.to_string()
178
        } else {
179
            format!("repos/{}", repo)
180
        };
181
        let url = format!("{}/{}/issues/{}/comments", self.api_base, repo_path, number);
155 182
        let resp = self.http.post(&url).headers(self.headers()).json(&serde_json::json!({
156 183
            "body": body
157 184
        })).send().await?;

@@ -159,7 +186,12 @@ impl TrackerClient {

159 186
    }
160 187
161 188
    pub async fn list_projects(&self, repo: &str) -> Result<Vec<Project>, Box<dyn std::error::Error + Send + Sync>> {
162
        let url = format!("{}/repos/{}/projects", self.api_base, repo);
189
        let repo_path = if repo.starts_with("repos/") {
190
            repo.to_string()
191
        } else {
192
            format!("repos/{}", repo)
193
        };
194
        let url = format!("{}/{}/projects", self.api_base, repo_path);
163 195
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
164 196
165 197
        if resp.status().is_success() {

This page updates live while a promote is in flight · changelog