OXYGENHUB

A refused checkout token falls back to this computer's sign-in for the hub, and says so (closes #155) 569802ebae0e

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 33c2d662db09
When
· 2026-10-28 22:21 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
569802ebae0e3bfafda3eecd7042043c5465f609240efcd9c44be88190ca5d07
Depends on
135212de5370 2863456a0f6d 2b5ee371060e 5b9c6ad45f6e 642da2636de2 68a924846379 750251b21a96 83e0677b07fe 8fd78eb52ad2 982864068a59 c6aa5bd9d1c8 cb8b8082fa73 e4595038502a fce44ae9ad29 ff1f62fee654
Depended on by
0588de69a84d
In bookmarks
main
In tags
v0.10.0 v0.11.0 v0.9.0
Edits
18 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Fixed: an expired checkout token no longer hides your sign-in (#155).** A checkout's own token for a remote (`remote.NAME.token`) is used first, so when it expired every fetch, pull and push answered 401 although `oxy auth login` had a good sign-in for the same hub. A refused own token now falls back to that sign-in, with a warning naming `oxy config --unset remote.NAME.token` to drop the stale one; never to another checkout's token. When both are refused, the error says to sign in again and unset it.
5 6
- **Added: artifacts (Cylinder, step C1, #108).** `oxy artifact pack DIR --kind archive|package|container|runner-image|report [--arch --os --entrypoint --env K=V --label K=V]` packs a directory into an artifact: each file an ordinary blob of the repository's store, so files and chunks it holds already cost nothing, under a tree and an artifact encoding in canonical CBOR, named by the BLAKE3 of that encoding (docs/cylinder.md §4.1). `oxy artifact list | show NAME | unpack NAME DEST | verify NAME` (any unique prefix of six characters or more; `--json` on list, show and pack), and `oxy gc` keeps every artifact's blobs. The new `cylinder` crate holds the model.
6 7
- **Added: oxy on Haiku (#154).** oxy builds and runs on Haiku R1/beta6 (x86_64) with HaikuPorts' Rust; `oxy-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.8.0, with its own `SHA256SUMS-haiku`. The README says how to install it and how to build it (`ZSTD_SYS_USE_PKG_CONFIG=1`).
7 8
…
299 unchanged lines
modified crates/oxy-core/src/protocol.rs +5 −4
…
314 unchanged lines
315 315
    /// a temporary directory.
316 316
    pub cache_dir: Option<PathBuf>,
317 317
    /// Other tokens for the same hub, tried in turn when `token` is
318 -
    /// refused (401, 403 or 404): those of enclosing checkouts, when
319 -
    /// `token` came from the credential store rather than from this
320 -
    /// remote's own configuration (oxygen#112).
318 +
    /// refused (401, 403 or 404): the credential store's sign-in when
319 +
    /// `token` is the remote's own (oxygen#155), or those of enclosing
320 +
    /// checkouts when it came from the credential store (oxygen#112).
321 321
    pub fallback_tokens: Vec<String>,
322 322
}
323 323
…
134 unchanged lines
458 458
                        return Err(RepoError::Msg(m));
459 459
                    };
460 460
                    crate::credentials::warn_once(&format!(
461 -
                        "{}: the stored token was refused; using the one an enclosing checkout has for this hub",
461 +
                        "{}: the token in use was refused; trying another this computer has for the hub \
462 +
                         (a stale remote.NAME.token goes with `oxy config --unset remote.NAME.token`)",
462 463
                        self.base
463 464
                    ));
464 465
                    *self.token.lock().unwrap_or_else(|p| p.into_inner()) = Some(next);
…
1332 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +33 −2
…
1408 unchanged lines
1409 1409
        if options.token.is_some() && options.ca.is_some() {
1410 1410
            return;
1411 1411
        }
1412 -
        let explicit =
1413 -
            options.token.is_some() || std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty());
1412 +
        let own = options.token.is_some();
1413 +
        let env = std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty());
1414 +
        let explicit = own || env;
1414 1415
        let stored = self.credentials().options_for(url);
1416 +
        // A checkout's own token refused (expired, say) falls back to this
1417 +
        // computer's sign-in for the hub, never to another checkout's
1418 +
        // (oxygen#155).
1419 +
        if own && !env {
1420 +
            if let Some(t) = stored
1421 +
                .token
1422 +
                .clone()
1423 +
                .filter(|t| options.token.as_ref() != Some(t))
1424 +
            {
1425 +
                options.fallback_tokens.push(t);
1426 +
            }
1427 +
        }
1415 1428
        options.token = options.token.take().or(stored.token);
1416 1429
        options.ca = options.ca.take().or(stored.ca);
1417 1430
        let enclosing = self.enclosing_credentials(url);
…
9 unchanged lines
1427 1440
                    options.fallback_tokens.push(t);
1428 1441
                }
1429 1442
            }
1443 +
        }
1444 +
    }
1445 +
1446 +
    /// What to do when `remote`'s own token was refused (`message` says
1447 +
    /// HTTP 401) and so was this computer's sign-in for its hub, which it
1448 +
    /// falls back to (oxygen#155).
1449 +
    pub fn stale_token_note(&self, remote: &str, message: &str) -> Option<String> {
1450 +
        if !message.contains("HTTP 401") {
1451 +
            return None;
1430 1452
        }
1453 +
        let own = self.config.remote_tokens.get(remote)?;
1454 +
        let url = self.config.remotes.get(remote)?;
1455 +
        let stored = self.credentials().options_for(url).token?;
1456 +
        (stored != *own).then(|| {
1457 +
            format!(
1458 +
                "the hub refused both this checkout's own token for {remote} (remote.{remote}.token) and your sign-in: \
1459 +
                 `oxy auth login HUB`, then `oxy config --unset remote.{remote}.token` so the sign-in is used"
1460 +
            )
1461 +
        })
1431 1462
    }
1432 1463
1433 1464
    /// The tokens and CAs of remotes on `url`'s hub in enclosing
…
1458 unchanged lines
modified crates/oxy-server/tests/http.rs +22 −1
…
741 unchanged lines
742 742
        1
743 743
    );
744 744
745 -
    // Its own token, if wrong, stays wrong: what a checkout sets is its choice.
745 +
    // Its own token, if refused, falls back to this computer's sign-in,
746 +
    // never to the parent's (#155): stale too here, so the push fails and
747 +
    // says what to do.
746 748
    child.config.set("remote.origin.token", "wrong").unwrap();
747 749
    child.save_config().unwrap();
750 +
    let options = child.options_for_url(&format!("{base}/child"));
751 +
    assert_eq!(
752 +
        options.fallback_tokens,
753 +
        vec!["stale".to_string()],
754 +
        "the sign-in only"
755 +
    );
748 756
    write(&nested, "d.txt", b"more\n");
749 757
    child.commit("more", &[]).unwrap();
750 758
    let err = child.push("origin", false).err().unwrap().to_string();
751 759
    assert!(err.contains("401"), "{err}");
760 +
    let note = child.stale_token_note("origin", &err).expect("a note");
761 +
    assert!(
762 +
        note.contains("oxy auth login") && note.contains("oxy config --unset remote.origin.token"),
763 +
        "{note}"
764 +
    );
765 +
    assert!(child.stale_token_note("origin", "HTTP 500").is_none());
766 +
    // With a good sign-in, an expired checkout token no longer blocks.
767 +
    oxy_core::credentials::Credentials::at(&home)
768 +
        .save(&base, "good", Some("andy"), None)
769 +
        .unwrap();
770 +
    let child = Repo::open(&nested).unwrap().with_home(&home);
771 +
    let pushed = child.push("origin", false).unwrap();
772 +
    assert!(pushed[0].ok, "{}", pushed[0].reason);
752 773
}
753 774
754 775
/// A host refuses a push by what it adds (`Backend::vet_push`): the client
…
450 unchanged lines
modified src/cli/sync.rs +21 −5
…
371 unchanged lines
372 372
    let repo = open()?;
373 373
    let remote = remote_name(&repo, remote);
374 374
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
375 -
    let report = repo.fetch(&remote)?;
375 +
    let report = noted(&repo, &remote, repo.fetch(&remote))?;
376 376
    sp.clear();
377 377
    if as_json {
378 378
        println!(
…
149 unchanged lines
528 528
    let repo = open()?;
529 529
    let remote = remote_name(&repo, remote);
530 530
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
531 -
    let r = repo.pull(&remote)?;
531 +
    let r = noted(&repo, &remote, repo.pull(&remote))?;
532 532
    if as_json {
533 533
        sp.clear();
534 534
        let conflicted = repo
…
138 unchanged lines
673 673
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
674 674
    let bookmark_name = bookmark.map_or_else(|| repo.config.bookmark.clone(), String::from);
675 675
    // A long push goes in parts: the spinner says which.
676 -
    let results = repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| {
677 -
        sp.set(&format!("Pushing to {remote}… {m}"))
678 -
    })?;
676 +
    let results = noted(
677 +
        &repo,
678 +
        &remote,
679 +
        repo.push_bookmark_with(&remote, &bookmark_name, force, &mut |m| {
680 +
            sp.set(&format!("Pushing to {remote}… {m}"))
681 +
        }),
682 +
    )?;
679 683
    sp.clear();
680 684
    if as_json {
681 685
        println!(
…
484 unchanged lines
1166 1170
        }
1167 1171
    ));
1168 1172
    Ok(0)
1173 +
}
1174 +
1175 +
/// `r`, with a note when the remote's own token was refused while another
1176 +
/// sign-in for its hub would do (oxygen#155).
1177 +
fn noted<T>(repo: &oxy_core::repo::Repo, remote: &str, r: oxy_core::repo::Result<T>) -> Result<T> {
1178 +
    r.map_err(|e| {
1179 +
        let m = e.to_string();
1180 +
        match repo.stale_token_note(remote, &m) {
1181 +
            Some(note) => anyhow::anyhow!("{m}\n  note: {note}"),
1182 +
            None => e.into(),
1183 +
        }
1184 +
    })
1169 1185
}