OXYGENHUB

Fall back from a checkout's own token only when the hub does not know it (401), never when it refuses it for permission (refs #155) 0588de69a84d

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 912fd3b85660
When
· 2026-10-28 22:30 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
0588de69a84d807a47d1013e20ecfaffe92fa1016b3470d10807e130edbcfb94
Depends on
2b5ee371060e 569802ebae0e 5b9c6ad45f6e 642da2636de2 68a924846379 750251b21a96 75167672ebcd 8fd78eb52ad2 982864068a59 e4595038502a ff1f62fee654
Depended on by
6c3dbe2018e0 a42f9fb47e37
In bookmarks
main
In tags
v0.10.0 v0.11.0 v0.9.0
Edits
12 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −1
…
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 +
- **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 checkout token the hub no longer knows (401) 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, and not when the token is refused for permission (403, 404), so a narrowly scoped token is never swapped for a wider sign-in. When both are refused, the error says to sign in again and unset it.
6 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.
7 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`).
8 8
…
299 unchanged lines
modified crates/oxy-core/src/protocol.rs +12 −1
…
318 unchanged lines
319 319
    /// `token` is the remote's own (oxygen#155), or those of enclosing
320 320
    /// checkouts when it came from the credential store (oxygen#112).
321 321
    pub fallback_tokens: Vec<String>,
322 +
    /// `token` is the remote's own: fall back only when the hub says it is
323 +
    /// not a token at all (HTTP 401: expired or unknown), never when it is
324 +
    /// one that may not do this (403, 404), so a narrowly scoped token is
325 +
    /// not quietly swapped for a wider sign-in (oxygen#155).
326 +
    pub fallback_on_401_only: bool,
322 327
}
323 328
324 329
/// Opens a store for a URL it understands, or passes (`None`). Stores that
…
65 unchanged lines
390 395
    /// The token in use, and those still to try when it is refused.
391 396
    token: Mutex<Option<String>>,
392 397
    fallbacks: Mutex<Vec<String>>,
398 +
    fallback_on_401_only: bool,
393 399
    repo_id: Option<[u8; 16]>,
394 400
    agent: ureq::Agent,
395 401
}
…
41 unchanged lines
437 443
            base: base.trim_end_matches('/').to_string(),
438 444
            token: Mutex::new(options.token.clone()),
439 445
            fallbacks: Mutex::new(options.fallback_tokens.iter().rev().cloned().collect()),
446 +
            fallback_on_401_only: options.fallback_on_401_only,
440 447
            repo_id: options.repo_id,
441 448
            agent,
442 449
        })
…
5 unchanged lines
448 455
        loop {
449 456
            let token = self.token.lock().unwrap_or_else(|p| p.into_inner()).clone();
450 457
            match self.call_once(post, endpoint, body, token.as_deref()) {
451 -
                Err(RepoError::Msg(m)) if token.is_some() && refused(&m) => {
458 +
                Err(RepoError::Msg(m))
459 +
                    if token.is_some()
460 +
                        && refused(&m)
461 +
                        && (!self.fallback_on_401_only || m.contains(": HTTP 401")) =>
462 +
                {
452 463
                    let next = self
453 464
                        .fallbacks
454 465
                        .lock()
…
1343 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +4 −3
…
1412 unchanged lines
1413 1413
        let env = std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty());
1414 1414
        let explicit = own || env;
1415 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).
1416 +
        // A checkout's own token refused as expired or unknown (401) falls
1417 +
        // back to this computer's sign-in for the hub, never to another
1418 +
        // checkout's, and never when it is refused for permission (#155).
1419 1419
        if own && !env {
1420 1420
            if let Some(t) = stored
1421 1421
                .token
…
1 unchanged lines
1423 1423
                .filter(|t| options.token.as_ref() != Some(t))
1424 1424
            {
1425 1425
                options.fallback_tokens.push(t);
1426 +
                options.fallback_on_401_only = true;
1426 1427
            }
1427 1428
        }
1428 1429
        options.token = options.token.take().or(stored.token);
…
1494 unchanged lines
modified crates/oxy-server/tests/http.rs +5 −0
…
728 unchanged lines
729 729
        vec!["good".to_string()],
730 730
        "the parent's, to fall back on"
731 731
    );
732 +
    assert!(!options.fallback_on_401_only);
732 733
    write(&nested, "c.txt", b"child\n");
733 734
    child.commit("child work", &[]).unwrap();
734 735
    let pushed = child.push("origin", false).unwrap();
…
18 unchanged lines
753 754
        vec!["stale".to_string()],
754 755
        "the sign-in only"
755 756
    );
757 +
    assert!(
758 +
        options.fallback_on_401_only,
759 +
        "and only for a token the hub does not know: one refused for permission is not swapped"
760 +
    );
756 761
    write(&nested, "d.txt", b"more\n");
757 762
    child.commit("more", &[]).unwrap();
758 763
    let err = child.push("origin", false).err().unwrap().to_string();
…
467 unchanged lines