OXYGENHUB

oh signs in through oxy's credential store; pin oxygen with oxy auth and remote bookmarks (refs andymccall/oxygen#47, #48) 2bbe88a85860

oh auth login saves its token in oxy's credential store, and oh looks
tokens up there, so one sign-in serves oh and oxy alike;
~/.config/oh/config.toml keeps only the default hub. The pinned oxygen no
longer copies a token into a subrepo (it borrows the parent's when it
fetches), names remote bookmarks, and clones the repository's default
bookmark, which the hub already reports.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 13:49 UTC
Signature
✔ signed by Andy McCall
Hash
2bbe88a8586052df5c4a8b95725cbec3b70fed875e889918d7d5351f26a5369d
Depends on
19bb31ff1f5c 380996db84e5 42fa95540468 5c6e92fd8d71 60f11b71a740 a4f01d51c2a7 b5668108487f c4c87f481d05
Depended on by
d305b3b0afdc
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
15 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **One sign-in for oh and oxy (oxygen#47).** `oh auth login` keeps its token in oxy's credential store (`~/.config/oxy/credentials.toml`, or the keyring through `auth.helper`), and `oh` finds tokens there, so `oxy auth login` signs `oh` in too and the reverse. `~/.config/oh/config.toml` now holds only the default hub. Pinned oxygen brings remote bookmarks by name, and clones check out the repository's default bookmark.
8 +
7 9
- **Device tokens expire, and the approval page says where a request came from (#70).** A token from device sign-in lasts 90 days past its last use; Settings → Tokens shows its expiry, renewed on use. The `/device` page shows when the code was asked for and from which address (the client's, through the proxy when `behind_proxy` is set), and warns to approve only a code you started yourself. `DELETE /api/v1/user/token` revokes the token it is called with, which `oxy auth logout` uses.
8 10
9 11
- **Device sign-in (#67).** An app gets a token without a pasted secret. It asks `/api/v1/device/code` for a code and shows it; you approve the code at `/device`, signed in with your second factor, choosing what the app may do; its next poll gets the token. `oh auth login` signs in this way; `--token` still takes a token.
…
11 unchanged lines
modified crates/hub/tests/accounts.rs +11 −0
…
187 unchanged lines
188 188
    std::env::remove_var("OXY_TOKEN");
189 189
    assert!(ok);
190 190
191 +
    // oxygen#47: signed in once (oxy auth login), a private repository
192 +
    // clones with no OXY_TOKEN, and the clone keeps no token of its own.
193 +
    let store = oxy_core::credentials::Credentials::user();
194 +
    let hub = oxy_core::credentials::hub_of(&h.base).unwrap();
195 +
    store.save(&hub, &rw, Some("andy"), None).unwrap();
196 +
    let signed_in = Repo::clone(&format!("{}/andy/secret", h.base), &work.path().join("signedin"));
197 +
    store.remove(&hub).unwrap();
198 +
    let signed_in = signed_in.unwrap();
199 +
    assert!(signed_in.config.remote_tokens.is_empty());
200 +
    assert_eq!(signed_in.bookmark_heads().unwrap(), vec![c1]);
201 +
191 202
    // Linking a key: upload the identity, sign the challenge, see attribution.
192 203
    let (key, identity) = {
193 204
        let k = client.config.author_key.unwrap();
…
276 unchanged lines
modified crates/hub/tests/flow.rs +4 −3
…
447 unchanged lines
448 448
    let app = client(&format!("{}/andy/app", h.base), work.path());
449 449
    app.submodule_add(&format!("{}/andy/lib", h.base), "deps/lib", Some("main")).unwrap();
450 450
    // Vault is private: a checkout with no token for this hub cannot add
451 -
    // it, but the app's remote on the same hub lends its token (oxygen#32),
452 -
    // which the subrepo then records for its own fetches.
451 +
    // it, but the app's remote on the same hub lends its token (oxygen#32).
452 +
    // The subrepo keeps no copy: its own fetches find the parent's (oxygen#47).
453 453
    sqlx::query("UPDATE repositories SET visibility = 'private' WHERE name = 'vault'").execute(&h.app.db).await.unwrap();
454 454
    let bare_work = tempfile::tempdir().unwrap();
455 455
    let mut bare = Repo::init(bare_work.path()).unwrap();
…
2 unchanged lines
458 458
    assert!(bare.submodule_add(&format!("{}/andy/vault", h.base), "deps/vault", Some("main")).is_err(), "not without a token");
459 459
    app.submodule_add(&format!("{}/andy/vault", h.base), "deps/vault", Some("main")).unwrap();
460 460
    let nested = Repo::open(&work.path().join("deps/vault")).unwrap();
461 -
    assert_eq!(nested.config.remote_tokens.get("origin").map(String::as_str), Some("site"));
461 +
    assert!(nested.config.remote_tokens.is_empty(), "no token is copied into a subrepo");
462 +
    nested.fetch("origin").unwrap();
462 463
    // The pusher below is the site token, which has no account: it cannot read vault.
463 464
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
464 465
    write(work.path(), "Makefile", "all:\n");
…
866 unchanged lines
modified crates/oh/src/main.rs +8 −3
…
10 unchanged lines
11 11
use client::Client;
12 12
use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release};
13 13
use std::time::Duration;
14 -
use target::{Choice, Config, Host, Target};
14 +
use target::{Choice, Config, Target};
15 15
16 16
#[derive(Parser)]
17 17
#[command(name = "oh", version = env!("OXY_VERSION_LONG"), about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")]
…
302 unchanged lines
320 320
    }
321 321
    let t = Target { hub: hub.clone(), token: Some(token.clone()), ca: ca.clone(), owner: String::new(), name: String::new() };
322 322
    let me: serde_json::Value = Client::new(&t)?.get("/user").context("checking the token")?;
323 -
    cfg.hosts.insert(hub.clone(), Host { token, ca });
323 +
    let account = me["username"].as_str().unwrap_or("?");
324 +
    // One sign-in for oh and oxy alike: the token goes to oxy's store.
325 +
    let key = oxy_core::credentials::hub_of(&hub).with_context(|| format!("{hub} is not a hub URL"))?;
326 +
    let ca = ca.map(|c| std::fs::canonicalize(&c).unwrap_or(c));
327 +
    oxy_core::credentials::Credentials::user().save(&key, &token, Some(account), ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
324 328
    cfg.default_hub = Some(hub.clone());
325 329
    cfg.save()?;
326 -
    println!("{} signed in to {hub} as {}", ui::ok("✔"), ui::strong(me["username"].as_str().unwrap_or("?")));
330 +
    println!("{} signed in to {hub} as {}", ui::ok("✔"), ui::strong(account));
331 +
    println!("{}", ui::dim("  oxy uses this sign-in too; oxy auth status lists it, oxy auth logout ends it"));
327 332
    Ok(0)
328 333
}
329 334
…
451 unchanged lines
modified crates/oh/src/target.rs +11 −7
…
7 unchanged lines
8 8
9 9
use anyhow::{anyhow, bail, Context, Result};
10 10
use serde::{Deserialize, Serialize};
11 -
use std::collections::BTreeMap;
12 11
use std::path::{Path, PathBuf};
13 12
14 -
/// `~/.config/oh/config.toml`: tokens and CAs by hub, and the default hub.
13 +
/// `~/.config/oh/config.toml`: the default hub. Tokens are oxy's: one
14 +
/// sign-in (`oh auth login` or `oxy auth login`) serves both, kept in
15 +
/// oxy's credential store (oxygen#47).
15 16
#[derive(Debug, Default, Serialize, Deserialize)]
16 17
pub struct Config {
17 18
    #[serde(default)]
18 19
    pub default_hub: Option<String>,
19 -
    #[serde(default)]
20 -
    pub hosts: BTreeMap<String, Host>,
21 20
}
22 21
23 -
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
22 +
/// What oxy's credential store holds for a hub.
23 +
#[derive(Clone, Debug, Default)]
24 24
pub struct Host {
25 25
    pub token: String,
26 -
    #[serde(default)]
27 26
    pub ca: Option<PathBuf>,
27 +
}
28 +
29 +
fn stored(hub: &str) -> Host {
30 +
    let (token, ca) = oxy_core::credentials::Credentials::user().lookup(hub);
31 +
    Host { token: token.unwrap_or_default(), ca }
28 32
}
29 33
30 34
pub fn config_path() -> Result<PathBuf> {
…
82 unchanged lines
113 117
pub fn resolve(choice: &Choice, dir: &Path, cfg: &Config) -> Result<Target> {
114 118
    let env_token = std::env::var("OH_TOKEN").ok().or_else(|| std::env::var("OXY_TOKEN").ok()).filter(|t| !t.is_empty());
115 119
    let env_ca = std::env::var_os("OXY_CA").map(PathBuf::from);
116 -
    let saved = |hub: &str| cfg.hosts.get(hub).cloned();
120 +
    let saved = |hub: &str| Some(stored(hub));
117 121
    if let Some(r) = choice.repo {
118 122
        let (owner, name) = r.split_once('/').ok_or_else(|| anyhow!("--repo takes OWNER/NAME"))?;
119 123
        let hub = choice
…
68 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.