OXYGENHUB

Fall back to a parent's token when a stored one is refused; send the signer's identity with a bookmark move 68a924846379

Two things that stopped agents tonight.

A subrepo checkout with no token of its own took the user's credential
store before an enclosing checkout's remote on the same hub. A stale
stored login (or another account's) then won, and a private subrepo
answered 404 although the parent's own token reads it (the R* agent and
rstar-api). Now, when the token came from the store or an enclosing
checkout, the other tokens enclosing checkouts hold for that hub travel
as RemoteOptions::fallback_tokens, and the HTTP remote tries the next
one when a request is refused (401, 403 or 404), saying so once. A
remote's own token and $OXY_TOKEN are explicit and never swapped.

A delegate that may move references made a bookmark at its owner's
change and was refused 'unknown key': a push carried only the identities
of its changes' authors, so a host that had seen no change by the
delegate could not check its attestation. A push that moves a bookmark
now carries the identity that signed the move and the one it acts for.

Tested: a nested checkout with a stale stored login pushes with its
parent's token (and one with its own wrong token still fails); a
delegate's first push of a bookmark at its owner's change is accepted
(without the fix: unknown key).

closes #112, refs andymccall/oxygenhub#129
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 04:06 UTC
Signature
✔ signed by Andy McCall
Hash
68a9248463792051b4dfa1b01061a7c7f2ed33ac4c63126b0cca064b3bd52437
Depends on
096f447ccda0 2b5ee371060e 5b9c6ad45f6e 6f8aeb996558 750251b21a96 75167672ebcd 7ba155f20aa0 84b9e9ac0ab4 982864068a59 9f9e72b93e4e be451356e3ef ee06a6c557b7 ff0af1ee299d ff1a64f81c1c ff1f62fee654
Depended on by
0588de69a84d 569802ebae0e ee130dd9eef2 67a4fe1aa2d5 d36ba2f79f95 f0a517d28237
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
31 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/oxy-core/src/credentials.rs +2 −3
…
333 unchanged lines
334 334
        RemoteOptions {
335 335
            token: env_token.or(token),
336 336
            ca: env_ca.or(ca),
337 -
            repo_id: None,
338 -
            cache_dir: None,
337 +
            ..RemoteOptions::default()
339 338
        }
340 339
    }
341 340
}
…
9 unchanged lines
351 350
    cache().lock().unwrap().clear();
352 351
}
353 352
354 -
fn warn_once(message: &str) {
353 +
pub(crate) fn warn_once(message: &str) {
355 354
    static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
356 355
    if SEEN
357 356
        .get_or_init(Default::default)
…
220 unchanged lines
modified crates/oxy-core/src/protocol.rs +52 −3
…
229 unchanged lines
230 230
    /// `.oxy/remotes`. None (a clone, before there is a repository) means
231 231
    /// a temporary directory.
232 232
    pub cache_dir: Option<PathBuf>,
233 +
    /// Other tokens for the same hub, tried in turn when `token` is
234 +
    /// refused (401, 403 or 404): those of enclosing checkouts, when
235 +
    /// `token` came from the credential store rather than from this
236 +
    /// remote's own configuration (oxygen#112).
237 +
    pub fallback_tokens: Vec<String>,
233 238
}
234 239
235 240
/// Opens a store for a URL it understands, or passes (`None`). Stores that
…
35 unchanged lines
271 276
/// A remote served by `oxy-server` at `<base>/oxy/v1/...`.
272 277
pub struct HttpRemote {
273 278
    base: String,
274 -
    token: Option<String>,
279 +
    /// The token in use, and those still to try when it is refused.
280 +
    token: Mutex<Option<String>>,
281 +
    fallbacks: Mutex<Vec<String>>,
275 282
    repo_id: Option<[u8; 16]>,
276 283
    agent: ureq::Agent,
277 284
}
…
38 unchanged lines
316 323
            .build();
317 324
        Ok(HttpRemote {
318 325
            base: base.trim_end_matches('/').to_string(),
319 -
            token: options.token.clone(),
326 +
            token: Mutex::new(options.token.clone()),
327 +
            fallbacks: Mutex::new(options.fallback_tokens.iter().rev().cloned().collect()),
320 328
            repo_id: options.repo_id,
321 329
            agent,
322 330
        })
323 331
    }
324 332
333 +
    /// A request, tried again with the next fallback token while the
334 +
    /// hub refuses the one in use (oxygen#112).
325 335
    fn call(&self, post: bool, endpoint: &str, body: Option<&[u8]>) -> Result<Vec<u8>> {
336 +
        loop {
337 +
            let token = self.token.lock().unwrap_or_else(|p| p.into_inner()).clone();
338 +
            match self.call_once(post, endpoint, body, token.as_deref()) {
339 +
                Err(RepoError::Msg(m)) if token.is_some() && refused(&m) => {
340 +
                    let next = self
341 +
                        .fallbacks
342 +
                        .lock()
343 +
                        .unwrap_or_else(|p| p.into_inner())
344 +
                        .pop();
345 +
                    let Some(next) = next else {
346 +
                        return Err(RepoError::Msg(m));
347 +
                    };
348 +
                    crate::credentials::warn_once(&format!(
349 +
                        "{}: the stored token was refused; using the one an enclosing checkout has for this hub",
350 +
                        self.base
351 +
                    ));
352 +
                    *self.token.lock().unwrap_or_else(|p| p.into_inner()) = Some(next);
353 +
                }
354 +
                other => return other,
355 +
            }
356 +
        }
357 +
    }
358 +
359 +
    fn call_once(
360 +
        &self,
361 +
        post: bool,
362 +
        endpoint: &str,
363 +
        body: Option<&[u8]>,
364 +
        token: Option<&str>,
365 +
    ) -> Result<Vec<u8>> {
326 366
        let url = format!("{}/oxy/v1/{endpoint}", self.base);
327 367
        let mut req = if post {
328 368
            self.agent.post(&url)
…
1 unchanged lines
330 370
            self.agent.get(&url)
331 371
        };
332 372
        req = req.set("Accept", CONTENT_TYPE);
333 -
        if let Some(t) = &self.token {
373 +
        if let Some(t) = token {
334 374
            req = req.set("Authorization", &format!("Bearer {t}"));
335 375
        }
336 376
        if let Some(id) = &self.repo_id {
…
20 unchanged lines
357 397
            Err(e) => Err(RepoError::Msg(with_hint(e.to_string()))),
358 398
        }
359 399
    }
400 +
}
401 +
402 +
/// Whether a failed request's message says the hub refused its token:
403 +
/// HTTP 401, 403, or 404 (a hub hides a private repository from a token
404 +
/// that may not read it).
405 +
fn refused(message: &str) -> bool {
406 +
    [": HTTP 401", ": HTTP 403", ": HTTP 404"]
407 +
        .iter()
408 +
        .any(|c| message.contains(c))
360 409
}
361 410
362 411
/// Refuse to send a token where anyone on the way could read it: plain
…
882 unchanged lines
modified crates/oxy-core/src/repo/hosted.rs +37 −0
…
630 unchanged lines
631 631
        ));
632 632
    }
633 633
634 +
    /// oxygenhub#129: a delegate that may move references makes a bookmark
635 +
    /// at its owner's change, signing no change of its own. The push
636 +
    /// carries its identity, so the host can check the move (it was
637 +
    /// refused as "unknown key" until the delegate had pushed a change).
638 +
    #[test]
639 +
    fn a_delegate_s_first_push_of_a_bookmark_carries_its_identity() {
640 +
        let dir = tempfile::tempdir().unwrap();
641 +
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
642 +
        let agent_home = dir.path().join("agent-home");
643 +
        let mut agent_repo = Repo::init(&dir.path().join("agent-work"))
644 +
            .unwrap()
645 +
            .with_home(agent_home.clone());
646 +
        let (agent, agent_id) = agent_repo
647 +
            .create_identity("agent", "agent@example", IdentityScope::User)
648 +
            .unwrap();
649 +
        user.delegate_add(agent_id, Scope::CHANGES | Scope::REFS, None)
650 +
            .unwrap();
651 +
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
652 +
        let work = dir.path().join("work");
653 +
        let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home);
654 +
        as_agent
655 +
            .set_user_config("author.signer", &agent.to_hex())
656 +
            .unwrap();
657 +
        assert!(
658 +
            host.store().get_identity(&agent).is_err(),
659 +
            "the host has never seen the agent"
660 +
        );
661 +
        let heads = as_agent.bookmark_heads().unwrap();
662 +
        as_agent.bookmark_create("feature", Some(&heads)).unwrap();
663 +
        let r = as_agent.push_bookmark("origin", "feature", false).unwrap();
664 +
        assert!(r.iter().all(|x| x.ok), "{r:?}");
665 +
        assert!(
666 +
            host.store().get_identity(&agent).is_ok(),
667 +
            "its identity came with the move"
668 +
        );
669 +
    }
670 +
634 671
    /// A delegation past its expiry authenticates nothing made after it,
635 672
    /// and the delegator may revoke the key; a stranger's key, they may not.
636 673
    #[test]
…
125 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +52 −12
…
913 unchanged lines
914 914
            token: self.config.remote_tokens.get(name).cloned(),
915 915
            ca: self.config.remote_cas.get(name).map(PathBuf::from),
916 916
            cache_dir: Some(self.oxy.join("remotes").join(name).join("strata")),
917 +
            ..RemoteOptions::default()
917 918
        };
918 919
        self.fill_credentials(&url, &mut options);
919 920
        options
…
13 unchanged lines
933 934
    /// Fill in what `options` lacks for `url`: `$OXY_TOKEN` and `$OXY_CA`,
934 935
    /// then the user's credential store for its hub, then a remote on the
935 936
    /// same hub in an enclosing checkout (a subrepo borrows its parent's).
937 +
    /// When the token is not this remote's own or the environment's, the
938 +
    /// other tokens enclosing checkouts hold for the hub are kept to try
939 +
    /// if it is refused: a stale stored login must not hide a parent's
940 +
    /// good token (oxygen#112).
936 941
    fn fill_credentials(&self, url: &str, options: &mut RemoteOptions) {
937 942
        if options.token.is_some() && options.ca.is_some() {
938 943
            return;
939 944
        }
945 +
        let explicit =
946 +
            options.token.is_some() || std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty());
940 947
        let stored = self.credentials().options_for(url);
941 948
        options.token = options.token.take().or(stored.token);
942 949
        options.ca = options.ca.take().or(stored.ca);
943 -
        if options.token.is_some() && options.ca.is_some() {
944 -
            return;
950 +
        let enclosing = self.enclosing_credentials(url);
951 +
        if options.token.is_none() {
952 +
            options.token = enclosing.iter().find_map(|(t, _)| t.clone());
953 +
        }
954 +
        if options.ca.is_none() {
955 +
            options.ca = enclosing.iter().find_map(|(_, c)| c.clone());
956 +
        }
957 +
        if !explicit {
958 +
            for t in enclosing.into_iter().filter_map(|(t, _)| t) {
959 +
                if options.token.as_ref() != Some(&t) && !options.fallback_tokens.contains(&t) {
960 +
                    options.fallback_tokens.push(t);
961 +
                }
962 +
            }
945 963
        }
964 +
    }
965 +
966 +
    /// The tokens and CAs of remotes on `url`'s hub in enclosing
967 +
    /// checkouts, nearest first.
968 +
    fn enclosing_credentials(&self, url: &str) -> Vec<(Option<String>, Option<PathBuf>)> {
969 +
        let mut out = Vec::new();
946 970
        let Some(hub) = crate::credentials::hub_of(url) else {
947 -
            return;
971 +
            return out;
948 972
        };
949 973
        let mut at = self.root.parent();
950 974
        while let Some(dir) = at {
…
10 unchanged lines
961 985
                if crate::credentials::hub_of(remote_url).as_deref() != Some(hub.as_str()) {
962 986
                    continue;
963 987
                }
964 -
                if options.token.is_none() {
965 -
                    options.token = cfg.get(&format!("remote.{name}.token")).cloned();
966 -
                }
967 -
                if options.ca.is_none() {
968 -
                    options.ca = cfg.get(&format!("remote.{name}.ca")).map(PathBuf::from);
969 -
                }
970 -
            }
971 -
            if options.token.is_some() && options.ca.is_some() {
972 -
                return;
988 +
                out.push((
989 +
                    cfg.get(&format!("remote.{name}.token")).cloned(),
990 +
                    cfg.get(&format!("remote.{name}.ca")).map(PathBuf::from),
991 +
                ));
973 992
            }
974 993
            at = dir.parent();
975 994
        }
995 +
        out
976 996
    }
977 997
978 998
    /// Push seal attestations on their own (hub spec §7.4): a bundle with
…
679 unchanged lines
1658 1678
            })?),
1659 1679
            None => None,
1660 1680
        };
1681 +
        // Whoever signed the move, and whom a delegate acts for, travel with
1682 +
        // it: a receiver that has never seen a change by this delegate could
1683 +
        // not otherwise check its signature (oxygenhub#129).
1684 +
        if attestation.is_some() {
1685 +
            for k in [self.signing_key(), self.config.author_key]
1686 +
                .into_iter()
1687 +
                .flatten()
1688 +
            {
1689 +
                if let Ok(id) = self.identity(&k) {
1690 +
                    let body = id.encode();
1691 +
                    if !bundle
1692 +
                        .objects
1693 +
                        .iter()
1694 +
                        .any(|(t, b)| *t == ObjectType::Identity && *b == body)
1695 +
                    {
1696 +
                        bundle.objects.push((ObjectType::Identity, body));
1697 +
                    }
1698 +
                }
1699 +
            }
1700 +
        }
1661 1701
        let serial = attestation.as_ref().and_then(|a| match a.attest.body {
1662 1702
            AttestBody::Ref { serial, .. } => Some(serial),
1663 1703
            _ => None,
…
239 unchanged lines
modified crates/oxy-server/tests/http.rs +60 −0
…
689 unchanged lines
690 690
        fs::set_permissions(&attest, fs::Permissions::from_mode(0o755)).unwrap();
691 691
    }
692 692
}
693 +
694 +
/// oxygen#112: a subrepo checkout whose stored login for the hub is stale
695 +
/// pushes with the token its parent checkout holds for the same hub, once
696 +
/// the stale one is refused; a token the subrepo sets itself is never
697 +
/// swapped for another.
698 +
#[test]
699 +
fn a_refused_stored_token_falls_back_to_the_parents() {
700 +
    hermetic();
701 +
    let dir = tempfile::tempdir().unwrap();
702 +
    let root = dir.path();
703 +
    let served = root.join("served");
704 +
    Repo::init(&served.join("parent")).unwrap();
705 +
    Repo::init(&served.join("child")).unwrap();
706 +
    let base = start(&served, vec!["good".into()]);
707 +
708 +
    let work = root.join("work");
709 +
    let mut parent = Repo::clone(&format!("{base}/parent"), &work).unwrap();
710 +
    parent.config.set("remote.origin.token", "good").unwrap();
711 +
    parent.save_config().unwrap();
712 +
    // The subrepo's own home holds a stale login for this hub.
713 +
    let home = root.join("home");
714 +
    oxy_core::credentials::Credentials::at(&home)
715 +
        .save(&base, "stale", Some("andy"), None)
716 +
        .unwrap();
717 +
    let nested = work.join("deps").join("child");
718 +
    let mut child = Repo::clone(&format!("{base}/child"), &nested)
719 +
        .unwrap()
720 +
        .with_home(&home);
721 +
    let options = child.options_for_url(&format!("{base}/child"));
722 +
    assert_eq!(
723 +
        options.token.as_deref(),
724 +
        Some("stale"),
725 +
        "the stored login comes first"
726 +
    );
727 +
    assert_eq!(
728 +
        options.fallback_tokens,
729 +
        vec!["good".to_string()],
730 +
        "the parent's, to fall back on"
731 +
    );
732 +
    write(&nested, "c.txt", b"child\n");
733 +
    child.commit("child work", &[]).unwrap();
734 +
    let pushed = child.push("origin", false).unwrap();
735 +
    assert!(pushed[0].ok, "{}", pushed[0].reason);
736 +
    assert_eq!(
737 +
        Repo::open(&served.join("child"))
738 +
            .unwrap()
739 +
            .bookmark_heads()
740 +
            .unwrap()
741 +
            .len(),
742 +
        1
743 +
    );
744 +
745 +
    // Its own token, if wrong, stays wrong: what a checkout sets is its choice.
746 +
    child.config.set("remote.origin.token", "wrong").unwrap();
747 +
    child.save_config().unwrap();
748 +
    write(&nested, "d.txt", b"more\n");
749 +
    child.commit("more", &[]).unwrap();
750 +
    let err = child.push("origin", false).err().unwrap().to_string();
751 +
    assert!(err.contains("401"), "{err}");
752 +
}
modified docs/guide.md +5 −1
…
364 unchanged lines
365 365
366 366
A token is found in this order: the remote's own `remote.<name>.token`, then
367 367
`$OXY_TOKEN` (for CI), then the store, then a remote on the same hub in an
368 -
enclosing checkout (so a subrepo uses its parent's). oxy sends a token over
368 +
enclosing checkout (so a subrepo uses its parent's). When the token came
369 +
from the store or an enclosing checkout and the hub refuses it (a stale login,
370 +
or another account's), oxy tries the other tokens enclosing checkouts hold for
371 +
that hub before giving up, and says so once; a remote's own token and
372 +
`$OXY_TOKEN` are never swapped for another. oxy sends a token over
369 373
`https` only, or over plain `http` to this machine; it is never sent to
370 374
another host after a redirect, and a credentials file that others can read
371 375
is ignored.
…
246 unchanged lines
modified docs/spec.md +1 −1
…
760 unchanged lines
761 761
| Message | Encoding |
762 762
|---|---|
763 763
| Signed attestation | `[attest bytes, [Signature]]` |
764 -
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending |
764 +
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending |
765 765
| `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` |
766 766
| Signed policy | `[policy bytes, [Signature]]` |
767 767
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
…
283 unchanged lines
modified src/cli/auth.rs +3 −6
…
169 unchanged lines
170 170
    let anonymous = RemoteOptions {
171 171
        token: None,
172 172
        ca: ca.clone(),
173 -
        repo_id: None,
174 -
        cache_dir: None,
173 +
        ..RemoteOptions::default()
175 174
    };
176 175
    let token = if with_token {
177 176
        let mut line = String::new();
…
83 unchanged lines
261 260
        let options = RemoteOptions {
262 261
            token: Some(token.clone()),
263 262
            ca: e.ca.clone(),
264 -
            repo_id: None,
265 -
            cache_dir: None,
263 +
            ..RemoteOptions::default()
266 264
        };
267 265
        let check = match whoami(&e.hub, &options) {
268 266
            Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")),
…
46 unchanged lines
315 313
            let options = RemoteOptions {
316 314
                token: Some(t.clone()),
317 315
                ca: entry.ca.clone(),
318 -
                repo_id: None,
319 -
                cache_dir: None,
316 +
                ..RemoteOptions::default()
320 317
            };
321 318
            matches!(
322 319
                http_json(
…
26 unchanged lines