Port auth, device pairing, credential store and repo management to the Rust CLI

d9700f86d0cc · AtlantisPleb · · parent a5eaade71d30

Port auth, device pairing, credential store and repo management to the Rust CLI

Closes the reopened scope on #74 and #77. Four commands were printlns that
configured nothing and contacted nothing: `auth login`, `auth status`,
`auth setup-git`, and `repo view`. `repo import` and `repo delete` did not
exist. The credential helper answered every host, including github.com,
because it never read git's request.

auth (#74)

- `login` runs the real device flow: POST /api/v1/device/authorizations,
  open the browser, poll the token endpoint on the server's interval, and
  store what it issues. `--headless` and a non-tty session record the
  pending authorization 0600 under ~/.config/openagents and hand back a
  `--resume` command; `--resume` finishes it.
- `status` asks GET /api/v1/user and reports login, id, eligible
  namespaces, expiry, and git-helper state. A revoked token now reports as
  revoked and exits 2 instead of "Authenticated".
- `logout` wipes the plaintext with zeroize before it asks any store to
  delete its record, so nothing survives in this process when a store then
  refuses.
- `setup-git --local|--global` writes credential.<origin>.helper for real,
  behind a --yes confirmation for the global scope.
- Tokens live in a `Secret` that has no Display, redacts under Debug, and
  zeroizes on drop. The store keys on the endpoint, keeps the OS keychain
  primary with a readback check, and refuses when a store cannot be read
  rather than reporting "not signed in" — which would send the next command
  out unauthenticated to fail somewhere unrelated.
- The endpoint comes from --api-url / --profile / the environment instead
  of a hardcoded production origin, and admits plain HTTP only on loopback.

repo (#77)

- `view` reads the API and prints visibility, default branch, and
  provisioning state, matching the TypeScript CLI field for field. With no
  argument it infers the repository from the remote whose URL is on this
  origin; the remote's name never decides it.
- `import` and `delete` added, with the same eligibility check and --yes
  confirmation the TypeScript commands require.
- `create` takes --private, --description, --default-branch, and
  --wait-timeout, and polls provisioning to ready.
- `clone` validates that the clone URL the API returned is on the selected
  origin before handing it to git, and pins this CLI as the only credential
  helper for that origin on the command line.
- The credential helper parses git's request and answers only the selected
  scheme and authority. No credential is a silence, never an invented
  password.

Two things the run itself found. The helper named a bare `oa`, which the
shell resolved against PATH to an older install that does not understand
--api-url, so a private clone fell through to a password prompt; it now
names the running binary. And the file fallback was going to write
~/.config/openagents/credentials.json, which is already the agent-key
store — that would have destroyed an unrelated set of keys.

Tests: 131 pass, 0 fail. The two stale assertions in cli_test.rs are
replaced. One read `default_profile.is_some()`, true because `load`
synthesizes a default when the file is absent, so it would have passed
against a store that could neither read nor write. The other checked that
the helper named a username, which the any-host helper satisfied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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 Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • modified crates/openagents-cli/src/auth.rs
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/repo.rs
  • added crates/openagents-cli/tests/auth_repo_test.rs
  • modified crates/openagents-cli/tests/cli_test.rs

Diff

7 files changed, +3362 -233

Cargo.lock modified +1

@@ -1590,6 +1590,7 @@ dependencies = [

1590 1590
 "tracing-subscriber",
1591 1591
 "unicode-segmentation",
1592 1592
 "unicode-width 0.2.0",
1593
 "zeroize",
1593 1594
]
1594 1595
1595 1596
[[package]]
crates/openagents-cli/Cargo.toml modified +1

@@ -35,6 +35,7 @@ bech32 = "0.11"

35 35
ripemd = "0.1"
36 36
bs58 = { version = "0.5", features = ["check"] }
37 37
regex = "1"
38
zeroize = "1"
38 39
39 40
[dev-dependencies]
40 41
tempfile = "3"
crates/openagents-cli/src/auth.rs modified +1115 -92

@@ -1,16 +1,359 @@

1
//! Authentication, credential store, OS keychain / secret-tool adapter, and persistent state
1
//! Authentication: endpoint resolution, secret handling, the credential store,
2
//! the browser device-authorization flow, and the pending-authorization state.
3
//!
4
//! The rule this module is written around: a credential command that cannot
5
//! reach its data refuses. It never returns a plausible token, never reports an
6
//! account it did not read from the server, and never treats a store that
7
//! answered with an error as a store that answered "nothing is there". An
8
//! invented credential does not fail here — it fails much later, inside some
9
//! unrelated request, or worse, appears to succeed.
2 10
3 11
use serde::{Deserialize, Serialize};
4
use std::collections::HashMap;
12
use std::fmt;
5 13
use std::fs;
6
use std::path::PathBuf;
7
use std::process::Command;
14
use std::io::Write as _;
15
use std::path::{Path, PathBuf};
16
use std::process::{Command, Stdio};
17
use std::time::{Duration, SystemTime, UNIX_EPOCH};
18
use zeroize::Zeroize;
19
20
/// A refusal. Every variant carries the sentence the CLI prints after `oa: `.
21
#[derive(Debug)]
22
pub struct AuthError(pub String);
23
24
impl AuthError {
25
    pub fn new(message: impl Into<String>) -> Self {
26
        Self(message.into())
27
    }
28
}
29
30
impl fmt::Display for AuthError {
31
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32
        f.write_str(&self.0)
33
    }
34
}
35
36
impl std::error::Error for AuthError {}
37
38
// ---------------------------------------------------------------------------
39
// secrets
40
// ---------------------------------------------------------------------------
41
42
/// A token held in memory.
43
///
44
/// Two properties matter and both are enforced here rather than at every call
45
/// site: the value is wiped when it is dropped, and neither `Debug` nor
46
/// `Display` can leak it into a log line, a panic message, or a `{:?}` in some
47
/// future edit. There is deliberately no `Display`.
48
#[derive(Clone)]
49
pub struct Secret(String);
50
51
impl Secret {
52
    pub fn new(value: impl Into<String>) -> Self {
53
        Self(value.into())
54
    }
55
56
    /// The only way to read the value. Named so that every use is visible in a
57
    /// grep for `expose`.
58
    pub fn expose(&self) -> &str {
59
        &self.0
60
    }
61
62
    pub fn is_empty(&self) -> bool {
63
        self.0.is_empty()
64
    }
65
66
    /// Wipe the buffer now rather than at drop.
67
    ///
68
    /// `oa auth logout` calls this before it asks the OS store to delete the
69
    /// record, so the plaintext is gone from this process even if the deletion
70
    /// itself fails and the command exits by the error path.
71
    pub fn zeroize_now(&mut self) {
72
        self.0.zeroize();
73
    }
74
75
    /// The prefix a person can compare against without the value leaving the
76
    /// machine. Never widen this.
77
    pub fn fingerprint(&self) -> String {
78
        let head: String = self.0.chars().take(11).collect();
79
        format!("{head}…")
80
    }
81
}
82
83
impl Drop for Secret {
84
    fn drop(&mut self) {
85
        self.0.zeroize();
86
    }
87
}
88
89
impl fmt::Debug for Secret {
90
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91
        f.write_str("Secret(<redacted>)")
92
    }
93
}
94
95
// ---------------------------------------------------------------------------
96
// endpoint
97
// ---------------------------------------------------------------------------
98
99
pub const PRODUCTION_ORIGIN: &str = "https://openagents.com";
100
pub const STAGING_ORIGIN: &str = "https://staging.openagents.com";
101
pub const LOCAL_ORIGIN: &str = "http://localhost:4000";
102
103
/// The API this invocation talks to, and the name for it.
104
#[derive(Debug, Clone, PartialEq, Eq)]
105
pub struct Endpoint {
106
    pub origin: String,
107
    pub profile: String,
108
}
109
110
fn loopback_hostname(hostname: &str) -> bool {
111
    let normalized = hostname.to_ascii_lowercase();
112
    normalized == "localhost"
113
        || normalized.ends_with(".localhost")
114
        || normalized.starts_with("127.")
115
        || normalized == "::1"
116
        || normalized == "[::1]"
117
        || normalized == "host.docker.internal"
118
}
119
120
/// Reduce a URL to a bare origin, refusing anything that is not one.
121
///
122
/// Credentials are stored per origin, so a URL carrying a path, a query, or an
123
/// embedded username would silently key the store under something other than
124
/// the authority the token is good for.
125
pub fn normalize_api_origin(input: &str) -> Result<String, AuthError> {
126
    let value = input.trim();
127
    if value.is_empty() {
128
        return Err(AuthError::new("the API URL cannot be empty"));
129
    }
130
    let url = reqwest::Url::parse(value)
131
        .map_err(|_| AuthError::new(format!("invalid API URL: {value}")))?;
132
    if !url.username().is_empty() || url.password().is_some() {
133
        return Err(AuthError::new("the API URL cannot contain credentials"));
134
    }
135
    if url.path() != "/" && !url.path().is_empty() {
136
        return Err(AuthError::new(
137
            "the API URL must be an origin without a path, query, or fragment",
138
        ));
139
    }
140
    if url.query().is_some() || url.fragment().is_some() {
141
        return Err(AuthError::new(
142
            "the API URL must be an origin without a path, query, or fragment",
143
        ));
144
    }
145
    let host = url
146
        .host_str()
147
        .ok_or_else(|| AuthError::new(format!("invalid API URL: {value}")))?;
148
    match url.scheme() {
149
        "https" => {}
150
        "http" if loopback_hostname(host) => {}
151
        _ => {
152
            return Err(AuthError::new(
153
                "API URLs must use HTTPS. HTTP is allowed only for loopback development",
154
            ))
155
        }
156
    }
157
    Ok(match url.port() {
158
        Some(port) => format!("{}://{}:{}", url.scheme(), host, port),
159
        None => format!("{}://{}", url.scheme(), host),
160
    })
161
}
162
163
fn profile_origin(profile: &str) -> Option<&'static str> {
164
    match profile {
165
        "production" => Some(PRODUCTION_ORIGIN),
166
        "staging" => Some(STAGING_ORIGIN),
167
        "local" => Some(LOCAL_ORIGIN),
168
        _ => None,
169
    }
170
}
171
172
fn profile_for_origin(origin: &str) -> String {
173
    match origin {
174
        PRODUCTION_ORIGIN => "production".to_string(),
175
        STAGING_ORIGIN => "staging".to_string(),
176
        LOCAL_ORIGIN => "local".to_string(),
177
        _ => "custom".to_string(),
178
    }
179
}
180
181
/// Resolve the endpoint from the flags and the environment.
182
///
183
/// Precedence: `--api-url`, then `--profile`, then `OPENAGENTS_API_URL`, then
184
/// `OPENAGENTS_PROFILE`, then production.
185
pub fn resolve_endpoint(
186
    api_url: Option<&str>,
187
    profile: Option<&str>,
188
) -> Result<Endpoint, AuthError> {
189
    if api_url.is_some() && profile.is_some() {
190
        return Err(AuthError::new(
191
            "use either --api-url or --profile, not both",
192
        ));
193
    }
194
    if let Some(url) = api_url {
195
        let origin = normalize_api_origin(url)?;
196
        let name = profile_for_origin(&origin);
197
        return Ok(Endpoint {
198
            origin,
199
            profile: name,
200
        });
201
    }
202
    if let Some(name) = profile {
203
        let origin = profile_origin(name).ok_or_else(|| {
204
            AuthError::new(format!(
205
                "unknown profile {name}. Use production, staging, or local"
206
            ))
207
        })?;
208
        return Ok(Endpoint {
209
            origin: origin.to_string(),
210
            profile: name.to_string(),
211
        });
212
    }
213
    if let Ok(url) = std::env::var("OPENAGENTS_API_URL") {
214
        if !url.trim().is_empty() {
215
            let origin = normalize_api_origin(&url)?;
216
            let name = profile_for_origin(&origin);
217
            return Ok(Endpoint {
218
                origin,
219
                profile: name,
220
            });
221
        }
222
    }
223
    if let Ok(name) = std::env::var("OPENAGENTS_PROFILE") {
224
        if !name.trim().is_empty() {
225
            let name = name.trim();
226
            let origin = profile_origin(name).ok_or_else(|| {
227
                AuthError::new(format!(
228
                    "unknown OPENAGENTS_PROFILE {name}. Use production, staging, or local"
229
                ))
230
            })?;
231
            return Ok(Endpoint {
232
                origin: origin.to_string(),
233
                profile: name.to_string(),
234
            });
235
        }
236
    }
237
    Ok(Endpoint {
238
        origin: PRODUCTION_ORIGIN.to_string(),
239
        profile: "production".to_string(),
240
    })
241
}
242
243
/// The `oa auth login` a person pointed at this endpoint has to type.
244
///
245
/// A hint that drops the endpoint selection sends the reader around a loop that
246
/// never signs them in: the login would store a token for production while the
247
/// session keeps reading the one for staging.
248
pub fn login_command_for(endpoint: &Endpoint) -> String {
249
    match endpoint.profile.as_str() {
250
        "production" => "oa auth login".to_string(),
251
        "custom" => format!("oa --api-url {} auth login", endpoint.origin),
252
        other => format!("oa --profile {other} auth login"),
253
    }
254
}
255
256
pub fn resume_command_for(endpoint: &Endpoint) -> String {
257
    match endpoint.profile.as_str() {
258
        "production" => "oa auth login --resume".to_string(),
259
        "custom" => format!("oa --api-url {} auth login --resume", endpoint.origin),
260
        other => format!("oa --profile {other} auth login --resume"),
261
    }
262
}
263
264
// ---------------------------------------------------------------------------
265
// on-disk paths
266
// ---------------------------------------------------------------------------
267
268
pub fn home_directory() -> PathBuf {
269
    PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
270
}
271
272
/// `~/.config/openagents`, the directory the TypeScript CLI already uses. Both
273
/// binaries read each other's state, so a person can switch between them
274
/// without signing in twice.
275
pub fn config_directory() -> PathBuf {
276
    home_directory().join(".config").join("openagents")
277
}
278
279
/// The file adapter's path.
280
///
281
/// Deliberately *not* `credentials.json` in that directory: that name is
282
/// already taken by the agent-key store (`{"agents": …, "default": …}`), and
283
/// writing this store's shape over it would destroy an unrelated set of keys.
284
/// The OS keychain remains the primary store; this file is what a machine
285
/// without one falls back to.
286
pub fn credentials_path() -> PathBuf {
287
    config_directory().join("cli-credentials.json")
288
}
289
290
pub fn device_authorizations_path() -> PathBuf {
291
    config_directory().join("device-authorizations.json")
292
}
293
294
/// Create a directory 0700, or fail saying which one.
295
fn ensure_private_directory(directory: &Path) -> Result<(), AuthError> {
296
    fs::create_dir_all(directory).map_err(|error| {
297
        AuthError::new(format!("could not create {}: {error}", directory.display()))
298
    })?;
299
    #[cfg(unix)]
300
    {
301
        use std::os::unix::fs::PermissionsExt;
302
        fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).map_err(|error| {
303
            AuthError::new(format!(
304
                "could not restrict {} to 0700: {error}",
305
                directory.display()
306
            ))
307
        })?;
308
    }
309
    Ok(())
310
}
311
312
/// Write a file 0600 through a temporary file in the same directory.
313
fn write_private_file(path: &Path, contents: &str) -> Result<(), AuthError> {
314
    let parent = path
315
        .parent()
316
        .ok_or_else(|| AuthError::new(format!("{} has no parent directory", path.display())))?;
317
    ensure_private_directory(parent)?;
318
    let temporary = path.with_extension("tmp");
319
    {
320
        let mut options = fs::OpenOptions::new();
321
        options.write(true).create(true).truncate(true);
322
        #[cfg(unix)]
323
        {
324
            use std::os::unix::fs::OpenOptionsExt;
325
            options.mode(0o600);
326
        }
327
        let mut file = options.open(&temporary).map_err(|error| {
328
            AuthError::new(format!("could not write {}: {error}", temporary.display()))
329
        })?;
330
        file.write_all(contents.as_bytes()).map_err(|error| {
331
            AuthError::new(format!("could not write {}: {error}", temporary.display()))
332
        })?;
333
    }
334
    #[cfg(unix)]
335
    {
336
        use std::os::unix::fs::PermissionsExt;
337
        fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)).map_err(|error| {
338
            AuthError::new(format!(
339
                "could not restrict {} to 0600: {error}",
340
                temporary.display()
341
            ))
342
        })?;
343
    }
344
    fs::rename(&temporary, path)
345
        .map_err(|error| AuthError::new(format!("could not write {}: {error}", path.display())))
346
}
347
348
// ---------------------------------------------------------------------------
349
// legacy profile configuration
350
// ---------------------------------------------------------------------------
8 351
9 352
#[derive(Debug, Clone, Serialize, Deserialize)]
10 353
pub struct AuthConfig {
11 354
    pub default_profile: Option<String>,
12 355
    #[serde(default)]
13
    pub profiles: HashMap<String, ProfileConfig>,
356
    pub profiles: std::collections::HashMap<String, ProfileConfig>,
14 357
}
15 358
16 359
#[derive(Debug, Clone, Serialize, Deserialize, Default)]

@@ -20,147 +363,827 @@ pub struct ProfileConfig {

20 363
    pub identity_name: Option<String>,
21 364
}
22 365
366
#[derive(Debug, Clone, Serialize, Deserialize)]
367
struct CredentialFile {
368
    version: u8,
369
    #[serde(default)]
370
    tokens: std::collections::BTreeMap<String, String>,
371
}
372
373
// ---------------------------------------------------------------------------
374
// credential store
375
// ---------------------------------------------------------------------------
376
377
/// Where a token was read from. `oa auth status` reports this, because "the
378
/// environment overrides the store" is the single most common reason a person
379
/// is signed in as somebody they did not expect.
380
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381
pub enum TokenSource {
382
    Environment,
383
    Store,
384
    File,
385
    LegacyConfig,
386
}
387
388
impl TokenSource {
389
    pub fn label(self) -> &'static str {
390
        match self {
391
            TokenSource::Environment => "environment",
392
            TokenSource::Store => "store",
393
            TokenSource::File => "file",
394
            TokenSource::LegacyConfig => "legacy config",
395
        }
396
    }
397
}
398
399
pub struct StoredToken {
400
    pub token: Secret,
401
    pub source: TokenSource,
402
}
403
404
impl fmt::Debug for StoredToken {
405
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406
        f.debug_struct("StoredToken")
407
            .field("source", &self.source)
408
            .finish_non_exhaustive()
409
    }
410
}
411
412
/// The service name the OS credential store files the record under. The
413
/// TypeScript CLI uses the same one.
414
const KEYCHAIN_SERVICE: &str = "openagents-cli";
415
416
/// Longest token the store will hand back. A record longer than this is not a
417
/// token the API issues, so it is a corrupt or hostile record, not a credential.
418
const MAX_TOKEN_LENGTH: usize = 160;
419
420
fn admitted_token(value: &str) -> bool {
421
    (value.starts_with("oa_pat_") || value.starts_with("smct_")) && value.len() < MAX_TOKEN_LENGTH
422
}
423
23 424
pub struct CredentialStore {
24
    config_path: PathBuf,
425
    origin: String,
426
    credentials_path: PathBuf,
427
    legacy_config_path: PathBuf,
428
    use_os_store: bool,
25 429
}
26 430
27 431
impl CredentialStore {
28 432
    pub fn default_path() -> PathBuf {
29
        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
30
        PathBuf::from(home).join(".openagents").join("config.json")
433
        home_directory().join(".openagents").join("config.json")
31 434
    }
32 435
436
    /// The store for production, reading the legacy profile file at `path`.
437
    /// Kept because the rest of the CLI constructs the store this way.
33 438
    pub fn new(path: Option<PathBuf>) -> Self {
34 439
        Self {
35
            config_path: path.unwrap_or_else(Self::default_path),
440
            origin: PRODUCTION_ORIGIN.to_string(),
441
            credentials_path: credentials_path(),
442
            legacy_config_path: path.unwrap_or_else(Self::default_path),
443
            use_os_store: true,
444
        }
445
    }
446
447
    pub fn for_origin(origin: &str) -> Self {
448
        Self {
449
            origin: origin.to_string(),
450
            credentials_path: credentials_path(),
451
            legacy_config_path: Self::default_path(),
452
            use_os_store: true,
453
        }
454
    }
455
456
    /// A store confined to a directory, with the OS keychain switched off.
457
    /// Tests use it so they exercise the real read, write, and delete paths
458
    /// without touching the developer's own credentials.
459
    pub fn isolated(origin: &str, directory: &Path) -> Self {
460
        Self {
461
            origin: origin.to_string(),
462
            credentials_path: directory.join("credentials.json"),
463
            legacy_config_path: directory.join("config.json"),
464
            use_os_store: false,
36 465
        }
37 466
    }
38 467
468
    pub fn origin(&self) -> &str {
469
        &self.origin
470
    }
471
472
    // -- legacy profile file -------------------------------------------------
473
39 474
    pub fn load(&self) -> Result<AuthConfig, Box<dyn std::error::Error>> {
40
        if !self.config_path.exists() {
475
        if !self.legacy_config_path.exists() {
41 476
            return Ok(AuthConfig {
42 477
                default_profile: Some("default".to_string()),
43
                profiles: HashMap::new(),
478
                profiles: std::collections::HashMap::new(),
44 479
            });
45 480
        }
46
        let data = fs::read_to_string(&self.config_path)?;
47
        let config: AuthConfig = serde_json::from_str(&data)?;
48
        Ok(config)
481
        let data = fs::read_to_string(&self.legacy_config_path)?;
482
        Ok(serde_json::from_str(&data)?)
49 483
    }
50 484
51 485
    pub fn save(&self, config: &AuthConfig) -> Result<(), Box<dyn std::error::Error>> {
52
        if let Some(parent) = self.config_path.parent() {
53
            fs::create_dir_all(parent)?;
54
        }
55
        let data = serde_json::to_string_pretty(config)?;
56
        fs::write(&self.config_path, data)?;
486
        write_private_file(
487
            &self.legacy_config_path,
488
            &serde_json::to_string_pretty(config)?,
489
        )?;
57 490
        Ok(())
58 491
    }
59 492
60
    pub fn get_token(&self) -> Option<String> {
61
        if let Ok(env_token) = std::env::var("OPENAGENTS_TOKEN") {
62
            if !env_token.trim().is_empty() {
63
                return Some(env_token.trim().to_string());
493
    // -- credentials.json ----------------------------------------------------
494
495
    fn load_credential_file(&self) -> Result<CredentialFile, AuthError> {
496
        if !self.credentials_path.exists() {
497
            return Ok(CredentialFile {
498
                version: 1,
499
                tokens: Default::default(),
500
            });
501
        }
502
        let text = fs::read_to_string(&self.credentials_path).map_err(|error| {
503
            AuthError::new(format!(
504
                "could not read {}: {error}",
505
                self.credentials_path.display()
506
            ))
507
        })?;
508
        serde_json::from_str(&text).map_err(|error| {
509
            AuthError::new(format!(
510
                "could not decode {}: {error}",
511
                self.credentials_path.display()
512
            ))
513
        })
514
    }
515
516
    fn save_credential_file(&self, file: &CredentialFile) -> Result<(), AuthError> {
517
        if file.tokens.is_empty() {
518
            if self.credentials_path.exists() {
519
                fs::remove_file(&self.credentials_path).map_err(|error| {
520
                    AuthError::new(format!(
521
                        "could not remove {}: {error}",
522
                        self.credentials_path.display()
523
                    ))
524
                })?;
64 525
            }
526
            return Ok(());
65 527
        }
528
        let encoded = serde_json::to_string(file)
529
            .map_err(|error| AuthError::new(format!("could not encode credentials: {error}")))?;
530
        write_private_file(&self.credentials_path, &encoded)
531
    }
66 532
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);
533
    // -- OS credential store -------------------------------------------------
534
535
    /// Read the OS store.
536
    ///
537
    /// `Ok(None)` means the store answered and holds nothing. An error means
538
    /// the store could not be consulted, and that is never reported as "not
539
    /// signed in": the caller would then send an unauthenticated request that
540
    /// fails somewhere far away from the actual problem.
541
    fn os_get(&self) -> Result<Option<Secret>, AuthError> {
542
        if !self.use_os_store {
543
            return Ok(None);
544
        }
545
        let output = match os_store_command_get(&self.origin) {
546
            Some(mut command) => match command.output() {
547
                Ok(output) => output,
548
                // No `security` / `secret-tool` on this machine. That is not a
549
                // failure to read: this platform simply has no OS store, and
550
                // the file adapter below is the whole store.
551
                Err(_) => return Ok(None),
552
            },
553
            None => return Ok(None),
554
        };
555
        if !output.status.success() {
556
            return Ok(None);
557
        }
558
        let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
559
        if value.is_empty() {
560
            return Ok(None);
561
        }
562
        if !admitted_token(&value) {
563
            return Err(AuthError::new(format!(
564
                "the OS credential store holds a record for {} that is not an OpenAgents token. \
565
                 Run oa auth logout, then oa auth login",
566
                self.origin
567
            )));
568
        }
569
        Ok(Some(Secret::new(value)))
570
    }
571
572
    fn os_set(&self, token: &Secret) -> Result<bool, AuthError> {
573
        if !self.use_os_store {
574
            return Ok(false);
575
        }
576
        let Some((mut command, stdin_input)) = os_store_command_set(&self.origin, token) else {
577
            return Ok(false);
578
        };
579
        let output = match stdin_input {
580
            None => match command.output() {
581
                Ok(output) => output,
582
                Err(_) => return Ok(false),
583
            },
584
            Some(input) => {
585
                command.stdin(Stdio::piped()).stdout(Stdio::piped());
586
                let mut child = match command.spawn() {
587
                    Ok(child) => child,
588
                    Err(_) => return Ok(false),
589
                };
590
                if let Some(mut pipe) = child.stdin.take() {
591
                    let _ = pipe.write_all(input.expose().as_bytes());
592
                    let _ = pipe.write_all(b"\n");
72 593
                }
594
                child.wait_with_output().map_err(|error| {
595
                    AuthError::new(format!("the OS credential store failed: {error}"))
596
                })?
73 597
            }
598
        };
599
        if !output.status.success() {
600
            return Err(AuthError::new(format!(
601
                "the OS credential store refused to store a token for {} (exit {})",
602
                self.origin,
603
                output.status.code().unwrap_or(-1)
604
            )));
605
        }
606
        // Read back. A store that reported success but did not keep the value
607
        // would leave the next command sending the previous token.
608
        match self.os_get()? {
609
            Some(stored) if stored.expose() == token.expose() => Ok(true),
610
            _ => Err(AuthError::new(
611
                "the OS credential store did not return the token that was just written",
612
            )),
74 613
        }
614
    }
75 615
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);
616
    fn os_remove(&self) {
617
        if !self.use_os_store {
618
            return;
619
        }
620
        if let Some(mut command) = os_store_command_remove(&self.origin) {
621
            let _ = command.output();
622
        }
623
    }
624
625
    // -- public API ----------------------------------------------------------
626
627
    /// Find the token for this endpoint, refusing when a store could not be read.
628
    pub fn find_token(&self) -> Result<Option<StoredToken>, AuthError> {
629
        if let Ok(value) = std::env::var("OPENAGENTS_TOKEN") {
630
            let trimmed = value.trim();
631
            if !trimmed.is_empty() {
632
                return Ok(Some(StoredToken {
633
                    token: Secret::new(trimmed),
634
                    source: TokenSource::Environment,
635
                }));
636
            }
637
        }
638
        if let Some(token) = self.os_get()? {
639
            return Ok(Some(StoredToken {
640
                token,
641
                source: TokenSource::Store,
642
            }));
643
        }
644
        let file = self.load_credential_file()?;
645
        if let Some(value) = file.tokens.get(&self.origin) {
646
            if !value.trim().is_empty() {
647
                return Ok(Some(StoredToken {
648
                    token: Secret::new(value.trim()),
649
                    source: TokenSource::File,
650
                }));
651
            }
652
        }
653
        // The legacy profile file predates per-endpoint keying. Its token is
654
        // admitted only when the profile names this origin, or names none and
655
        // this origin is production — the assumption the old file was written
656
        // under. Reading it unconditionally would hand a production token to a
657
        // session pointed at staging or at a developer's own server.
658
        if let Ok(config) = self.load() {
659
            let key = config
660
                .default_profile
661
                .clone()
662
                .unwrap_or_else(|| "default".to_string());
663
            if let Some(profile) = config.profiles.get(&key) {
664
                let profile_origin = profile
665
                    .api_url
666
                    .as_deref()
667
                    .map(|url| normalize_api_origin(url).unwrap_or_else(|_| url.to_string()));
668
                let admitted = match profile_origin {
669
                    Some(origin) => origin == self.origin,
670
                    None => self.origin == PRODUCTION_ORIGIN,
671
                };
672
                if admitted {
673
                    if let Some(value) = profile.token.clone() {
674
                        if !value.trim().is_empty() {
675
                            return Ok(Some(StoredToken {
676
                                token: Secret::new(value.trim()),
677
                                source: TokenSource::LegacyConfig,
678
                            }));
88 679
                        }
89 680
                    }
90 681
                }
91 682
            }
683
        }
684
        Ok(None)
685
    }
92 686
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
            }
687
    /// The lenient read the rest of the CLI uses when it wants a bearer token
688
    /// for an unrelated command. Auth and repository commands call
689
    /// [`CredentialStore::find_token`] instead, so a store that cannot be read
690
    /// is refused rather than reported as "not signed in".
691
    pub fn get_token(&self) -> Option<String> {
692
        self.find_token()
693
            .ok()
694
            .flatten()
695
            .map(|stored| stored.token.expose().to_string())
696
    }
697
698
    /// Store a token for this endpoint, and say where it landed.
699
    pub fn store(&self, token: &Secret) -> Result<TokenSource, AuthError> {
700
        if token.is_empty() {
701
            return Err(AuthError::new("refusing to store an empty token"));
702
        }
703
        if self.os_set(token)? {
704
            return Ok(TokenSource::Store);
104 705
        }
706
        let mut file = self.load_credential_file()?;
707
        file.tokens
708
            .insert(self.origin.clone(), token.expose().to_string());
709
        self.save_credential_file(&file)?;
710
        Ok(TokenSource::File)
711
    }
105 712
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
                    }
713
    pub fn set_token(&self, token: &str) -> Result<(), Box<dyn std::error::Error>> {
714
        self.store(&Secret::new(token))?;
715
        Ok(())
716
    }
717
718
    /// Remove the token for this endpoint.
719
    ///
720
    /// The in-memory copy is wiped before any deletion is attempted, so the
721
    /// plaintext is gone from this process even if a store then refuses to
722
    /// delete its record and the command exits by the error path.
723
    pub fn remove(&self) -> Result<bool, AuthError> {
724
        let held = self.find_token()?;
725
        let had_token = held.is_some();
726
        if let Some(mut stored) = held {
727
            stored.token.zeroize_now();
728
            debug_assert!(stored.token.is_empty());
729
            drop(stored);
730
        }
731
732
        self.os_remove();
733
734
        let mut file = self.load_credential_file()?;
735
        if file.tokens.remove(&self.origin).is_some() {
736
            self.save_credential_file(&file)?;
737
        }
738
739
        if let Ok(mut config) = self.load() {
740
            let key = config
741
                .default_profile
742
                .clone()
743
                .unwrap_or_else(|| "default".to_string());
744
            if let Some(profile) = config.profiles.get_mut(&key) {
745
                if let Some(mut token) = profile.token.take() {
746
                    token.zeroize();
118 747
                }
748
                let _ = self.save(&config);
119 749
            }
120 750
        }
751
        Ok(had_token)
752
    }
753
754
    pub fn clear_token(&self) -> Result<(), Box<dyn std::error::Error>> {
755
        self.remove()?;
756
        Ok(())
757
    }
758
}
121 759
760
fn os_store_command_get(origin: &str) -> Option<Command> {
761
    if cfg!(target_os = "macos") {
762
        let mut command = Command::new("security");
763
        command.args([
764
            "find-generic-password",
765
            "-a",
766
            origin,
767
            "-s",
768
            KEYCHAIN_SERVICE,
769
            "-w",
770
        ]);
771
        command.stderr(Stdio::null());
772
        Some(command)
773
    } else if cfg!(target_os = "linux") {
774
        let mut command = Command::new("secret-tool");
775
        command.args(["lookup", "service", KEYCHAIN_SERVICE, "origin", origin]);
776
        command.stderr(Stdio::null());
777
        Some(command)
778
    } else {
122 779
        None
123 780
    }
781
}
124 782
125
    pub fn set_token(&self, token: &str) -> Result<(), Box<dyn std::error::Error>> {
126
        let mut config = self.load().unwrap_or_else(|_| AuthConfig {
127
            default_profile: Some("default".to_string()),
128
            profiles: HashMap::new(),
129
        });
130
        let profile_key = config.default_profile.clone().unwrap_or_else(|| "default".to_string());
131
        let profile = config.profiles.entry(profile_key).or_insert_with(Default::default);
132
        profile.token = Some(token.to_string());
133
        self.save(&config)?;
783
fn os_store_command_set(origin: &str, token: &Secret) -> Option<(Command, Option<Secret>)> {
784
    if cfg!(target_os = "macos") {
785
        let mut command = Command::new("security");
786
        command.args([
787
            "add-generic-password",
788
            "-U",
789
            "-a",
790
            origin,
791
            "-s",
792
            KEYCHAIN_SERVICE,
793
            "-w",
794
            token.expose(),
795
        ]);
796
        command.stderr(Stdio::null());
797
        Some((command, None))
798
    } else if cfg!(target_os = "linux") {
799
        let mut command = Command::new("secret-tool");
800
        command.args([
801
            "store",
802
            "--label=OpenAgents CLI",
803
            "service",
804
            KEYCHAIN_SERVICE,
805
            "origin",
806
            origin,
807
        ]);
808
        command.stderr(Stdio::null());
809
        Some((command, Some(token.clone())))
810
    } else {
811
        None
812
    }
813
}
134 814
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();
815
fn os_store_command_remove(origin: &str) -> Option<Command> {
816
    if cfg!(target_os = "macos") {
817
        let mut command = Command::new("security");
818
        command.args([
819
            "delete-generic-password",
820
            "-a",
821
            origin,
822
            "-s",
823
            KEYCHAIN_SERVICE,
824
        ]);
825
        command.stderr(Stdio::null());
826
        command.stdout(Stdio::null());
827
        Some(command)
828
    } else if cfg!(target_os = "linux") {
829
        let mut command = Command::new("secret-tool");
830
        command.args(["clear", "service", KEYCHAIN_SERVICE, "origin", origin]);
831
        command.stderr(Stdio::null());
832
        Some(command)
833
    } else {
834
        None
835
    }
836
}
837
838
// ---------------------------------------------------------------------------
839
// device authorization
840
// ---------------------------------------------------------------------------
841
842
#[derive(Debug, Clone, Serialize, Deserialize)]
843
pub struct DeviceAuthorization {
844
    pub device_code: String,
845
    pub user_code: String,
846
    pub verification_uri: String,
847
    pub verification_uri_complete: String,
848
    pub expires_in: i64,
849
    pub interval: u64,
850
}
851
852
/// One poll of the token endpoint.
853
#[derive(Debug)]
854
pub enum DevicePoll {
855
    Granted,
856
    Pending,
857
    SlowDown,
858
}
859
860
pub struct DeviceClient {
861
    origin: String,
862
    http: reqwest::Client,
863
}
864
865
impl DeviceClient {
866
    pub fn new(origin: &str) -> Self {
867
        Self {
868
            origin: origin.trim_end_matches('/').to_string(),
869
            http: reqwest::Client::builder()
870
                .timeout(Duration::from_secs(30))
871
                .build()
872
                .unwrap_or_default(),
140 873
        }
874
    }
141 875
142
        Ok(())
876
    /// Ask the server to open an authorization. The server decides the default
877
    /// scope set; naming scopes here names exactly what the approval page shows.
878
    pub async fn start(&self, scopes: &[String]) -> Result<DeviceAuthorization, AuthError> {
879
        let url = format!("{}/api/v1/device/authorizations", self.origin);
880
        let body = if scopes.is_empty() {
881
            serde_json::json!({})
882
        } else {
883
            serde_json::json!({ "scope": scopes.join(" ") })
884
        };
885
        let response = self
886
            .http
887
            .post(&url)
888
            .json(&body)
889
            .send()
890
            .await
891
            .map_err(|error| AuthError::new(format!("could not reach {}: {error}", self.origin)))?;
892
        let status = response.status();
893
        let value: serde_json::Value = response.json().await.map_err(|error| {
894
            AuthError::new(format!(
895
                "could not read the authorization response: {error}"
896
            ))
897
        })?;
898
        if status.as_u16() != 201 {
899
            return Err(AuthError::new(format!(
900
                "{} could not start CLI authorization ({}{})",
901
                self.origin,
902
                status.as_u16(),
903
                api_error_detail(&value)
904
            )));
905
        }
906
        serde_json::from_value(value).map_err(|error| {
907
            AuthError::new(format!(
908
                "the device authorization response did not match the API contract: {error}"
909
            ))
910
        })
143 911
    }
144 912
145
    pub fn clear_token(&self) -> Result<(), Box<dyn std::error::Error>> {
146
        let mut config = self.load().unwrap_or_else(|_| AuthConfig {
147
            default_profile: Some("default".to_string()),
148
            profiles: HashMap::new(),
149
        });
150
        if let Some(profile_key) = &config.default_profile {
151
            if let Some(profile) = config.profiles.get_mut(profile_key) {
152
                profile.token = None;
913
    /// One poll. Distinguishes "not yet approved" from "denied", because the
914
    /// first is a reason to wait and the second is a reason to stop.
915
    pub async fn poll(
916
        &self,
917
        device_code: &str,
918
        into: &mut Option<Secret>,
919
    ) -> Result<DevicePoll, AuthError> {
920
        let url = format!("{}/api/v1/device/authorizations/token", self.origin);
921
        let response = self
922
            .http
923
            .post(&url)
924
            .json(&serde_json::json!({ "device_code": device_code }))
925
            .send()
926
            .await
927
            .map_err(|error| AuthError::new(format!("could not reach {}: {error}", self.origin)))?;
928
        let status = response.status().as_u16();
929
        let value: serde_json::Value = response.json().await.map_err(|error| {
930
            AuthError::new(format!(
931
                "could not read the authorization response: {error}"
932
            ))
933
        })?;
934
        if status == 200 {
935
            let access = value
936
                .get("access_token")
937
                .and_then(|v| v.as_str())
938
                .ok_or_else(|| {
939
                    AuthError::new(
940
                        "the device token response did not match the API contract: no access_token",
941
                    )
942
                })?;
943
            *into = Some(Secret::new(access));
944
            return Ok(DevicePoll::Granted);
945
        }
946
        let code = value.get("code").and_then(|v| v.as_str()).unwrap_or("");
947
        match (status, code) {
948
            (428, "authorization_pending") => Ok(DevicePoll::Pending),
949
            (429, "slow_down") => Ok(DevicePoll::SlowDown),
950
            _ => Err(AuthError::new(format!(
951
                "CLI authorization was denied, expired, or already claimed ({status}{})",
952
                api_error_detail(&value)
953
            ))),
954
        }
955
    }
956
957
    /// Poll until the request is approved, denied, or expires.
958
    pub async fn wait(&self, authorization: &DeviceAuthorization) -> Result<Secret, AuthError> {
959
        let mut interval = authorization.interval.max(1);
960
        let deadline =
961
            SystemTime::now() + Duration::from_secs(authorization.expires_in.max(1) as u64);
962
        loop {
963
            let mut received: Option<Secret> = None;
964
            match self.poll(&authorization.device_code, &mut received).await? {
965
                DevicePoll::Granted => {
966
                    return received.ok_or_else(|| {
967
                        AuthError::new("the device token response carried no token")
968
                    })
969
                }
970
                DevicePoll::Pending => {}
971
                DevicePoll::SlowDown => interval += 5,
972
            }
973
            if SystemTime::now() >= deadline {
974
                return Err(AuthError::new(
975
                    "CLI authorization expired before it was approved",
976
                ));
153 977
            }
978
            tokio::time::sleep(Duration::from_secs(interval)).await;
154 979
        }
155
        self.save(&config)?;
980
    }
981
}
156 982
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();
983
/// The parenthetical the CLI appends to an API refusal: the server's own code
984
/// and request id when it sent them, and nothing invented when it did not.
985
pub fn api_error_detail(value: &serde_json::Value) -> String {
986
    let mut parts: Vec<String> = Vec::new();
987
    if let Some(code) = value.get("code").and_then(|v| v.as_str()) {
988
        parts.push(code.to_string());
989
    }
990
    if let Some(message) = value
991
        .get("message")
992
        .and_then(|v| v.as_str())
993
        .filter(|m| !m.is_empty())
994
    {
995
        parts.push(message.to_string());
996
    }
997
    if let Some(request_id) = value.get("request_id").and_then(|v| v.as_str()) {
998
        parts.push(format!("request {request_id}"));
999
    }
1000
    if parts.is_empty() {
1001
        String::new()
1002
    } else {
1003
        format!(": {}", parts.join("; "))
1004
    }
1005
}
1006
1007
// ---------------------------------------------------------------------------
1008
// pending device authorizations
1009
// ---------------------------------------------------------------------------
1010
1011
#[derive(Debug, Clone, Serialize, Deserialize)]
1012
pub struct PendingDeviceAuthorization {
1013
    pub origin: String,
1014
    pub device_code: String,
1015
    pub user_code: String,
1016
    pub verification_uri: String,
1017
    pub verification_uri_complete: String,
1018
    pub expires_at_ms: i64,
1019
    pub interval: u64,
1020
    #[serde(default, skip_serializing_if = "Option::is_none")]
1021
    pub kind: Option<String>,
1022
}
1023
1024
#[derive(Debug, Clone, Serialize, Deserialize)]
1025
struct PendingFile {
1026
    version: u8,
1027
    #[serde(default)]
1028
    authorizations: std::collections::BTreeMap<String, PendingDeviceAuthorization>,
1029
}
1030
1031
/// The half-finished login. `oa auth login --headless` writes one; `--resume`
1032
/// reads it. It carries no token, only the code the person is approving.
1033
pub struct PendingStore {
1034
    path: PathBuf,
1035
}
1036
1037
impl PendingStore {
1038
    pub fn new() -> Self {
1039
        Self {
1040
            path: device_authorizations_path(),
162 1041
        }
1042
    }
1043
1044
    pub fn at(path: PathBuf) -> Self {
1045
        Self { path }
1046
    }
163 1047
1048
    fn load(&self) -> Result<PendingFile, AuthError> {
1049
        if !self.path.exists() {
1050
            return Ok(PendingFile {
1051
                version: 1,
1052
                authorizations: Default::default(),
1053
            });
1054
        }
1055
        let text = fs::read_to_string(&self.path).map_err(|error| {
1056
            AuthError::new(format!("could not read {}: {error}", self.path.display()))
1057
        })?;
1058
        if text.len() > 65_536 {
1059
            return Err(AuthError::new(format!(
1060
                "{} is larger than a pending authorization file can be",
1061
                self.path.display()
1062
            )));
1063
        }
1064
        serde_json::from_str(&text).map_err(|error| {
1065
            AuthError::new(format!("could not decode {}: {error}", self.path.display()))
1066
        })
1067
    }
1068
1069
    fn save(&self, file: &PendingFile) -> Result<(), AuthError> {
1070
        if file.authorizations.is_empty() {
1071
            if self.path.exists() {
1072
                fs::remove_file(&self.path).map_err(|error| {
1073
                    AuthError::new(format!("could not remove {}: {error}", self.path.display()))
1074
                })?;
1075
            }
1076
            return Ok(());
1077
        }
1078
        let encoded = serde_json::to_string(file).map_err(|error| {
1079
            AuthError::new(format!("could not encode pending authorizations: {error}"))
1080
        })?;
1081
        write_private_file(&self.path, &encoded)
1082
    }
1083
1084
    pub fn get(&self, origin: &str) -> Result<Option<PendingDeviceAuthorization>, AuthError> {
1085
        Ok(self.load()?.authorizations.get(origin).cloned())
1086
    }
1087
1088
    pub fn set(&self, authorization: &PendingDeviceAuthorization) -> Result<(), AuthError> {
1089
        let mut file = self.load()?;
1090
        file.authorizations
1091
            .insert(authorization.origin.clone(), authorization.clone());
1092
        self.save(&file)
1093
    }
1094
1095
    pub fn remove(&self, origin: &str) -> Result<(), AuthError> {
1096
        let mut file = self.load()?;
1097
        if file.authorizations.remove(origin).is_some() {
1098
            self.save(&file)?;
1099
        }
164 1100
        Ok(())
165 1101
    }
166 1102
}
1103
1104
impl Default for PendingStore {
1105
    fn default() -> Self {
1106
        Self::new()
1107
    }
1108
}
1109
1110
pub fn now_ms() -> i64 {
1111
    SystemTime::now()
1112
        .duration_since(UNIX_EPOCH)
1113
        .map(|d| d.as_millis() as i64)
1114
        .unwrap_or(0)
1115
}
1116
1117
// ---------------------------------------------------------------------------
1118
// browser
1119
// ---------------------------------------------------------------------------
1120
1121
/// Try to open the approval page. The URL is printed either way, so a machine
1122
/// with no browser is not a machine that cannot sign in.
1123
pub fn open_browser(url: &str) -> bool {
1124
    let launcher: Option<(&str, Vec<&str>)> = if cfg!(target_os = "macos") {
1125
        Some(("open", vec![url]))
1126
    } else if cfg!(target_os = "linux") {
1127
        Some(("xdg-open", vec![url]))
1128
    } else if cfg!(target_os = "windows") {
1129
        Some(("cmd", vec!["/C", "start", "", url]))
1130
    } else {
1131
        None
1132
    };
1133
    match launcher {
1134
        Some((program, args)) => Command::new(program)
1135
            .args(args)
1136
            .stdout(Stdio::null())
1137
            .stderr(Stdio::null())
1138
            .status()
1139
            .map(|status| status.success())
1140
            .unwrap_or(false),
1141
        None => false,
1142
    }
1143
}
1144
1145
#[cfg(test)]
1146
mod tests {
1147
    use super::*;
1148
1149
    #[test]
1150
    fn secret_never_renders_its_value() {
1151
        let secret = Secret::new("oa_pat_supersecretvalue");
1152
        let rendered = format!("{secret:?}");
1153
        // Asserting on the redaction marker alone would be satisfied by a
1154
        // prefix swap that leaves the value in the tail, so assert absence.
1155
        assert!(!rendered.contains("supersecretvalue"), "{rendered}");
1156
        assert!(!format!(
1157
            "{:?}",
1158
            StoredToken {
1159
                token: secret,
1160
                source: TokenSource::Store
1161
            }
1162
        )
1163
        .contains("supersecretvalue"));
1164
    }
1165
1166
    #[test]
1167
    fn zeroize_now_empties_the_buffer() {
1168
        let mut secret = Secret::new("oa_pat_supersecretvalue");
1169
        secret.zeroize_now();
1170
        assert!(secret.is_empty());
1171
        assert_eq!(secret.expose(), "");
1172
    }
1173
1174
    #[test]
1175
    fn origins_must_be_bare_and_https() {
1176
        assert_eq!(
1177
            normalize_api_origin("https://openagents.com/").unwrap(),
1178
            "https://openagents.com"
1179
        );
1180
        assert_eq!(
1181
            normalize_api_origin("http://localhost:4000").unwrap(),
1182
            "http://localhost:4000"
1183
        );
1184
        assert!(normalize_api_origin("http://example.com").is_err());
1185
        assert!(normalize_api_origin("https://user:pw@openagents.com").is_err());
1186
        assert!(normalize_api_origin("https://openagents.com/api/v1").is_err());
1187
        assert!(normalize_api_origin("").is_err());
1188
    }
1189
}
crates/openagents-cli/src/cli.rs modified +688 -72

@@ -11,6 +11,20 @@ pub struct Cli {

11 11
12 12
    #[arg(short, long, global = true, help = "Verbose logging output")]
13 13
    pub verbose: bool,
14
15
    #[arg(
16
        long,
17
        global = true,
18
        help = "API origin to talk to, such as https://openagents.com"
19
    )]
20
    pub api_url: Option<String>,
21
22
    #[arg(
23
        long,
24
        global = true,
25
        help = "Named API endpoint: production, staging, or local"
26
    )]
27
    pub profile: Option<String>,
14 28
}
15 29
16 30
#[derive(Subcommand, Debug)]

@@ -67,11 +81,42 @@ pub struct AuthArgs {

67 81
68 82
#[derive(Subcommand, Debug)]
69 83
pub enum AuthAction {
70
    Login,
84
    /// Authorize this CLI in your browser and store the resulting token
85
    Login {
86
        #[arg(
87
            long,
88
            help = "Read and store a token from standard input instead of opening a browser"
89
        )]
90
        token_stdin: bool,
91
        #[arg(
92
            long,
93
            help = "Print an authorization URL and code without waiting for approval"
94
        )]
95
        headless: bool,
96
        #[arg(long, help = "Complete the pending device authorization")]
97
        resume: bool,
98
        #[arg(
99
            long,
100
            help = "Request a scope for the new token; repeatable. Omit to take the server's default"
101
        )]
102
        scope: Vec<String>,
103
    },
104
    /// Read a token from standard input and store it for the selected API
71 105
    TokenStdin,
106
    /// Show authentication status for the selected API
72 107
    Status,
108
    /// Remove the stored token for the selected API
73 109
    Logout,
74
    SetupGit,
110
    /// Configure git to obtain OpenAgents credentials from this CLI
111
    SetupGit {
112
        #[arg(long, help = "Configure the current git repository")]
113
        local: bool,
114
        #[arg(long, help = "Configure your global git settings")]
115
        global: bool,
116
        #[arg(long, help = "Confirm a global git credential-helper change")]
117
        yes: bool,
118
    },
119
    /// Internal git credential-helper protocol endpoint
75 120
    GitCredential {
76 121
        #[arg(default_value = "get")]
77 122
        operation: String,

@@ -337,18 +382,93 @@ pub struct RepoArgs {

337 382
338 383
#[derive(Subcommand, Debug)]
339 384
pub enum RepoAction {
340
    List,
385
    /// List repositories available to you
386
    List {
387
        #[arg(long, help = "Filter by a GitHub-backed namespace")]
388
        namespace: Option<String>,
389
        #[arg(
390
            long,
391
            default_value_t = 30,
392
            help = "Return between 1 and 100 repositories"
393
        )]
394
        limit: u32,
395
        #[arg(long, help = "Continue from an opaque repository cursor")]
396
        after: Option<String>,
397
    },
398
    /// Show one repository, or infer it from this checkout's git remotes
341 399
    View {
342
        #[arg(help = "Repository slug")]
343
        slug: String,
400
        #[arg(help = "Repository in OWNER/REPO format")]
401
        repository: Option<String>,
402
        #[arg(
403
            short = 'R',
404
            long,
405
            help = "Select OWNER/REPO instead of inferring the remote"
406
        )]
407
        repo: Option<String>,
344 408
    },
409
    /// Create an empty OpenAgents repository
345 410
    Create {
346
        #[arg(long)]
411
        #[arg(help = "Repository name, or OWNER/NAME")]
347 412
        name: String,
413
        #[arg(long, help = "Set the repository description")]
414
        description: Option<String>,
415
        #[arg(long, help = "Create a public repository (the default)")]
416
        public: bool,
417
        #[arg(long, help = "Create a private repository")]
418
        private: bool,
419
        #[arg(long, default_value = "main", help = "Set the initial default branch")]
420
        default_branch: String,
421
        #[arg(
422
            long,
423
            default_value_t = 300,
424
            help = "Seconds to wait for durable provisioning (0 does not wait)"
425
        )]
426
        wait_timeout: u64,
427
    },
428
    /// Import a GitHub repository once
429
    Import {
430
        #[arg(help = "GitHub repository in OWNER/REPO format")]
431
        source: String,
432
        #[arg(long, help = "Override the destination repository name")]
433
        name: Option<String>,
434
        #[arg(long, help = "Import into an eligible GitHub organization namespace")]
435
        namespace: Option<String>,
436
        #[arg(long, help = "Import as a public repository")]
437
        public: bool,
438
        #[arg(long, help = "Import as a private repository")]
439
        private: bool,
440
        #[arg(
441
            long,
442
            default_value_t = 300,
443
            help = "Seconds to wait for the import (0 does not wait)"
444
        )]
445
        wait_timeout: u64,
348 446
    },
447
    /// Clone a repository with git
349 448
    Clone {
350
        #[arg(help = "Repository slug")]
351
        slug: String,
449
        #[arg(help = "Repository in OWNER/REPO format")]
450
        repository: Option<String>,
451
        #[arg(help = "Directory to clone into")]
452
        directory: Option<String>,
453
        #[arg(
454
            short = 'R',
455
            long,
456
            help = "Select OWNER/REPO instead of inferring the remote"
457
        )]
458
        repo: Option<String>,
459
    },
460
    /// Permanently delete a repository you own
461
    Delete {
462
        #[arg(help = "Repository in OWNER/REPO format")]
463
        repository: Option<String>,
464
        #[arg(
465
            short = 'R',
466
            long,
467
            help = "Select OWNER/REPO instead of inferring the remote"
468
        )]
469
        repo: Option<String>,
470
        #[arg(long, help = "Confirm permanent repository deletion")]
471
        yes: bool,
352 472
    },
353 473
}
354 474

@@ -622,64 +742,20 @@ pub enum TraceAction {

622 742
}
623 743
624 744
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
625
    let cred_store = crate::auth::CredentialStore::new(None);
745
    let endpoint =
746
        match crate::auth::resolve_endpoint(cli.api_url.as_deref(), cli.profile.as_deref()) {
747
            Ok(endpoint) => endpoint,
748
            Err(error) => fail(&error.to_string()),
749
        };
750
    let cred_store = crate::auth::CredentialStore::for_origin(&endpoint.origin);
626 751
    let token = cred_store.get_token();
627 752
628 753
    match cli.command {
629
        Commands::Auth(auth) => match auth.action {
630
            AuthAction::Login => {
631
                println!("Auth login initialized");
632
            }
633
            AuthAction::TokenStdin => {
634
                let mut buffer = String::new();
635
                std::io::stdin().read_line(&mut buffer)?;
636
                cred_store.set_token(buffer.trim())?;
637
                println!("Token saved successfully.");
638
            }
639
            AuthAction::Status => {
640
                if let Some(tok) = token {
641
                    println!("Authenticated (token present, prefix: {}...)", &tok[..tok.len().min(8)]);
642
                } else {
643
                    println!("Not authenticated. No token found in config or environment.");
644
                }
645
            }
646
            AuthAction::Logout => {
647
                cred_store.clear_token()?;
648
                println!("Logged out successfully.");
649
            }
650
            AuthAction::SetupGit => {
651
                println!("Configured git credentials helper for OpenAgents.");
652
            }
653
            AuthAction::GitCredential { operation } => {
654
                let output = crate::repo::handle_git_credential(&operation, "openagents.com", token.as_deref());
655
                print!("{}", output);
656
            }
657
        },
754
        Commands::Auth(auth) => run_auth(auth.action, &endpoint, &cred_store, cli.json).await,
658 755
        Commands::Identity(identity) => run_identity(identity.action, cli.json),
659 756
        Commands::Issue(issue) => run_issue(issue.action, token, cli.json).await,
660 757
        Commands::Project(project) => run_project(project.action, token, cli.json).await,
661
        Commands::Repo(repo) => {
662
            let repo_client = crate::repo::RepoClient::new("https://openagents.com/api/v1", token);
663
            match repo.action {
664
                RepoAction::List => {
665
                    let repos = repo_client.list_repos().await.map_err(|e| e.to_string())?;
666
                    for r in repos {
667
                        println!("{}\t(branch: {})", r.slug, r.default_branch);
668
                    }
669
                }
670
                RepoAction::View { slug } => println!("Viewing repository {}", slug),
671
                RepoAction::Create { name } => {
672
                    if repo_client.create_repo(&name, false).await.map_err(|e| e.to_string())? {
673
                        println!("Created repository {}", name);
674
                    }
675
                }
676
                RepoAction::Clone { slug } => {
677
                    if crate::repo::RepoClient::clone_repo(&slug, None).await.map_err(|e| e.to_string())? {
678
                        println!("Cloned repository {}", slug);
679
                    }
680
                }
681
            }
682
        }
758
        Commands::Repo(repo) => run_repo(repo.action, &endpoint, &cred_store, cli.json).await,
683 759
        Commands::Coder(coder) => {
684 760
            if coder.delegate {
685 761
                crate::delegate::run_delegation(coder, token).await?;

@@ -790,6 +866,558 @@ pub(crate) fn fail(message: &str) -> ! {

790 866
    std::process::exit(2)
791 867
}
792 868
869
// ---------------------------------------------------------------------------
870
// auth
871
// ---------------------------------------------------------------------------
872
873
use crate::auth::{
874
    CredentialStore, DeviceClient, Endpoint, PendingDeviceAuthorization, PendingStore,
875
    Secret,
876
};
877
878
/// Unwrap or refuse. Every auth, repository, tracker, box, and memory path
879
/// funnels through this, so a store that could not be read, or a server that
880
/// answered with anything other than success, ends the command instead of
881
/// continuing with a value nobody provided.
882
///
883
/// That is the whole difference between reporting what the server said and
884
/// printing an empty list that reads as "there is nothing".
885
fn or_fail<T, E: std::fmt::Display>(result: Result<T, E>) -> T {
886
    match result {
887
        Ok(value) => value,
888
        Err(error) => fail(&error.to_string()),
889
    }
890
}
891
892
/// Read a token from standard input, keeping it out of the process table and
893
/// the shell history.
894
fn read_token_from_stdin() -> Secret {
895
    use std::io::BufRead;
896
    let mut buffer = String::new();
897
    if std::io::stdin().lock().read_line(&mut buffer).is_err() {
898
        fail("could not read a token from standard input");
899
    }
900
    let trimmed = buffer.trim().to_string();
901
    if trimmed.is_empty() {
902
        fail("standard input carried no token");
903
    }
904
    Secret::new(trimmed)
905
}
906
907
async fn run_auth(action: AuthAction, endpoint: &Endpoint, store: &CredentialStore, json: bool) {
908
    match action {
909
        AuthAction::Login {
910
            token_stdin,
911
            headless,
912
            resume,
913
            scope,
914
        } => run_auth_login(endpoint, store, token_stdin, headless, resume, &scope, json).await,
915
        AuthAction::TokenStdin => {
916
            let token = read_token_from_stdin();
917
            let source = or_fail(store.store(&token));
918
            if json {
919
                print_json(&serde_json::json!({
920
                    "origin": endpoint.origin,
921
                    "stored": true,
922
                    "token_source": source.label(),
923
                }));
924
            } else {
925
                println!("Stored an OpenAgents token for {}.", endpoint.origin);
926
            }
927
        }
928
        AuthAction::Status => run_auth_status(endpoint, store, json).await,
929
        AuthAction::Logout => {
930
            let removed = or_fail(store.remove());
931
            if json {
932
                print_json(&serde_json::json!({
933
                    "origin": endpoint.origin,
934
                    "removed": removed,
935
                }));
936
            } else if removed {
937
                println!(
938
                    "Removed the stored OpenAgents token for {}.",
939
                    endpoint.origin
940
                );
941
            } else {
942
                println!("No OpenAgents token was stored for {}.", endpoint.origin);
943
            }
944
        }
945
        AuthAction::SetupGit { local, global, yes } => {
946
            if local == global {
947
                fail("choose exactly one of --local or --global");
948
            }
949
            if global && !yes {
950
                fail("global setup requires --yes confirmation");
951
            }
952
            let scope = if local { "local" } else { "global" };
953
            or_fail(crate::repo::configure_credential_helper(
954
                &endpoint.origin,
955
                scope,
956
                None,
957
            ));
958
            if json {
959
                print_json(&serde_json::json!({
960
                    "origin": endpoint.origin,
961
                    "scope": scope,
962
                    "configured": true,
963
                }));
964
            } else {
965
                println!(
966
                    "Configured the {scope} git credential helper for {}.",
967
                    endpoint.origin
968
                );
969
            }
970
        }
971
        AuthAction::GitCredential { operation } => {
972
            let input = or_fail(crate::repo::read_credential_stdin());
973
            let answer = or_fail(crate::repo::run_git_credential_helper(
974
                &endpoint.origin,
975
                &operation,
976
                &input,
977
                store,
978
            ));
979
            print!("{answer}");
980
            let _ = std::io::Write::flush(&mut std::io::stdout());
981
        }
982
    }
983
}
984
985
#[allow(clippy::too_many_arguments)]
986
async fn run_auth_login(
987
    endpoint: &Endpoint,
988
    store: &CredentialStore,
989
    token_stdin: bool,
990
    headless: bool,
991
    resume: bool,
992
    scope: &[String],
993
    json: bool,
994
) {
995
    if [token_stdin, headless, resume]
996
        .iter()
997
        .filter(|f| **f)
998
        .count()
999
        > 1
1000
    {
1001
        fail("use only one of --token-stdin, --headless, or --resume");
1002
    }
1003
1004
    let announce = |source: &str| {
1005
        if json {
1006
            print_json(&serde_json::json!({
1007
                "origin": endpoint.origin,
1008
                "authenticated": true,
1009
                "token_source": source,
1010
            }));
1011
        } else {
1012
            println!("Authenticated with {}.", endpoint.origin);
1013
            println!("The token is stored in your OS credential store.");
1014
            println!("Run oa auth setup-git --local to configure git for this repository.");
1015
        }
1016
    };
1017
1018
    if token_stdin {
1019
        let token = read_token_from_stdin();
1020
        or_fail(store.store(&token));
1021
        announce("token_stdin");
1022
        return;
1023
    }
1024
1025
    let pending_store = PendingStore::new();
1026
    let devices = DeviceClient::new(&endpoint.origin);
1027
1028
    if resume {
1029
        let pending = match or_fail(pending_store.get(&endpoint.origin)) {
1030
            Some(pending) => pending,
1031
            None => fail(&format!(
1032
                "no pending authorization exists for {}. Run {} first",
1033
                endpoint.origin,
1034
                crate::auth::login_command_for(endpoint)
1035
            )),
1036
        };
1037
        let remaining = (pending.expires_at_ms - crate::auth::now_ms()) / 1_000;
1038
        if remaining <= 0 {
1039
            let _ = pending_store.remove(&endpoint.origin);
1040
            fail(&format!(
1041
                "the pending authorization expired. Run {} again",
1042
                crate::auth::login_command_for(endpoint)
1043
            ));
1044
        }
1045
        let authorization = crate::auth::DeviceAuthorization {
1046
            device_code: pending.device_code.clone(),
1047
            user_code: pending.user_code.clone(),
1048
            verification_uri: pending.verification_uri.clone(),
1049
            verification_uri_complete: pending.verification_uri_complete.clone(),
1050
            expires_in: remaining,
1051
            interval: pending.interval,
1052
        };
1053
        let token = or_fail(devices.wait(&authorization).await);
1054
        or_fail(store.store(&token));
1055
        or_fail(pending_store.remove(&endpoint.origin));
1056
        announce("device_authorization");
1057
        return;
1058
    }
1059
1060
    let authorization = or_fail(devices.start(scope).await);
1061
1062
    // A session with no terminal cannot wait for a person to click, and a
1063
    // session asked for JSON cannot interleave a wait with its single object.
1064
    // Both hand the approval back and record it for `--resume`.
1065
    let interactive = std::io::IsTerminal::is_terminal(&std::io::stderr());
1066
    if headless || json || !interactive {
1067
        let resume_command = crate::auth::resume_command_for(endpoint);
1068
        or_fail(pending_store.set(&PendingDeviceAuthorization {
1069
            origin: endpoint.origin.clone(),
1070
            device_code: authorization.device_code.clone(),
1071
            user_code: authorization.user_code.clone(),
1072
            verification_uri: authorization.verification_uri.clone(),
1073
            verification_uri_complete: authorization.verification_uri_complete.clone(),
1074
            expires_at_ms: crate::auth::now_ms() + authorization.expires_in * 1_000,
1075
            interval: authorization.interval,
1076
            kind: Some("device".to_string()),
1077
        }));
1078
        if json {
1079
            print_json(&serde_json::json!({
1080
                "origin": endpoint.origin,
1081
                "authenticated": false,
1082
                "authorization_pending": true,
1083
                "verification_url": authorization.verification_uri_complete,
1084
                "user_code": authorization.user_code,
1085
                "expires_in": authorization.expires_in,
1086
                "resume_command": resume_command,
1087
            }));
1088
        } else {
1089
            println!("OpenAgents authorization is ready.");
1090
            println!("Open this URL: {}", authorization.verification_uri_complete);
1091
            println!("Authorization code: {}", authorization.user_code);
1092
            println!("After you approve the request, run: {resume_command}");
1093
        }
1094
        return;
1095
    }
1096
1097
    eprintln!(
1098
        "OpenAgents authorization URL: {}",
1099
        authorization.verification_uri_complete
1100
    );
1101
    eprintln!("OpenAgents authorization code: {}", authorization.user_code);
1102
    if !crate::auth::open_browser(&authorization.verification_uri_complete) {
1103
        eprintln!("The browser did not open. Open the authorization URL above.");
1104
    }
1105
    eprintln!("Waiting for approval...");
1106
    let token = or_fail(devices.wait(&authorization).await);
1107
    or_fail(store.store(&token));
1108
    announce("device_authorization");
1109
}
1110
1111
async fn run_auth_status(endpoint: &Endpoint, store: &CredentialStore, json: bool) {
1112
    let held = or_fail(store.find_token());
1113
    let (local_helper, global_helper) =
1114
        crate::repo::credential_helper_state(&endpoint.origin, None);
1115
1116
    let Some(held) = held else {
1117
        if json {
1118
            print_json(&serde_json::json!({
1119
                "origin": endpoint.origin,
1120
                "profile": endpoint.profile,
1121
                "authenticated": false,
1122
                "token_source": serde_json::Value::Null,
1123
                "account": serde_json::Value::Null,
1124
                "namespaces": [],
1125
                "token_expires_at": serde_json::Value::Null,
1126
                "git_helper": { "local": local_helper, "global": global_helper },
1127
            }));
1128
        } else {
1129
            println!("API: {}", endpoint.origin);
1130
            println!("No token is available.");
1131
            println!(
1132
                "Set OPENAGENTS_TOKEN or run {}.",
1133
                crate::auth::login_command_for(endpoint)
1134
            );
1135
        }
1136
        return;
1137
    };
1138
1139
    // The token is only evidence that something is stored. Whether it still
1140
    // authenticates anyone is a question only the server can answer, so ask it.
1141
    // A revoked token reports as revoked here rather than at some later command.
1142
    let client = crate::repo::RepoClient::new(&endpoint.origin, Some(held.token.clone()));
1143
    let user = or_fail(client.authenticated_user().await);
1144
1145
    if json {
1146
        print_json(&serde_json::json!({
1147
            "origin": endpoint.origin,
1148
            "profile": endpoint.profile,
1149
            "authenticated": true,
1150
            "token_source": held.source.label(),
1151
            "account": { "id": user.id, "login": user.login },
1152
            "namespaces": user.namespaces,
1153
            "token_expires_at": user.token_expires_at,
1154
            "git_helper": { "local": local_helper, "global": global_helper },
1155
        }));
1156
    } else {
1157
        println!("API: {}", endpoint.origin);
1158
        println!(
1159
            "Authenticated as {} ({}) with a {} token.",
1160
            user.login,
1161
            user.id,
1162
            held.source.label()
1163
        );
1164
        println!(
1165
            "Eligible namespaces: {}.",
1166
            user.namespaces
1167
                .iter()
1168
                .map(|namespace| namespace.login.as_str())
1169
                .collect::<Vec<_>>()
1170
                .join(", ")
1171
        );
1172
        println!("Token expires: {}.", user.token_expires_at);
1173
        println!(
1174
            "Git helper: local {}; global {}.",
1175
            if local_helper {
1176
                "configured"
1177
            } else {
1178
                "not configured"
1179
            },
1180
            if global_helper {
1181
                "configured"
1182
            } else {
1183
                "not configured"
1184
            }
1185
        );
1186
    }
1187
}
1188
1189
// ---------------------------------------------------------------------------
1190
// repo
1191
// ---------------------------------------------------------------------------
1192
1193
fn print_json(value: &serde_json::Value) {
1194
    match serde_json::to_string_pretty(value) {
1195
        Ok(text) => println!("{text}"),
1196
        Err(error) => fail(&format!("could not render JSON output: {error}")),
1197
    }
1198
}
1199
1200
/// The token every repository command needs, or a refusal naming the login.
1201
fn require_token(endpoint: &Endpoint, store: &CredentialStore) -> Secret {
1202
    match or_fail(store.find_token()) {
1203
        Some(held) => held.token,
1204
        None => fail(&format!(
1205
            "no OpenAgents token for {}. Set OPENAGENTS_TOKEN or run {}",
1206
            endpoint.origin,
1207
            crate::auth::login_command_for(endpoint)
1208
        )),
1209
    }
1210
}
1211
1212
/// Resolve `OWNER/REPO` from the positional argument, `--repo`, or this
1213
/// checkout's git remotes. When none of the three answers, the command refuses
1214
/// rather than picking a repository nobody named.
1215
fn resolve_repository(
1216
    positional: Option<String>,
1217
    override_flag: Option<String>,
1218
    origin: &str,
1219
) -> (String, String) {
1220
    if positional.is_some() && override_flag.is_some() {
1221
        fail("pass a repository argument or --repo, not both");
1222
    }
1223
    let selected = match override_flag.or(positional) {
1224
        Some(value) => value,
1225
        None => or_fail(crate::repo::infer_repository(origin, None)),
1226
    };
1227
    or_fail(crate::repo::parse_repository_target(&selected))
1228
}
1229
1230
fn visibility(public: bool, private: bool) -> Option<bool> {
1231
    if public && private {
1232
        fail("use either --public or --private, not both");
1233
    }
1234
    if public {
1235
        return Some(false);
1236
    }
1237
    if private {
1238
        return Some(true);
1239
    }
1240
    None
1241
}
1242
1243
async fn run_repo(action: RepoAction, endpoint: &Endpoint, store: &CredentialStore, json: bool) {
1244
    let token = require_token(endpoint, store);
1245
    let client = crate::repo::RepoClient::new(&endpoint.origin, Some(token));
1246
1247
    match action {
1248
        RepoAction::List {
1249
            namespace,
1250
            limit,
1251
            after,
1252
        } => {
1253
            let listed = or_fail(
1254
                client
1255
                    .list(namespace.as_deref(), limit, after.as_deref())
1256
                    .await,
1257
            );
1258
            if json {
1259
                print_json(&serde_json::json!({
1260
                    "repositories": listed.repositories,
1261
                    "next_cursor": listed.next_cursor,
1262
                }));
1263
            } else if listed.repositories.is_empty() {
1264
                println!("No repositories found.");
1265
            } else {
1266
                for repository in &listed.repositories {
1267
                    println!(
1268
                        "{}\t(branch: {})",
1269
                        repository.full_name, repository.default_branch
1270
                    );
1271
                }
1272
                if let Some(cursor) = listed.next_cursor {
1273
                    println!("Next cursor: {cursor}");
1274
                }
1275
            }
1276
        }
1277
        RepoAction::View { repository, repo } => {
1278
            let (owner, name) = resolve_repository(repository, repo, &endpoint.origin);
1279
            let value = or_fail(client.view(&owner, &name).await);
1280
            if json {
1281
                print_json(&serde_json::to_value(&value).unwrap_or(serde_json::Value::Null));
1282
            } else {
1283
                for line in value.human_lines() {
1284
                    println!("{line}");
1285
                }
1286
            }
1287
        }
1288
        RepoAction::Create {
1289
            name,
1290
            description,
1291
            public,
1292
            private,
1293
            default_branch,
1294
            wait_timeout,
1295
        } => {
1296
            let is_private = visibility(public, private).unwrap_or(false);
1297
            let (owner, repository_name) = if name.contains('/') {
1298
                let (owner, repository_name) = or_fail(crate::repo::parse_repository_target(&name));
1299
                (Some(owner), repository_name)
1300
            } else {
1301
                (None, name.clone())
1302
            };
1303
            let created = or_fail(
1304
                client
1305
                    .create(
1306
                        owner.as_deref(),
1307
                        &repository_name,
1308
                        is_private,
1309
                        description.as_deref(),
1310
                        &default_branch,
1311
                        std::time::Duration::from_secs(wait_timeout),
1312
                    )
1313
                    .await,
1314
            );
1315
            if json {
1316
                print_json(&serde_json::to_value(&created).unwrap_or(serde_json::Value::Null));
1317
            } else {
1318
                println!("Repository created.");
1319
                for line in created.human_lines() {
1320
                    println!("{line}");
1321
                }
1322
            }
1323
        }
1324
        RepoAction::Import {
1325
            source,
1326
            name,
1327
            namespace,
1328
            public,
1329
            private,
1330
            wait_timeout,
1331
        } => {
1332
            let is_private = visibility(public, private);
1333
            let (source_owner, source_repo) =
1334
                or_fail(crate::repo::parse_repository_target(&source));
1335
            let destination = namespace.clone().unwrap_or_else(|| source_owner.clone());
1336
            if !destination.eq_ignore_ascii_case(&source_owner) {
1337
                fail("--namespace must match the GitHub source owner");
1338
            }
1339
            // Eligibility is the server's fact, so read it rather than assume it.
1340
            let user = or_fail(client.authenticated_user().await);
1341
            let personal = destination.eq_ignore_ascii_case(&user.login);
1342
            if !personal
1343
                && !user.namespaces.iter().any(|candidate| {
1344
                    candidate.r#type == "organization"
1345
                        && candidate.login.eq_ignore_ascii_case(&destination)
1346
                })
1347
            {
1348
                fail(&format!(
1349
                    "{destination} is not an eligible GitHub namespace for this account"
1350
                ));
1351
            }
1352
            let (repository, repository_import) = or_fail(
1353
                client
1354
                    .import(
1355
                        if personal {
1356
                            None
1357
                        } else {
1358
                            Some(destination.as_str())
1359
                        },
1360
                        &format!("{source_owner}/{source_repo}"),
1361
                        name.as_deref(),
1362
                        is_private,
1363
                        std::time::Duration::from_secs(wait_timeout),
1364
                    )
1365
                    .await,
1366
            );
1367
            if json {
1368
                print_json(&serde_json::json!({
1369
                    "repository": repository,
1370
                    "import": repository_import,
1371
                }));
1372
            } else {
1373
                println!(
1374
                    "Imported {source_owner}/{source_repo} into {}.",
1375
                    repository.full_name
1376
                );
1377
                println!("Import state: {}", repository_import.state);
1378
                println!("This is a one-time import. Later GitHub changes do not sync.");
1379
            }
1380
        }
1381
        RepoAction::Clone {
1382
            repository,
1383
            directory,
1384
            repo,
1385
        } => {
1386
            let (owner, name) = resolve_repository(repository, repo, &endpoint.origin);
1387
            let (value, clone_url) = or_fail(client.clone_info(&owner, &name).await);
1388
            or_fail(crate::repo::git_clone(&clone_url, directory.as_deref()).await);
1389
            if json {
1390
                print_json(&serde_json::json!({
1391
                    "repository": value,
1392
                    "clone_url": clone_url,
1393
                    "cloned": true,
1394
                }));
1395
            } else {
1396
                println!("Cloned {}.", value.full_name);
1397
            }
1398
        }
1399
        RepoAction::Delete {
1400
            repository,
1401
            repo,
1402
            yes,
1403
        } => {
1404
            if !yes {
1405
                fail("repository deletion requires --yes confirmation");
1406
            }
1407
            let (owner, name) = resolve_repository(repository, repo, &endpoint.origin);
1408
            or_fail(client.remove(&owner, &name).await);
1409
            if json {
1410
                print_json(&serde_json::json!({
1411
                    "full_name": format!("{owner}/{name}"),
1412
                    "deleted": true,
1413
                }));
1414
            } else {
1415
                println!("Deleted {owner}/{name}.");
1416
            }
1417
        }
1418
    }
1419
}
1420
793 1421
/// The first eight characters of a UUID, which is how the TypeScript CLI renders
794 1422
/// topic ids in a listing.
795 1423
fn short_id(id: &str) -> &str {

@@ -806,18 +1434,6 @@ fn home_directory() -> std::path::PathBuf {

806 1434
807 1435
const API_BASE: &str = "https://openagents.com/api/v1";
808 1436
809
/// Unwrap a client result, or print the server's own refusal and exit non-zero.
810
///
811
/// Every tracker, box, and memory command ends here rather than in an
812
/// `unwrap_or_default`. That is the whole difference between reporting what the
813
/// server said and printing an empty list that reads as "there is nothing".
814
fn or_fail<T>(result: Result<T, crate::tracker::ApiError>) -> T {
815
    match result {
816
        Ok(value) => value,
817
        Err(error) => fail(&error.to_string()),
818
    }
819
}
820
821 1437
/// Print the server's body verbatim under `--json`, or the human lines.
822 1438
fn emit(json: bool, value: &serde_json::Value, human: &[String]) {
823 1439
    if json {
crates/openagents-cli/src/repo.rs modified +979 -63

@@ -1,105 +1,1021 @@

1
//! Forge repository management, clone, import and git credential helper
2
//! Talking to real `/api/v1` routes and executing git processes
1
//! Forge repository management, git execution, and the git credential helper.
2
//!
3
//! Every command here either reads the server's own answer or refuses. There is
4
//! no default repository, no assumed visibility, and no invented clone URL: a
5
//! repository the API did not describe is one this CLI cannot describe either.
3 6
7
use crate::auth::{api_error_detail, AuthError, Secret};
4 8
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
5 9
use serde::{Deserialize, Serialize};
10
use std::io::Read;
6 11
use std::path::Path;
12
use std::process::Command as SyncCommand;
13
use std::time::{Duration, Instant};
7 14
use tokio::process::Command;
8 15
16
// ---------------------------------------------------------------------------
17
// contract
18
// ---------------------------------------------------------------------------
19
20
#[derive(Debug, Clone, Serialize, Deserialize)]
21
pub struct RepositoryOwner {
22
    pub id: serde_json::Value,
23
    pub login: String,
24
    #[serde(default)]
25
    pub r#type: String,
26
}
27
28
#[derive(Debug, Clone, Serialize, Deserialize)]
29
pub struct RepositoryPermissions {
30
    pub admin: bool,
31
    pub push: bool,
32
    pub pull: bool,
33
}
34
35
/// The repository as `openagents.repositories.v1` describes it.
36
///
37
/// Nothing here has a serde default. A response missing `lifecycle_state` or
38
/// `clone_url` is a response this CLI cannot report on, and saying so is the
39
/// point: the alternative is printing `Provisioning: ready` about a repository
40
/// whose state the server never sent.
9 41
#[derive(Debug, Clone, Serialize, Deserialize)]
10 42
pub struct Repository {
11 43
    pub id: String,
12
    pub slug: String,
13
    pub is_private: bool,
44
    pub name: String,
45
    pub full_name: String,
46
    pub owner: RepositoryOwner,
47
    pub private: bool,
48
    pub visibility: String,
49
    pub description: Option<String>,
14 50
    pub default_branch: String,
51
    pub lifecycle_state: String,
52
    pub provision_error_code: Option<String>,
53
    pub clone_url: String,
54
    pub html_url: String,
55
    pub permissions: RepositoryPermissions,
56
    pub created_at: String,
57
    pub updated_at: String,
58
}
59
60
impl Repository {
61
    /// The block `oa repo view` prints, field for field with the TypeScript CLI.
62
    pub fn human_lines(&self) -> Vec<String> {
63
        vec![
64
            self.full_name.clone(),
65
            format!(
66
                "Visibility: {}",
67
                if self.private { "private" } else { "public" }
68
            ),
69
            format!("Default branch: {}", self.default_branch),
70
            format!("Provisioning: {}", self.lifecycle_state),
71
        ]
72
    }
15 73
}
16 74
75
#[derive(Debug, Clone, Serialize, Deserialize)]
76
pub struct RepositoryImport {
77
    pub id: String,
78
    pub provider: String,
79
    pub source_full_name: String,
80
    pub state: String,
81
    #[serde(default)]
82
    pub attempt_count: i64,
83
    #[serde(default)]
84
    pub lfs_warning: bool,
85
    pub error_code: Option<String>,
86
}
87
88
#[derive(Debug, Clone, Serialize, Deserialize)]
89
pub struct RepositoryList {
90
    pub repositories: Vec<Repository>,
91
    pub next_cursor: Option<String>,
92
}
93
94
#[derive(Debug, Clone, Serialize, Deserialize)]
95
pub struct AuthenticatedNamespace {
96
    pub id: serde_json::Value,
97
    pub login: String,
98
    pub r#type: String,
99
}
100
101
#[derive(Debug, Clone, Serialize, Deserialize)]
102
pub struct AuthenticatedUser {
103
    pub id: i64,
104
    pub login: String,
105
    pub token_expires_at: String,
106
    pub namespaces: Vec<AuthenticatedNamespace>,
107
}
108
109
// ---------------------------------------------------------------------------
110
// name validation
111
// ---------------------------------------------------------------------------
112
113
/// `[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9])){0,63}` written out, because a dot has
114
/// to be followed by an alphanumeric and Rust's regex engine has no lookahead.
115
pub fn validate_repository_name(name: &str) -> Result<String, AuthError> {
116
    let normalized = name.trim().to_ascii_lowercase();
117
    let bytes = normalized.as_bytes();
118
    let mut valid = (1..=64).contains(&bytes.len()) && bytes[0].is_ascii_alphanumeric();
119
    let mut index = 1;
120
    while valid && index < bytes.len() {
121
        let byte = bytes[index];
122
        valid = byte.is_ascii_lowercase()
123
            || byte.is_ascii_digit()
124
            || byte == b'_'
125
            || byte == b'-'
126
            || (byte == b'.'
127
                && bytes
128
                    .get(index + 1)
129
                    .is_some_and(|next| next.is_ascii_lowercase() || next.is_ascii_digit()));
130
        index += 1;
131
    }
132
    if !valid {
133
        return Err(AuthError::new(format!(
134
            "invalid repository name {name}. Names must match \
135
             [a-z0-9](?:[a-z0-9_-]|\\.(?=[a-z0-9])){{0,63}}"
136
        )));
137
    }
138
    Ok(normalized)
139
}
140
141
/// `[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})`, the GitHub namespace shape.
142
pub fn validate_owner(owner: &str) -> Result<String, AuthError> {
143
    let normalized = owner.trim().to_string();
144
    let bytes = normalized.as_bytes();
145
    let valid = (1..=39).contains(&bytes.len())
146
        && bytes[0].is_ascii_alphanumeric()
147
        && bytes[1..]
148
            .iter()
149
            .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-');
150
    if !valid {
151
        return Err(AuthError::new(format!(
152
            "invalid GitHub-backed namespace: {owner}"
153
        )));
154
    }
155
    Ok(normalized)
156
}
157
158
pub fn parse_repository_target(full_name: &str) -> Result<(String, String), AuthError> {
159
    let parts: Vec<&str> = full_name.trim().split('/').collect();
160
    if parts.len() != 2 {
161
        return Err(AuthError::new("use the repository format OWNER/REPO"));
162
    }
163
    Ok((
164
        validate_owner(parts[0])?,
165
        validate_repository_name(parts[1])?,
166
    ))
167
}
168
169
// ---------------------------------------------------------------------------
170
// client
171
// ---------------------------------------------------------------------------
172
17 173
pub struct RepoClient {
18
    pub api_base: String,
19
    pub token: Option<String>,
20
    pub http: reqwest::Client,
174
    origin: String,
175
    token: Option<Secret>,
176
    http: reqwest::Client,
21 177
}
22 178
23 179
impl RepoClient {
24
    pub fn new(api_base: &str, token: Option<String>) -> Self {
180
    /// `origin` is a bare API origin, such as `https://openagents.com`.
181
    pub fn new(origin: &str, token: Option<Secret>) -> Self {
25 182
        Self {
26
            api_base: api_base.trim_end_matches('/').to_string(),
183
            origin: origin.trim_end_matches('/').to_string(),
27 184
            token,
28
            http: reqwest::Client::new(),
185
            http: reqwest::Client::builder()
186
                .timeout(Duration::from_secs(60))
187
                .build()
188
                .unwrap_or_default(),
29 189
        }
30 190
    }
31 191
32
    fn headers(&self) -> HeaderMap {
192
    pub fn origin(&self) -> &str {
193
        &self.origin
194
    }
195
196
    fn headers(&self, idempotency_key: Option<&str>) -> HeaderMap {
33 197
        let mut map = HeaderMap::new();
34 198
        map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
35
        if let Some(tok) = &self.token {
36
            if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", tok)) {
37
                map.insert(AUTHORIZATION, val);
199
        if let Some(token) = &self.token {
200
            if let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", token.expose())) {
201
                map.insert(AUTHORIZATION, value);
202
            }
203
        }
204
        if let Some(key) = idempotency_key {
205
            if let Ok(value) = HeaderValue::from_str(key) {
206
                map.insert("idempotency-key", value);
38 207
            }
39 208
        }
40 209
        map
41 210
    }
42 211
43
    pub async fn list_repos(&self) -> Result<Vec<Repository>, Box<dyn std::error::Error + Send + Sync>> {
44
        let url = format!("{}/user/repos", self.api_base);
45
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
46
47
        if resp.status().is_success() {
48
            let body: serde_json::Value = resp.json().await?;
49
            let items = body.get("repositories").and_then(|v| v.as_array()).cloned().unwrap_or_else(|| {
50
                if let Some(arr) = body.as_array() { arr.clone() } else { Vec::new() }
51
            });
52
53
            let mut repos = Vec::new();
54
            for item in items {
55
                let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
56
                let full_name = item.get("full_name").or_else(|| item.get("slug")).and_then(|v| v.as_str()).unwrap_or("").to_string();
57
                let is_private = item.get("private").and_then(|v| v.as_bool()).unwrap_or(false);
58
                let default_branch = item.get("default_branch").and_then(|v| v.as_str()).unwrap_or("main").to_string();
59
60
                repos.push(Repository {
61
                    id,
62
                    slug: full_name,
63
                    is_private,
64
                    default_branch,
65
                });
66
            }
67
            Ok(repos)
68
        } else {
69
            Ok(Vec::new())
212
    /// Issue one request and refuse on anything the caller did not admit.
213
    ///
214
    /// The refusal carries the server's own status, code, and request id. A
215
    /// caller that fell back to an empty list here would report "no
216
    /// repositories" for a token the server rejected.
217
    async fn request(
218
        &self,
219
        operation: &str,
220
        method: reqwest::Method,
221
        path: &str,
222
        body: Option<serde_json::Value>,
223
        idempotency_key: Option<&str>,
224
        admitted: &[u16],
225
    ) -> Result<serde_json::Value, AuthError> {
226
        let url = format!("{}{}", self.origin, path);
227
        let mut builder = self
228
            .http
229
            .request(method, &url)
230
            .headers(self.headers(idempotency_key));
231
        if let Some(value) = body {
232
            builder = builder.json(&value);
70 233
        }
234
        let response = builder.send().await.map_err(|error| {
235
            AuthError::new(format!("could not {operation} at {}: {error}", self.origin))
236
        })?;
237
        let status = response.status().as_u16();
238
        let text = response.text().await.unwrap_or_default();
239
        let value: serde_json::Value =
240
            serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
241
        if !admitted.contains(&status) {
242
            return Err(AuthError::new(format!(
243
                "could not {operation} ({status}{})",
244
                api_error_detail(&value)
245
            )));
246
        }
247
        Ok(value)
248
    }
249
250
    fn decode<T: serde::de::DeserializeOwned>(
251
        operation: &str,
252
        value: serde_json::Value,
253
    ) -> Result<T, AuthError> {
254
        serde_json::from_value(value).map_err(|error| {
255
            AuthError::new(format!(
256
                "the API response did not match the {operation} contract: {error}"
257
            ))
258
        })
259
    }
260
261
    pub async fn authenticated_user(&self) -> Result<AuthenticatedUser, AuthError> {
262
        let value = self
263
            .request(
264
                "read the authenticated user",
265
                reqwest::Method::GET,
266
                "/api/v1/user",
267
                None,
268
                None,
269
                &[200],
270
            )
271
            .await?;
272
        Self::decode("read authenticated user", value)
71 273
    }
72 274
73
    pub async fn create_repo(&self, name: &str, is_private: bool) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
74
        let url = format!("{}/user/repos", self.api_base);
75
        let resp = self.http.post(&url).headers(self.headers()).json(&serde_json::json!({
275
    pub async fn list(
276
        &self,
277
        namespace: Option<&str>,
278
        limit: u32,
279
        after: Option<&str>,
280
    ) -> Result<RepositoryList, AuthError> {
281
        if !(1..=100).contains(&limit) {
282
            return Err(AuthError::new("--limit must be between 1 and 100"));
283
        }
284
        let mut query = format!("per_page={limit}");
285
        if let Some(namespace) = namespace {
286
            query.push_str(&format!("&namespace={}", validate_owner(namespace)?));
287
        }
288
        if let Some(after) = after {
289
            query.push_str(&format!("&after={}", urlencode(after)));
290
        }
291
        let value = self
292
            .request(
293
                "list repositories",
294
                reqwest::Method::GET,
295
                &format!("/api/v1/user/repos?{query}"),
296
                None,
297
                None,
298
                &[200],
299
            )
300
            .await?;
301
        Self::decode("list repositories", value)
302
    }
303
304
    pub async fn view(&self, owner: &str, repo: &str) -> Result<Repository, AuthError> {
305
        let owner = validate_owner(owner)?;
306
        let repo = validate_repository_name(repo)?;
307
        let value = self
308
            .request(
309
                &format!("view {owner}/{repo}"),
310
                reqwest::Method::GET,
311
                &format!("/api/v1/repos/{}/{}", urlencode(&owner), urlencode(&repo)),
312
                None,
313
                None,
314
                &[200],
315
            )
316
            .await?;
317
        Self::decode("view repository", value)
318
    }
319
320
    pub async fn remove(&self, owner: &str, repo: &str) -> Result<(), AuthError> {
321
        let owner = validate_owner(owner)?;
322
        let repo = validate_repository_name(repo)?;
323
        self.request(
324
            &format!("delete {owner}/{repo}"),
325
            reqwest::Method::DELETE,
326
            &format!("/api/v1/repos/{}/{}", urlencode(&owner), urlencode(&repo)),
327
            None,
328
            None,
329
            &[200, 202, 204],
330
        )
331
        .await?;
332
        Ok(())
333
    }
334
335
    #[allow(clippy::too_many_arguments)]
336
    pub async fn create(
337
        &self,
338
        owner: Option<&str>,
339
        name: &str,
340
        private: bool,
341
        description: Option<&str>,
342
        default_branch: &str,
343
        wait: Duration,
344
    ) -> Result<Repository, AuthError> {
345
        let name = validate_repository_name(name)?;
346
        let owner = owner.map(validate_owner).transpose()?;
347
        let mut body = serde_json::json!({
76 348
            "name": name,
77
            "private": is_private,
78
        })).send().await?;
79
        Ok(resp.status().is_success())
349
            "private": private,
350
            "default_branch": default_branch,
351
        });
352
        if let Some(description) = description {
353
            body["description"] = serde_json::Value::String(description.to_string());
354
        }
355
        let path = match &owner {
356
            None => "/api/v1/user/repos".to_string(),
357
            Some(owner) => format!("/api/v1/orgs/{}/repos", urlencode(owner)),
358
        };
359
        let value = self
360
            .request(
361
                "create the repository",
362
                reqwest::Method::POST,
363
                &path,
364
                Some(body),
365
                Some(&idempotency_key()),
366
                &[200, 201, 202],
367
            )
368
            .await?;
369
        let repository: Repository = Self::decode("create repository", value)?;
370
        if repository.lifecycle_state == "ready" || wait.is_zero() {
371
            return Ok(repository);
372
        }
373
        self.wait_for_repository(&repository.owner.login, &repository.name, wait)
374
            .await
80 375
    }
81 376
82
    pub async fn clone_repo(slug: &str, destination: Option<&Path>) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
83
        let repo_url = format!("https://openagents.com/{}.git", slug);
84
        let mut cmd = Command::new("git");
85
        cmd.arg("clone").arg(&repo_url);
86
        if let Some(dest) = destination {
87
            cmd.arg(dest);
377
    async fn wait_for_repository(
378
        &self,
379
        owner: &str,
380
        repo: &str,
381
        wait: Duration,
382
    ) -> Result<Repository, AuthError> {
383
        let started = Instant::now();
384
        loop {
385
            let repository = self.view(owner, repo).await?;
386
            match repository.lifecycle_state.as_str() {
387
                "ready" => return Ok(repository),
388
                "failed" => {
389
                    return Err(AuthError::new(format!(
390
                        "provisioning failed for {owner}/{repo}{}",
391
                        repository
392
                            .provision_error_code
393
                            .map(|code| format!(": {code}"))
394
                            .unwrap_or_default()
395
                    )))
396
                }
397
                _ => {}
398
            }
399
            if started.elapsed() >= wait {
400
                return Err(AuthError::new(format!(
401
                    "{owner}/{repo} is still provisioning after {} s. Provisioning continues on the server",
402
                    wait.as_secs()
403
                )));
404
            }
405
            eprintln!(
406
                "Repository provisioning: {} ({}s elapsed).",
407
                repository.lifecycle_state,
408
                started.elapsed().as_secs()
409
            );
410
            tokio::time::sleep(Duration::from_secs(1)).await;
411
        }
412
    }
413
414
    pub async fn import(
415
        &self,
416
        owner: Option<&str>,
417
        source: &str,
418
        name: Option<&str>,
419
        private: Option<bool>,
420
        wait: Duration,
421
    ) -> Result<(Repository, RepositoryImport), AuthError> {
422
        let (source_owner, source_repo) = parse_repository_target(source)?;
423
        let owner = owner.map(validate_owner).transpose()?;
424
        let name = name.map(validate_repository_name).transpose()?;
425
        let mut body = serde_json::json!({
426
            "source": { "provider": "github", "repository": format!("{source_owner}/{source_repo}") },
427
        });
428
        if let Some(private) = private {
429
            body["private"] = serde_json::Value::Bool(private);
430
        }
431
        if let Some(name) = &name {
432
            body["name"] = serde_json::Value::String(name.clone());
433
        }
434
        let path = match &owner {
435
            None => "/api/v1/user/repos/imports".to_string(),
436
            Some(owner) => format!("/api/v1/orgs/{}/repos/imports", urlencode(owner)),
437
        };
438
        let value = self
439
            .request(
440
                "import the repository",
441
                reqwest::Method::POST,
442
                &path,
443
                Some(body),
444
                Some(&idempotency_key()),
445
                &[200, 201, 202],
446
            )
447
            .await?;
448
        let repository: Repository = Self::decode("import repository", value.clone())?;
449
        let repository_import: RepositoryImport = serde_json::from_value(
450
            value
451
                .get("import")
452
                .cloned()
453
                .unwrap_or(serde_json::Value::Null),
454
        )
455
        .map_err(|error| {
456
            AuthError::new(format!(
457
                "the API response did not match the import repository contract: {error}"
458
            ))
459
        })?;
460
        if repository_import.state == "completed" || wait.is_zero() {
461
            return Ok((repository, repository_import));
462
        }
463
        self.wait_for_import(&repository_import.id, wait).await
464
    }
465
466
    async fn wait_for_import(
467
        &self,
468
        import_id: &str,
469
        wait: Duration,
470
    ) -> Result<(Repository, RepositoryImport), AuthError> {
471
        let started = Instant::now();
472
        loop {
473
            let value = self
474
                .request(
475
                    "read the repository import",
476
                    reqwest::Method::GET,
477
                    &format!("/api/v1/repository-imports/{}", urlencode(import_id)),
478
                    None,
479
                    None,
480
                    &[200],
481
                )
482
                .await?;
483
            let repository: Repository = Self::decode(
484
                "read repository import",
485
                value
486
                    .get("repository")
487
                    .cloned()
488
                    .unwrap_or(serde_json::Value::Null),
489
            )?;
490
            let repository_import: RepositoryImport = Self::decode(
491
                "read repository import",
492
                value
493
                    .get("import")
494
                    .cloned()
495
                    .unwrap_or(serde_json::Value::Null),
496
            )?;
497
            match repository_import.state.as_str() {
498
                "completed" => return Ok((repository, repository_import)),
499
                "failed" => {
500
                    return Err(AuthError::new(format!(
501
                        "repository import {import_id} failed{}",
502
                        repository_import
503
                            .error_code
504
                            .map(|code| format!(": {code}"))
505
                            .unwrap_or_default()
506
                    )))
507
                }
508
                _ => {}
509
            }
510
            if started.elapsed() >= wait {
511
                return Err(AuthError::new(format!(
512
                    "repository import {import_id} is still running after {} s. The import continues on the server",
513
                    wait.as_secs()
514
                )));
515
            }
516
            eprintln!(
517
                "Repository import: {} (shallow snapshot, attempt {}, {}s elapsed).",
518
                repository_import.state,
519
                repository_import.attempt_count,
520
                started.elapsed().as_secs()
521
            );
522
            tokio::time::sleep(Duration::from_secs(1)).await;
88 523
        }
89
        let status = cmd.status().await?;
90
        Ok(status.success())
524
    }
525
526
    /// The repository and the URL to clone it from, after checking that the URL
527
    /// the API returned is on the origin this invocation is talking to. A clone
528
    /// URL pointing elsewhere would send the credential helper's token to
529
    /// whatever host the response named.
530
    pub async fn clone_info(
531
        &self,
532
        owner: &str,
533
        repo: &str,
534
    ) -> Result<(Repository, String), AuthError> {
535
        let repository = self.view(owner, repo).await?;
536
        let url = reqwest::Url::parse(&repository.clone_url).map_err(|error| {
537
            AuthError::new(format!("the API returned an invalid clone URL: {error}"))
538
        })?;
539
        let expected = format!(
540
            "/{}/{}.git",
541
            urlencode(&repository.owner.login),
542
            urlencode(&repository.name)
543
        );
544
        let origin_matches = url_origin(&url)
545
            .map(|value| value == self.origin)
546
            .unwrap_or(false);
547
        if !origin_matches
548
            || !url.username().is_empty()
549
            || url.password().is_some()
550
            || url.query().is_some()
551
            || url.fragment().is_some()
552
            || url.path() != expected
553
        {
554
            return Err(AuthError::new(
555
                "the API returned a clone URL outside the selected OpenAgents origin",
556
            ));
557
        }
558
        Ok((repository, url.to_string()))
91 559
    }
92 560
}
93 561
94
pub fn handle_git_credential(operation: &str, host: &str, token: Option<&str>) -> String {
95
    match operation {
96
        "get" => {
97
            if let Some(tok) = token {
98
                format!("protocol=https\nhost={}\nusername=openagents-token\npassword={}\n", host, tok)
99
            } else {
100
                "".to_string()
562
fn url_origin(url: &reqwest::Url) -> Option<String> {
563
    let host = url.host_str()?;
564
    Some(match url.port() {
565
        Some(port) => format!("{}://{}:{}", url.scheme(), host, port),
566
        None => format!("{}://{}", url.scheme(), host),
567
    })
568
}
569
570
fn urlencode(value: &str) -> String {
571
    let mut out = String::with_capacity(value.len());
572
    for byte in value.bytes() {
573
        match byte {
574
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
575
                out.push(byte as char)
101 576
            }
577
            _ => out.push_str(&format!("%{byte:02X}")),
102 578
        }
103
        _ => "".to_string(),
579
    }
580
    out
581
}
582
583
/// A fresh idempotency key, so a retried mutation does not create a second
584
/// repository. Derived from the clock and the process, not from a constant: a
585
/// hardcoded key would make every machine's create collide with every other's.
586
fn idempotency_key() -> String {
587
    let nanos = std::time::SystemTime::now()
588
        .duration_since(std::time::UNIX_EPOCH)
589
        .map(|d| d.as_nanos())
590
        .unwrap_or(0);
591
    format!("oa-{:x}-{:x}", std::process::id(), nanos)
592
}
593
594
// ---------------------------------------------------------------------------
595
// git
596
// ---------------------------------------------------------------------------
597
598
/// Quote a value for the shell git runs a `!`-prefixed helper through.
599
fn shell_argument(value: &str) -> String {
600
    let plain = !value.is_empty()
601
        && value
602
            .bytes()
603
            .all(|byte| byte.is_ascii_alphanumeric() || b"_./:@=-".contains(&byte));
604
    if plain {
605
        value.to_string()
606
    } else {
607
        format!("'{}'", value.replace('\'', "'\"'\"'"))
608
    }
609
}
610
611
/// The path of the running binary, which is the program the credential helper
612
/// names.
613
///
614
/// A bare `oa` would be resolved by the shell against `PATH`, and on a machine
615
/// that also has an older `oa` installed — the common case while this port
616
/// lands — git would run that one instead, which does not understand
617
/// `--api-url` and answers nothing. Naming the path makes the helper this CLI.
618
pub fn cli_program_path() -> String {
619
    std::env::current_exe()
620
        .ok()
621
        .and_then(|path| path.canonicalize().ok())
622
        .map(|path| path.display().to_string())
623
        .unwrap_or_else(|| "oa".to_string())
624
}
625
626
/// The git credential helper line this CLI installs.
627
///
628
/// The `!` makes git run it as a shell command with the operation appended, so
629
/// `credential.<origin>.helper` resolves to `<oa> --api-url <origin> auth
630
/// git-credential get`.
631
pub fn credential_helper_command(origin: &str) -> String {
632
    format!(
633
        "!{} --api-url {origin} auth git-credential",
634
        shell_argument(&cli_program_path())
635
    )
636
}
637
638
pub fn credential_helper_key(origin: &str) -> String {
639
    format!("credential.{origin}.helper")
640
}
641
642
fn run_git_sync(args: &[&str], directory: Option<&Path>) -> Result<(i32, String), AuthError> {
643
    let mut command = SyncCommand::new("git");
644
    if let Some(directory) = directory {
645
        command.arg("-C").arg(directory);
646
    }
647
    command.args(args);
648
    let output = command
649
        .output()
650
        .map_err(|error| AuthError::new(format!("could not run git: {error}")))?;
651
    Ok((
652
        output.status.code().unwrap_or(-1),
653
        String::from_utf8_lossy(&output.stdout).to_string(),
654
    ))
655
}
656
657
/// Write `credential.<origin>.helper` into the local or global git config.
658
///
659
/// `directory` selects the checkout for `--local`; `None` means the working
660
/// directory, which is what `oa auth setup-git --local` wants.
661
pub fn configure_credential_helper(
662
    origin: &str,
663
    scope: &str,
664
    directory: Option<&Path>,
665
) -> Result<(), AuthError> {
666
    let scope_flag = if scope == "local" {
667
        "--local"
668
    } else {
669
        "--global"
670
    };
671
    let key = credential_helper_key(origin);
672
    let (reset, _) = run_git_sync(
673
        &["config", scope_flag, "--replace-all", &key, ""],
674
        directory,
675
    )?;
676
    if reset != 0 {
677
        return Err(AuthError::new(format!(
678
            "git config exited with status {reset}. Run oa auth setup-git --local inside a git repository"
679
        )));
680
    }
681
    let helper = credential_helper_command(origin);
682
    let (added, _) = run_git_sync(&["config", scope_flag, "--add", &key, &helper], directory)?;
683
    if added != 0 {
684
        return Err(AuthError::new(format!(
685
            "git config exited with status {added}"
686
        )));
687
    }
688
    Ok(())
689
}
690
691
/// Whether the helper is configured locally, globally, or not at all.
692
pub fn credential_helper_state(origin: &str, directory: Option<&Path>) -> (bool, bool) {
693
    let expected = credential_helper_command(origin);
694
    let key = credential_helper_key(origin);
695
    let configured = |scope: &str| {
696
        run_git_sync(&["config", scope, "--get-all", &key], directory)
697
            .map(|(code, out)| code == 0 && out.lines().any(|line| line == expected))
698
            .unwrap_or(false)
699
    };
700
    (configured("--local"), configured("--global"))
701
}
702
703
/// `git clone` with this CLI wired in as the only credential helper for the
704
/// origin, so a private repository clones without any other credential present.
705
pub fn git_clone_argv(url: &str, directory: Option<&str>) -> Vec<String> {
706
    let origin = reqwest::Url::parse(url)
707
        .ok()
708
        .and_then(|parsed| url_origin(&parsed))
709
        .unwrap_or_default();
710
    let mut argv = vec![
711
        "-c".to_string(),
712
        "credential.helper=".to_string(),
713
        "-c".to_string(),
714
        format!(
715
            "{}={}",
716
            credential_helper_key(&origin),
717
            credential_helper_command(&origin)
718
        ),
719
        "clone".to_string(),
720
        "--".to_string(),
721
        url.to_string(),
722
    ];
723
    if let Some(directory) = directory {
724
        argv.push(directory.to_string());
725
    }
726
    argv
727
}
728
729
pub async fn git_clone(url: &str, directory: Option<&str>) -> Result<(), AuthError> {
730
    let status = Command::new("git")
731
        .args(git_clone_argv(url, directory))
732
        .status()
733
        .await
734
        .map_err(|error| AuthError::new(format!("could not run git: {error}")))?;
735
    if !status.success() {
736
        return Err(AuthError::new(format!(
737
            "git clone exited with status {}",
738
            status.code().unwrap_or(-1)
739
        )));
740
    }
741
    Ok(())
742
}
743
744
pub fn parse_git_remotes(output: &str) -> Vec<(String, String)> {
745
    let mut remotes: Vec<(String, String)> = Vec::new();
746
    for line in output.lines() {
747
        let mut fields = line.split_whitespace();
748
        let (Some(name), Some(url)) = (fields.next(), fields.next()) else {
749
            continue;
750
        };
751
        if !remotes.iter().any(|(existing, _)| existing == name) {
752
            remotes.push((name.to_string(), url.to_string()));
753
        }
754
    }
755
    remotes
756
}
757
758
/// The `OWNER/REPO` a remote URL names, when the URL is a repository on `origin`.
759
///
760
/// A remote's *name* is a local convention — this project names the forge
761
/// `openagents` and reserves `origin` for the GitHub mirror, other checkouts do
762
/// the reverse — so the URL is what decides. A mirror is never inferred.
763
pub fn repository_from_remote_url(origin: &str, remote_url: &str) -> Result<String, AuthError> {
764
    let url = reqwest::Url::parse(remote_url)
765
        .map_err(|_| AuthError::new("that git remote URL is not an OpenAgents repository URL"))?;
766
    let parts: Vec<&str> = url.path().split('/').collect();
767
    let matches_origin = url_origin(&url)
768
        .map(|value| value == origin)
769
        .unwrap_or(false);
770
    if !matches_origin
771
        || !url.username().is_empty()
772
        || url.password().is_some()
773
        || url.query().is_some()
774
        || url.fragment().is_some()
775
        || parts.len() != 3
776
        || !parts[2].ends_with(".git")
777
    {
778
        return Err(AuthError::new(
779
            "that git remote URL is not an OpenAgents repository URL",
780
        ));
781
    }
782
    let owner = parts[1];
783
    let repo = &parts[2][..parts[2].len() - 4];
784
    if owner.is_empty() || repo.is_empty() {
785
        return Err(AuthError::new(
786
            "that git remote URL is not an OpenAgents repository URL",
787
        ));
788
    }
789
    Ok(format!("{owner}/{repo}"))
790
}
791
792
/// The repository this checkout belongs to, or a refusal that names what it
793
/// looked at. Never a guess.
794
pub fn infer_repository(origin: &str, directory: Option<&Path>) -> Result<String, AuthError> {
795
    let (code, listed) = run_git_sync(&["remote", "-v"], directory)?;
796
    if code != 0 {
797
        return Err(AuthError::new(
798
            "could not read the git remotes of this directory. Pass OWNER/REPO instead",
799
        ));
800
    }
801
    let remotes = parse_git_remotes(&listed);
802
    if remotes.is_empty() {
803
        return Err(AuthError::new(format!(
804
            "this checkout has no git remotes. Pass OWNER/REPO, or add a remote for {origin}"
805
        )));
806
    }
807
    // Prefer the forge remote by name only as a tie-break among admitted URLs.
808
    let mut ordered: Vec<&(String, String)> = Vec::new();
809
    for preferred in ["openagents", "origin", "upstream"] {
810
        if let Some(remote) = remotes.iter().find(|(name, _)| name == preferred) {
811
            ordered.push(remote);
812
        }
813
    }
814
    for remote in &remotes {
815
        if !ordered.iter().any(|(name, _)| *name == remote.0) {
816
            ordered.push(remote);
817
        }
818
    }
819
    let mut rejected: Vec<String> = Vec::new();
820
    for (name, url) in ordered {
821
        match repository_from_remote_url(origin, url) {
822
            Ok(repository) => return Ok(repository),
823
            Err(_) => rejected.push(format!("{name} {url}")),
824
        }
825
    }
826
    Err(AuthError::new(format!(
827
        "no git remote of this checkout is a repository on {origin}: {}. \
828
         A remote's name does not decide this; its URL does. Pass OWNER/REPO instead",
829
        rejected.join("; ")
830
    )))
831
}
832
833
// ---------------------------------------------------------------------------
834
// git credential helper protocol
835
// ---------------------------------------------------------------------------
836
837
/// Parse git's `key=value` credential request, keeping only the fields that
838
/// decide admission.
839
pub fn parse_git_credential_request(input: &str) -> Vec<(String, String)> {
840
    let mut fields = Vec::new();
841
    for line in input.split(['\n', '\r']) {
842
        let Some(separator) = line.find('=') else {
843
            continue;
844
        };
845
        if separator == 0 {
846
            continue;
847
        }
848
        let key = &line[..separator];
849
        let value = &line[separator + 1..];
850
        if matches!(key, "protocol" | "host" | "path") {
851
            fields.push((key.to_string(), value.to_string()));
852
        }
853
    }
854
    fields
855
}
856
857
/// Whether this request is for the endpoint the CLI holds a token for.
858
///
859
/// git asks every configured helper for every host. Answering one for
860
/// `github.com` would hand an OpenAgents token to GitHub.
861
pub fn admitted_credential_request(origin: &str, fields: &[(String, String)]) -> bool {
862
    let Ok(url) = reqwest::Url::parse(origin) else {
863
        return false;
864
    };
865
    let Some(host) = url.host_str() else {
866
        return false;
867
    };
868
    let authority = match url.port() {
869
        Some(port) => format!("{host}:{port}"),
870
        None => host.to_string(),
871
    };
872
    let field = |key: &str| {
873
        fields
874
            .iter()
875
            .find(|(name, _)| name == key)
876
            .map(|(_, value)| value.as_str())
877
    };
878
    field("protocol") == Some(url.scheme()) && field("host") == Some(authority.as_str())
879
}
880
881
/// The answer written on stdout for an admitted `get`. The username is ignored
882
/// by the forge; the token travels as the password.
883
pub fn credential_answer(token: &Secret) -> String {
884
    format!("username=openagents\npassword={}\n\n", token.expose())
885
}
886
887
/// Run the helper protocol against a store.
888
///
889
/// Returns the bytes to write on stdout, which is empty for every case that is
890
/// not an admitted `get` holding a token. Silence is the protocol's way of
891
/// saying "I have nothing", and it is the only honest answer when there is no
892
/// credential: an invented one would make git retry against the server with a
893
/// password that was never issued.
894
pub fn run_git_credential_helper(
895
    origin: &str,
896
    operation: &str,
897
    input: &str,
898
    store: &crate::auth::CredentialStore,
899
) -> Result<String, AuthError> {
900
    if input.len() > 8_192 {
901
        return Err(AuthError::new(
902
            "the git credential request exceeded 8192 bytes",
903
        ));
904
    }
905
    let fields = parse_git_credential_request(input);
906
    if !admitted_credential_request(origin, &fields) {
907
        return Ok(String::new());
908
    }
909
    match operation {
910
        "erase" => {
911
            store.remove()?;
912
            Ok(String::new())
913
        }
914
        "store" => Ok(String::new()),
915
        "get" => match store.find_token()? {
916
            Some(stored) => Ok(credential_answer(&stored.token)),
917
            None => Ok(String::new()),
918
        },
919
        other => Err(AuthError::new(format!(
920
            "unknown git credential operation {other}. Use get, store, or erase"
921
        ))),
922
    }
923
}
924
925
/// Read git's request from stdin, bounded.
926
pub fn read_credential_stdin() -> Result<String, AuthError> {
927
    let mut buffer = Vec::new();
928
    std::io::stdin()
929
        .take(8_193)
930
        .read_to_end(&mut buffer)
931
        .map_err(|error| {
932
            AuthError::new(format!(
933
                "the credential helper could not read git input: {error}"
934
            ))
935
        })?;
936
    Ok(String::from_utf8_lossy(&buffer).to_string())
937
}
938
939
#[cfg(test)]
940
mod tests {
941
    use super::*;
942
943
    #[test]
944
    fn names_and_targets_are_validated() {
945
        assert_eq!(
946
            validate_repository_name("OpenAgents").unwrap(),
947
            "openagents"
948
        );
949
        assert_eq!(
950
            validate_repository_name("open.agents").unwrap(),
951
            "open.agents"
952
        );
953
        assert!(validate_repository_name("open.").is_err());
954
        assert!(validate_repository_name("-open").is_err());
955
        assert!(validate_repository_name("").is_err());
956
        assert_eq!(
957
            parse_repository_target("OpenAgentsInc/openagents").unwrap(),
958
            ("OpenAgentsInc".to_string(), "openagents".to_string())
959
        );
960
        assert!(parse_repository_target("openagents").is_err());
961
        assert!(parse_repository_target("a/b/c").is_err());
962
    }
963
964
    #[test]
965
    fn only_the_selected_origin_is_admitted() {
966
        let fields = parse_git_credential_request("protocol=https\nhost=openagents.com\n\n");
967
        assert!(admitted_credential_request(
968
            "https://openagents.com",
969
            &fields
970
        ));
971
        assert!(!admitted_credential_request(
972
            "https://staging.openagents.com",
973
            &fields
974
        ));
975
        let github = parse_git_credential_request("protocol=https\nhost=github.com\n");
976
        assert!(!admitted_credential_request(
977
            "https://openagents.com",
978
            &github
979
        ));
980
    }
981
982
    #[test]
983
    fn remote_urls_decide_the_repository_not_remote_names() {
984
        assert_eq!(
985
            repository_from_remote_url(
986
                "https://openagents.com",
987
                "https://openagents.com/OpenAgentsInc/openagents.git"
988
            )
989
            .unwrap(),
990
            "OpenAgentsInc/openagents"
991
        );
992
        assert!(repository_from_remote_url(
993
            "https://openagents.com",
994
            "https://github.com/OpenAgentsInc/openagents.git"
995
        )
996
        .is_err());
997
        assert!(repository_from_remote_url(
998
            "https://openagents.com",
999
            "https://openagents.com/OpenAgentsInc/openagents"
1000
        )
1001
        .is_err());
1002
    }
1003
1004
    #[test]
1005
    fn clone_argv_pins_this_cli_as_the_only_helper() {
1006
        let argv = git_clone_argv("https://openagents.com/a/b.git", Some("dest"));
1007
        assert_eq!(argv[0], "-c");
1008
        assert_eq!(argv[1], "credential.helper=");
1009
        assert_eq!(
1010
            argv[3],
1011
            format!(
1012
                "credential.https://openagents.com.helper={}",
1013
                credential_helper_command("https://openagents.com")
1014
            )
1015
        );
1016
        // The helper names this binary, not a bare `oa` the shell would resolve
1017
        // against PATH — where an older install would answer instead.
1018
        assert!(argv[3].contains(&cli_program_path()), "{}", argv[3]);
1019
        assert_eq!(argv[argv.len() - 1], "dest");
104 1020
    }
105 1021
}
crates/openagents-cli/tests/auth_repo_test.rs added +551

@@ -0,0 +1,551 @@

1
//! The contract for `oa auth` (#74) and `oa repo` (#77).
2
//!
3
//! Every test here fails by construction against the behavior that was in the
4
//! tree before: printlns for `login`, `status`, `setup-git`, and `repo view`; a
5
//! credential helper that answered any host; a hardcoded production origin; and
6
//! no zeroization anywhere.
7
8
use openagents_cli::auth::{
9
    normalize_api_origin, resolve_endpoint, CredentialStore, DeviceClient,
10
    PendingDeviceAuthorization, PendingStore, Secret, TokenSource,
11
};
12
use openagents_cli::repo::{
13
    admitted_credential_request, cli_program_path, configure_credential_helper,
14
    credential_helper_command, credential_helper_state, git_clone_argv, infer_repository,
15
    parse_repository_target, repository_from_remote_url, run_git_credential_helper, RepoClient,
16
};
17
use std::path::Path;
18
use std::process::Command;
19
20
const ORIGIN: &str = "https://openagents.com";
21
22
fn git(directory: &Path, args: &[&str]) -> String {
23
    let output = Command::new("git")
24
        .arg("-C")
25
        .arg(directory)
26
        .args(args)
27
        .output()
28
        .expect("git runs");
29
    String::from_utf8_lossy(&output.stdout).to_string()
30
}
31
32
fn init_repository(directory: &Path) {
33
    // A repository-local config only, so nothing here reaches the developer's
34
    // own ~/.gitconfig.
35
    assert!(Command::new("git")
36
        .arg("init")
37
        .arg("--quiet")
38
        .arg(directory)
39
        .status()
40
        .expect("git init runs")
41
        .success());
42
}
43
44
// ---------------------------------------------------------------------------
45
// #74 credential store
46
// ---------------------------------------------------------------------------
47
48
/// Acceptance 5 on #74: logout wipes the plaintext before it asks any store to
49
/// delete its record, so a store that then refuses still leaves nothing behind
50
/// in this process.
51
#[test]
52
fn logout_zeroizes_the_token_before_clearing_it() {
53
    let directory = tempfile::tempdir().unwrap();
54
    let store = CredentialStore::isolated(ORIGIN, directory.path());
55
    store.store(&Secret::new("oa_pat_zeroize_me")).unwrap();
56
57
    let mut held = store.find_token().unwrap().expect("a stored token").token;
58
    assert_eq!(held.expose(), "oa_pat_zeroize_me");
59
    held.zeroize_now();
60
    assert!(held.is_empty(), "the buffer still holds the token");
61
    assert_eq!(held.expose(), "");
62
63
    assert!(store.remove().unwrap(), "remove reports it had a token");
64
    assert!(store.find_token().unwrap().is_none());
65
66
    let file = directory.path().join("credentials.json");
67
    let remaining = std::fs::read_to_string(&file).unwrap_or_default();
68
    assert!(
69
        !remaining.contains("oa_pat_zeroize_me"),
70
        "the credential file still holds the token: {remaining}"
71
    );
72
    assert!(
73
        !store.remove().unwrap(),
74
        "a second logout has nothing to do"
75
    );
76
}
77
78
/// A token must not be able to reach a log line, a panic message, or a stray
79
/// `{:?}`. Asserting only that a marker is present would be satisfied by a
80
/// prefix swap that leaves the value in the tail, so this asserts absence.
81
#[test]
82
fn a_token_never_renders_into_debug_output() {
83
    let secret = Secret::new("oa_pat_neverprintthis");
84
    assert!(!format!("{secret:?}").contains("neverprintthis"));
85
    let directory = tempfile::tempdir().unwrap();
86
    let store = CredentialStore::isolated(ORIGIN, directory.path());
87
    store.store(&secret).unwrap();
88
    let held = store.find_token().unwrap().unwrap();
89
    assert!(!format!("{held:?}").contains("neverprintthis"));
90
    assert_eq!(held.source, TokenSource::File);
91
}
92
93
/// The rule this whole port is written around. A store that could not be read
94
/// is not a store that holds nothing: reporting "not signed in" here would send
95
/// the next command out unauthenticated, to fail somewhere unrelated.
96
#[test]
97
fn a_store_that_cannot_be_read_is_refused_not_reported_as_empty() {
98
    let directory = tempfile::tempdir().unwrap();
99
    std::fs::write(directory.path().join("credentials.json"), "{ not json").unwrap();
100
    let store = CredentialStore::isolated(ORIGIN, directory.path());
101
    let error = store.find_token().expect_err("a corrupt store is refused");
102
    assert!(error.to_string().contains("could not decode"), "{error}");
103
    // The lenient path the unrelated subsystems use still yields nothing rather
104
    // than a value nobody wrote.
105
    assert!(store.get_token().is_none());
106
}
107
108
/// The store keys on the endpoint, so a token for staging is never handed to a
109
/// session pointed at production.
110
#[test]
111
fn tokens_are_keyed_by_endpoint() {
112
    let directory = tempfile::tempdir().unwrap();
113
    let production = CredentialStore::isolated(ORIGIN, directory.path());
114
    let staging = CredentialStore::isolated("https://staging.openagents.com", directory.path());
115
    production.store(&Secret::new("oa_pat_production")).unwrap();
116
    assert_eq!(
117
        production.find_token().unwrap().unwrap().token.expose(),
118
        "oa_pat_production"
119
    );
120
    assert!(
121
        staging.find_token().unwrap().is_none(),
122
        "the staging endpoint must not see the production token"
123
    );
124
}
125
126
#[test]
127
fn refusing_to_store_an_empty_token() {
128
    let directory = tempfile::tempdir().unwrap();
129
    let store = CredentialStore::isolated(ORIGIN, directory.path());
130
    assert!(store.store(&Secret::new("")).is_err());
131
}
132
133
// ---------------------------------------------------------------------------
134
// #74 endpoint
135
// ---------------------------------------------------------------------------
136
137
#[test]
138
fn the_endpoint_comes_from_the_flags_not_a_constant() {
139
    assert_eq!(
140
        resolve_endpoint(None, None).unwrap().origin,
141
        "https://openagents.com"
142
    );
143
    let staging = resolve_endpoint(None, Some("staging")).unwrap();
144
    assert_eq!(staging.origin, "https://staging.openagents.com");
145
    assert_eq!(staging.profile, "staging");
146
    let custom = resolve_endpoint(Some("https://forge.example.com"), None).unwrap();
147
    assert_eq!(custom.origin, "https://forge.example.com");
148
    assert_eq!(custom.profile, "custom");
149
    assert!(resolve_endpoint(Some("https://a.example"), Some("staging")).is_err());
150
    assert!(resolve_endpoint(None, Some("nowhere")).is_err());
151
    // Plain HTTP is admitted for loopback development and nothing else, because
152
    // a bearer token would otherwise cross the wire in the clear.
153
    assert!(normalize_api_origin("http://localhost:4000").is_ok());
154
    assert!(normalize_api_origin("http://forge.example.com").is_err());
155
}
156
157
// ---------------------------------------------------------------------------
158
// #74 device authorization
159
// ---------------------------------------------------------------------------
160
161
/// `--headless` records the half-finished login so `--resume` can finish it.
162
/// The record carries the code being approved and no token.
163
#[test]
164
fn pending_device_authorizations_round_trip_on_disk() {
165
    let directory = tempfile::tempdir().unwrap();
166
    let path = directory.path().join("device-authorizations.json");
167
    let store = PendingStore::at(path.clone());
168
    assert!(store.get(ORIGIN).unwrap().is_none());
169
170
    let pending = PendingDeviceAuthorization {
171
        origin: ORIGIN.to_string(),
172
        device_code: "device-code-1".to_string(),
173
        user_code: "ABCD-EFGH".to_string(),
174
        verification_uri: format!("{ORIGIN}/device"),
175
        verification_uri_complete: format!("{ORIGIN}/device?user_code=ABCD-EFGH"),
176
        expires_at_ms: 1_800_000_000_000,
177
        interval: 5,
178
        kind: Some("device".to_string()),
179
    };
180
    store.set(&pending).unwrap();
181
182
    let loaded = store.get(ORIGIN).unwrap().expect("the pending record");
183
    assert_eq!(loaded.device_code, "device-code-1");
184
    assert_eq!(loaded.user_code, "ABCD-EFGH");
185
    assert_eq!(loaded.interval, 5);
186
    assert!(store
187
        .get("https://staging.openagents.com")
188
        .unwrap()
189
        .is_none());
190
191
    #[cfg(unix)]
192
    {
193
        use std::os::unix::fs::PermissionsExt;
194
        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
195
        assert_eq!(mode, 0o600, "pending state must be 0600, was {mode:o}");
196
    }
197
198
    store.remove(ORIGIN).unwrap();
199
    assert!(store.get(ORIGIN).unwrap().is_none());
200
    assert!(!path.exists(), "the empty file is removed rather than left");
201
}
202
203
/// A device flow that cannot reach the server refuses. It does not mint a
204
/// verification URL or a code of its own, which is exactly the shape of every
205
/// fabrication this codebase has shipped before.
206
#[tokio::test]
207
async fn a_device_authorization_that_cannot_reach_the_server_refuses() {
208
    // Port 1 is reserved and refuses immediately.
209
    let client = DeviceClient::new("http://127.0.0.1:1");
210
    let error = client
211
        .start(&[])
212
        .await
213
        .expect_err("an unreachable server yields no authorization");
214
    assert!(error.to_string().contains("could not reach"), "{error}");
215
}
216
217
// ---------------------------------------------------------------------------
218
// #77 git credential helper
219
// ---------------------------------------------------------------------------
220
221
/// git asks every configured helper about every host. Answering for github.com
222
/// would hand an OpenAgents token to GitHub.
223
#[test]
224
fn the_credential_helper_answers_only_the_selected_origin() {
225
    let directory = tempfile::tempdir().unwrap();
226
    let store = CredentialStore::isolated(ORIGIN, directory.path());
227
    store.store(&Secret::new("oa_pat_helpertoken")).unwrap();
228
229
    let mine = run_git_credential_helper(
230
        ORIGIN,
231
        "get",
232
        "protocol=https\nhost=openagents.com\npath=OpenAgentsInc/openagents.git\n\n",
233
        &store,
234
    )
235
    .unwrap();
236
    assert!(mine.contains("username=openagents\n"), "{mine}");
237
    assert!(mine.contains("password=oa_pat_helpertoken\n"), "{mine}");
238
    assert!(
239
        mine.ends_with("\n\n"),
240
        "git expects a blank terminating line"
241
    );
242
243
    let theirs = run_git_credential_helper(
244
        ORIGIN,
245
        "get",
246
        "protocol=https\nhost=github.com\npath=OpenAgentsInc/openagents.git\n\n",
247
        &store,
248
    )
249
    .unwrap();
250
    assert_eq!(theirs, "", "a token must not leave for another host");
251
252
    let wrong_scheme = run_git_credential_helper(
253
        ORIGIN,
254
        "get",
255
        "protocol=http\nhost=openagents.com\n\n",
256
        &store,
257
    )
258
    .unwrap();
259
    assert_eq!(wrong_scheme, "");
260
}
261
262
/// No credential is a silence, never an invented password. A fabricated one
263
/// would make git retry against the forge with something never issued.
264
#[test]
265
fn the_credential_helper_says_nothing_when_it_holds_nothing() {
266
    let directory = tempfile::tempdir().unwrap();
267
    let store = CredentialStore::isolated(ORIGIN, directory.path());
268
    let answer = run_git_credential_helper(
269
        ORIGIN,
270
        "get",
271
        "protocol=https\nhost=openagents.com\n\n",
272
        &store,
273
    )
274
    .unwrap();
275
    assert_eq!(answer, "");
276
}
277
278
/// `erase` removes the credential; `store` is a no-op because the token is
279
/// issued by the device flow, not by git.
280
#[test]
281
fn the_credential_helper_erases_and_ignores_store() {
282
    let directory = tempfile::tempdir().unwrap();
283
    let store = CredentialStore::isolated(ORIGIN, directory.path());
284
    store.store(&Secret::new("oa_pat_erasable")).unwrap();
285
286
    let request = "protocol=https\nhost=openagents.com\n\n";
287
    assert_eq!(
288
        run_git_credential_helper(ORIGIN, "store", request, &store).unwrap(),
289
        ""
290
    );
291
    assert!(store.find_token().unwrap().is_some());
292
293
    assert_eq!(
294
        run_git_credential_helper(ORIGIN, "erase", request, &store).unwrap(),
295
        ""
296
    );
297
    assert!(store.find_token().unwrap().is_none());
298
299
    assert!(run_git_credential_helper(ORIGIN, "invent", request, &store).is_err());
300
}
301
302
#[test]
303
fn an_oversized_credential_request_is_refused() {
304
    let directory = tempfile::tempdir().unwrap();
305
    let store = CredentialStore::isolated(ORIGIN, directory.path());
306
    let request = format!("protocol=https\nhost=openagents.com\n{}", "x".repeat(9_000));
307
    assert!(run_git_credential_helper(ORIGIN, "get", &request, &store).is_err());
308
}
309
310
// ---------------------------------------------------------------------------
311
// #77 setup-git
312
// ---------------------------------------------------------------------------
313
314
/// Acceptance 4 on both issues: `setup-git` writes a real git config entry.
315
/// The old command printed a sentence and configured nothing, so this fails by
316
/// construction against it.
317
#[test]
318
fn setup_git_writes_the_credential_helper_into_git_config() {
319
    let directory = tempfile::tempdir().unwrap();
320
    init_repository(directory.path());
321
322
    let key = format!("credential.{ORIGIN}.helper");
323
    let before = git(directory.path(), &["config", "--local", "--get-all", &key]);
324
    assert_eq!(before.trim(), "", "the checkout starts unconfigured");
325
    assert!(!credential_helper_state(ORIGIN, Some(directory.path())).0);
326
327
    configure_credential_helper(ORIGIN, "local", Some(directory.path())).unwrap();
328
329
    let after = git(directory.path(), &["config", "--local", "--get-all", &key]);
330
    // Two entries, in order: an empty value, which is git's way of discarding
331
    // any helper inherited from a wider scope, and then this CLI. That pair is
332
    // what lets a private clone succeed with no other credential present.
333
    let values: Vec<&str> = after.trim_end_matches('\n').split('\n').collect();
334
    assert_eq!(
335
        values,
336
        vec!["", &credential_helper_command(ORIGIN)[..]],
337
        "{after}"
338
    );
339
    assert!(values[1].starts_with('!'), "{after}");
340
    assert!(values[1].ends_with(" --api-url https://openagents.com auth git-credential"));
341
    // The helper names this binary. A bare `oa` would be resolved against PATH,
342
    // where an older install answers nothing and the clone falls back to a
343
    // password prompt.
344
    assert!(values[1].contains(&cli_program_path()), "{after}");
345
    assert!(credential_helper_state(ORIGIN, Some(directory.path())).0);
346
347
    // Running it twice must not stack a second copy, which would make git ask
348
    // this CLI for the same credential twice.
349
    configure_credential_helper(ORIGIN, "local", Some(directory.path())).unwrap();
350
    let again = git(directory.path(), &["config", "--local", "--get-all", &key]);
351
    assert_eq!(again, after, "a second setup-git changed the config");
352
    assert_eq!(
353
        again
354
            .lines()
355
            .filter(|line| *line == credential_helper_command(ORIGIN))
356
            .count(),
357
        1,
358
        "{again}"
359
    );
360
}
361
362
/// A clone carries the helper on the command line, so a private repository
363
/// clones with no other credential present.
364
#[test]
365
fn clone_pins_this_cli_as_the_only_credential_helper() {
366
    let argv = git_clone_argv("https://openagents.com/OpenAgentsInc/openagents.git", None);
367
    assert_eq!(argv[1], "credential.helper=", "other helpers are cleared");
368
    assert!(argv[3].starts_with("credential.https://openagents.com.helper=!"));
369
    assert!(argv[3].ends_with(" --api-url https://openagents.com auth git-credential"));
370
    assert_eq!(argv[4], "clone");
371
    assert_eq!(argv[5], "--", "the URL can never be read as an option");
372
}
373
374
// ---------------------------------------------------------------------------
375
// #77 repository resolution
376
// ---------------------------------------------------------------------------
377
378
/// Acceptance 2 on #77: with no argument the repository comes from the origin
379
/// remote. The URL decides, not the remote's name — this project names the
380
/// forge `openagents` and reserves `origin` for the GitHub mirror.
381
#[test]
382
fn the_repository_is_inferred_from_the_remote_url_not_its_name() {
383
    let directory = tempfile::tempdir().unwrap();
384
    init_repository(directory.path());
385
    git(
386
        directory.path(),
387
        &[
388
            "remote",
389
            "add",
390
            "origin",
391
            "https://github.com/OpenAgentsInc/openagents.git",
392
        ],
393
    );
394
    git(
395
        directory.path(),
396
        &[
397
            "remote",
398
            "add",
399
            "openagents",
400
            "https://openagents.com/OpenAgentsInc/openagents.git",
401
        ],
402
    );
403
404
    let inferred = infer_repository(ORIGIN, Some(directory.path())).unwrap();
405
    assert_eq!(inferred, "OpenAgentsInc/openagents");
406
    assert_eq!(
407
        parse_repository_target(&inferred).unwrap(),
408
        ("OpenAgentsInc".to_string(), "openagents".to_string())
409
    );
410
}
411
412
/// A checkout with nothing on this origin refuses and says what it looked at.
413
/// It does not fall back to a default repository.
414
#[test]
415
fn a_checkout_with_no_matching_remote_refuses() {
416
    let directory = tempfile::tempdir().unwrap();
417
    init_repository(directory.path());
418
    git(
419
        directory.path(),
420
        &[
421
            "remote",
422
            "add",
423
            "origin",
424
            "https://github.com/OpenAgentsInc/openagents.git",
425
        ],
426
    );
427
    let error = infer_repository(ORIGIN, Some(directory.path())).expect_err("no forge remote");
428
    let message = error.to_string();
429
    assert!(message.contains("no git remote"), "{message}");
430
    assert!(message.contains("github.com"), "{message}");
431
432
    let empty = tempfile::tempdir().unwrap();
433
    init_repository(empty.path());
434
    let error = infer_repository(ORIGIN, Some(empty.path())).expect_err("no remotes at all");
435
    assert!(error.to_string().contains("no git remotes"), "{error}");
436
}
437
438
#[test]
439
fn remote_urls_must_be_repository_urls_on_this_origin() {
440
    assert!(repository_from_remote_url(ORIGIN, "https://openagents.com/a/b.git").is_ok());
441
    // A path with an extra segment is not a repository URL.
442
    assert!(repository_from_remote_url(ORIGIN, "https://openagents.com/a/b/c.git").is_err());
443
    // Credentials in the URL would be a second, unmanaged credential path.
444
    assert!(repository_from_remote_url(ORIGIN, "https://u:p@openagents.com/a/b.git").is_err());
445
    assert!(repository_from_remote_url(ORIGIN, "git@openagents.com:a/b.git").is_err());
446
}
447
448
// ---------------------------------------------------------------------------
449
// #77 repository client
450
// ---------------------------------------------------------------------------
451
452
/// `repo view` reads the server. It is not a println, so a server it cannot
453
/// reach ends the command rather than printing a repository nobody described.
454
#[tokio::test]
455
async fn repo_view_refuses_when_the_server_cannot_be_reached() {
456
    let client = RepoClient::new("http://127.0.0.1:1", Some(Secret::new("oa_pat_x")));
457
    let error = client
458
        .view("OpenAgentsInc", "openagents")
459
        .await
460
        .expect_err("an unreachable server describes no repository");
461
    assert!(error.to_string().contains("could not view"), "{error}");
462
463
    let error = client
464
        .authenticated_user()
465
        .await
466
        .expect_err("an unreachable server names no account");
467
    assert!(
468
        error
469
            .to_string()
470
            .contains("could not read the authenticated user"),
471
        "{error}"
472
    );
473
}
474
475
#[tokio::test]
476
async fn list_rejects_a_page_size_the_api_will_not_serve() {
477
    let client = RepoClient::new(ORIGIN, Some(Secret::new("oa_pat_x")));
478
    assert!(client.list(None, 0, None).await.is_err());
479
    assert!(client.list(None, 101, None).await.is_err());
480
}
481
482
#[test]
483
fn credential_requests_need_both_the_scheme_and_the_authority() {
484
    let fields = vec![
485
        ("protocol".to_string(), "https".to_string()),
486
        ("host".to_string(), "localhost:4000".to_string()),
487
    ];
488
    assert!(admitted_credential_request(
489
        "http://localhost:4000",
490
        &[
491
            ("protocol".to_string(), "http".to_string()),
492
            ("host".to_string(), "localhost:4000".to_string()),
493
        ]
494
    ));
495
    assert!(!admitted_credential_request(
496
        "http://localhost:4000",
497
        &fields
498
    ));
499
    assert!(!admitted_credential_request(
500
        "https://openagents.com",
501
        &fields
502
    ));
503
}
504
505
/// The legacy `~/.openagents/config.json` predates per-endpoint keying. Its
506
/// token belongs to whichever API the profile names, and to production only
507
/// when it names none. Reading it for any origin would hand a production token
508
/// to a session pointed somewhere else.
509
#[test]
510
fn the_legacy_profile_token_is_admitted_only_for_the_endpoint_it_names() {
511
    use openagents_cli::auth::{AuthConfig, ProfileConfig};
512
513
    let directory = tempfile::tempdir().unwrap();
514
    let write = |api_url: Option<&str>| {
515
        let mut profiles = std::collections::HashMap::new();
516
        profiles.insert(
517
            "default".to_string(),
518
            ProfileConfig {
519
                api_url: api_url.map(str::to_string),
520
                token: Some("oa_pat_legacy".to_string()),
521
                identity_name: None,
522
            },
523
        );
524
        let config = AuthConfig {
525
            default_profile: Some("default".to_string()),
526
            profiles,
527
        };
528
        CredentialStore::isolated(ORIGIN, directory.path())
529
            .save(&config)
530
            .unwrap();
531
    };
532
533
    write(None);
534
    let production = CredentialStore::isolated(ORIGIN, directory.path());
535
    let staging = CredentialStore::isolated("https://staging.openagents.com", directory.path());
536
    assert_eq!(
537
        production.find_token().unwrap().unwrap().source,
538
        TokenSource::LegacyConfig
539
    );
540
    assert!(
541
        staging.find_token().unwrap().is_none(),
542
        "an unlabelled legacy token must not answer for staging"
543
    );
544
545
    write(Some("https://staging.openagents.com"));
546
    assert!(
547
        production.find_token().unwrap().is_none(),
548
        "a staging-labelled legacy token must not answer for production"
549
    );
550
    assert!(staging.find_token().unwrap().is_some());
551
}
crates/openagents-cli/tests/cli_test.rs modified +27 -6

@@ -9,7 +9,7 @@ mod tests {

9 9
    use openagents_cli::auth::CredentialStore;
10 10
    use openagents_cli::identity::{derive_seed_identity, SeedStore};
11 11
    use openagents_cli::tracker::{slug_from_remote_url, IssueListOptions, RepoTarget, TrackerClient};
12
    use openagents_cli::repo::handle_git_credential;
12
    use openagents_cli::repo::{admitted_credential_request, parse_git_credential_request};
13 13
    use openagents_cli::box_client::BoxClient;
14 14
    use openagents_cli::computer::probe_host;
15 15
    use openagents_cli::forum::ForumClient;

@@ -17,11 +17,23 @@ mod tests {

17 17
    use openagents_cli::api_passthrough::ApiPassthroughClient;
18 18
    use openagents_cli::trace::{default_trace_stores, redact_text};
19 19
20
    /// The old assertion read `store.load().unwrap().default_profile.is_some()`,
21
    /// which was true because `load` synthesizes a default profile when the file
22
    /// is absent. It would have passed against a store that could neither read
23
    /// nor write a token. The contract now lives in `tests/auth_repo_test.rs`;
24
    /// this keeps a round trip through the real store here.
20 25
    #[test]
21 26
    fn test_auth_and_credential_store_issue_74() {
22
        let store = CredentialStore::new(None);
23
        let config = store.load().unwrap();
24
        assert!(config.default_profile.is_some());
27
        let directory = tempfile::tempdir().unwrap();
28
        let store = CredentialStore::isolated("https://openagents.com", directory.path());
29
        assert!(store.find_token().unwrap().is_none());
30
        store
31
            .store(&openagents_cli::auth::Secret::new("oa_pat_roundtrip"))
32
            .unwrap();
33
        let held = store.find_token().unwrap().expect("the token just stored");
34
        assert_eq!(held.token.expose(), "oa_pat_roundtrip");
35
        assert!(store.remove().unwrap());
36
        assert!(store.find_token().unwrap().is_none());
25 37
    }
26 38
27 39
    /// The old assertion checked only that the strings began `npub1`/`nsec1`, which

@@ -154,10 +166,19 @@ mod tests {

154 166
        );
155 167
    }
156 168
169
    /// The old assertion checked that the helper output named a username. It
170
    /// passed against a function that answered *every* host with the token,
171
    /// including github.com, because it never read git's request. The helper now
172
    /// parses the request and admits only the selected origin, which is what
173
    /// `tests/auth_repo_test.rs` asserts end to end.
157 174
    #[test]
158 175
    fn test_repo_and_git_credential_issue_77() {
159
        let cred_str = handle_git_credential("get", "openagents.com", Some("oa_pat_12345"));
160
        assert!(cred_str.contains("username=openagents-token"));
176
        let request = parse_git_credential_request("protocol=https\nhost=openagents.com\n\n");
177
        assert!(admitted_credential_request(
178
            "https://openagents.com",
179
            &request
180
        ));
181
        assert!(!admitted_credential_request("https://github.com", &request));
161 182
    }
162 183
163 184
    /// The old assertion was `boxes.is_empty() || !boxes.is_empty()` against the

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