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 | } |