OXYGENHUB

A rotation follows you everywhere; config --unset; delegate add takes several files 614f55e14eee

delegate add, delegate remove, rotate and renew record the successor
key in the user config when it named the old key, and in the
repository's own file when that pinned it, so every checkout moves
together instead of one drifting ahead. oxy identity show warns when a
repository pins a key other than the user config's. oxy config --unset
KEY drops a key. oxy identity delegate add takes several identity files
in one rotation and says that agents configured with author.key need
the new key. closes #69
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 17:50 UTC
Signature
✔ signed by Andy McCall
Hash
614f55e14eeed483b6f249f380b75acdcaf0953aafb7e9f2aa2762a0b60e45e5
Depends on
28fc23d7706a 2c9ddac3c387 445ce913ba56 47ce8b8e2d4b 5cde7882d767 75167672ebcd 7ba155f20aa0 8fd78eb52ad2 982864068a59 c07406bcc237 c71a5777509a d42369abf180 ee06a6c557b7 fce44ae9ad29
Depended on by
aded4110f0bd 8e9c64d16ded 33be24095b4b d45dab0484ea
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.1 v0.4.2 v0.4.3 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
25 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A rotation follows you everywhere (#69).** `delegate add`, `delegate remove`, `rotate` and `renew` record the successor key in the user config when it named the old key, and in the repository's own file when that pinned it, so every checkout moves together. `oxy identity show` warns when a repository pins a key other than the user config's; `oxy config --unset KEY` drops a key; `oxy identity delegate add` takes several identity files in one rotation and reminds you that agents configured with your `author.key` need the new one.
6 +
5 7
## 0.4.0 (2026-10-04)
6 8
7 9
- **Oxygen Strata: a repository on a bucket (#63).** `oxy remote add NAME s3://BUCKET/PREFIX` (`--profile`, `--region`, `--endpoint`), `gs://BUCKET/PREFIX` (`--credentials`), `az://ACCOUNT/CONTAINER/PREFIX`, or `dir://PATH`, then `push`, `pull`, `clone` and `gc` against it as against any remote, with no server in between: objects are written once, the references record moves by compare-and-swap, and two pushers merge and retry rather than lose each other's work. Credentials come from each cloud's own chain. `oxy remote check` runs the conformance suite against a store before it is trusted. `docs/strata.md` is the guide; the bucket layout is not stable before 1.0.
…
100 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +67 −16
…
99 unchanged lines
100 100
        }
101 101
    }
102 102
103 +
    /// Record a successor key everywhere the old one was named: the
104 +
    /// repository layer when it pins the old key, and the user file when
105 +
    /// it names the old key, so every checkout that followed the old key
106 +
    /// follows the new one (oxygen#69). A key named nowhere falls back to
107 +
    /// the scope that resolved it.
108 +
    fn record_successor(&mut self, id: &Identity, old: &KeyId, new_key: &KeyId) -> Result<()> {
109 +
        let names = |v: Option<&String>| v.and_then(|k| KeyId::from_hex(k)) == Some(*old);
110 +
        let repo_pins = names(self.config.repo_layer().get("author.key"));
111 +
        let user_entries = super::config::read_config_file(&self.user_config).unwrap_or_default();
112 +
        let user_names = names(user_entries.get("author.key"));
113 +
        if !repo_pins && !user_names {
114 +
            return self.set_author(id, new_key, self.author_scope());
115 +
        }
116 +
        if user_names {
117 +
            self.set_author(id, new_key, IdentityScope::User)?;
118 +
        }
119 +
        if repo_pins {
120 +
            self.set_author(id, new_key, IdentityScope::Repo)?;
121 +
        }
122 +
        Ok(())
123 +
    }
124 +
103 125
    /// Replace the configured identity by `new` signed with `sk`: store it,
104 126
    /// record a `rotate` attestation signed by the old key, move the old
105 127
    /// key's policy grants to the new key, and switch the author config.
…
26 unchanged lines
132 154
            }
133 155
            _ => None,
134 156
        };
135 -
        self.set_author(&new, &new_key, self.author_scope())?;
157 +
        self.record_successor(&new, &old, &new_key)?;
136 158
        Ok(Rotation {
137 159
            old,
138 160
            new: new_key,
…
33 unchanged lines
172 194
    pub fn delegate_add(
173 195
        &mut self,
174 196
        delegate: Identity,
197 +
        scope: Scope,
198 +
        expires: Option<u64>,
199 +
    ) -> Result<Rotation> {
200 +
        self.delegate_add_many(vec![delegate], scope, expires)
201 +
    }
202 +
203 +
    /// As [`Repo::delegate_add`], for several delegates in one rotation.
204 +
    pub fn delegate_add_many(
205 +
        &mut self,
206 +
        new_delegates: Vec<Identity>,
175 207
        scope: Scope,
176 208
        expires: Option<u64>,
177 209
    ) -> Result<Rotation> {
…
3 unchanged lines
181 213
        if scope.is_empty() {
182 214
            return fail("a delegate needs at least one scope: changes, refs or seal");
183 215
        }
184 -
        let dk = self.store.put_identity(&delegate)?;
185 -
        if dk == old {
186 -
            return fail("an identity cannot delegate to itself");
216 +
        if new_delegates.is_empty() {
217 +
            return fail("name at least one identity file");
187 218
        }
188 219
        let cur = self.identity(&old)?;
189 -
        if cur.delegate(&dk).is_some() {
190 -
            return fail(format!(
191 -
                "{} is already a delegate; remove it first to change its scope or expiry",
192 -
                dk.short(12)
193 -
            ));
220 +
        let mut delegates = cur.delegates.clone();
221 +
        let mut added = Vec::new();
222 +
        for delegate in new_delegates {
223 +
            let dk = self.store.put_identity(&delegate)?;
224 +
            added.push(dk);
225 +
            if dk == old {
226 +
                return fail("an identity cannot delegate to itself");
227 +
            }
228 +
            if cur.delegate(&dk).is_some()
229 +
                || delegates
230 +
                    .iter()
231 +
                    .skip(cur.delegates.len())
232 +
                    .any(|d| d.key == dk)
233 +
            {
234 +
                return fail(format!(
235 +
                    "{} is already a delegate; remove it first to change its scope or expiry",
236 +
                    dk.short(12)
237 +
                ));
238 +
            }
239 +
            delegates.push(Delegate {
240 +
                key: dk,
241 +
                scope,
242 +
                expires,
243 +
            });
194 244
        }
195 -
        let mut delegates = cur.delegates.clone();
196 -
        delegates.push(Delegate {
197 -
            key: dk,
198 -
            scope,
199 -
            expires,
200 -
        });
201 245
        let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms);
202 246
        self.replace_identity(
203 247
            id,
204 248
            sk,
205 -
            &format!("oxy identity delegate add {} --scope {scope}", dk.short(12)),
249 +
            &format!(
250 +
                "oxy identity delegate add {} --scope {scope}",
251 +
                added
252 +
                    .iter()
253 +
                    .map(|k| k.short(12))
254 +
                    .collect::<Vec<_>>()
255 +
                    .join(" ")
256 +
            ),
206 257
        )
207 258
    }
208 259
…
390 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +64 −0
…
7159 unchanged lines
7160 7160
    assert_eq!(set("revoked()"), vec![d]);
7161 7161
    assert_eq!(set("signed_by(me) ~ revoked()"), vec![]);
7162 7162
}
7163 +
7164 +
#[test]
7165 +
fn a_rotation_is_recorded_wherever_the_old_key_was_named() {
7166 +
    let dir = tempfile::tempdir().unwrap();
7167 +
    let root = dir.path();
7168 +
    let home = root.join("home");
7169 +
    // Ada's identity lives in the user file; repository `a` pins the same
7170 +
    // key in its own file, repository `b` names none.
7171 +
    let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home);
7172 +
    let (key, _) = a
7173 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
7174 +
        .unwrap();
7175 +
    a.config.set("author.key", &key.to_hex()).unwrap();
7176 +
    a.save_config().unwrap();
7177 +
    let agent = SecretKey::generate().identity("Agent", "[email protected]", 1);
7178 +
    let r = a
7179 +
        .delegate_add(agent.clone(), crate::identity::Scope::CHANGES, None)
7180 +
        .unwrap();
7181 +
    assert_eq!(
7182 +
        read_config_file(&home.join("config.toml")).unwrap()["author.key"],
7183 +
        r.new.to_hex(),
7184 +
        "the user file follows"
7185 +
    );
7186 +
    assert_eq!(
7187 +
        read_config_file(&root.join("a/.oxy/config")).unwrap()["author.key"],
7188 +
        r.new.to_hex(),
7189 +
        "the pinning repository follows"
7190 +
    );
7191 +
    let b = Repo::init(&root.join("b")).unwrap().with_home(&home);
7192 +
    assert_eq!(
7193 +
        b.config.author_key,
7194 +
        Some(r.new),
7195 +
        "a checkout without a pin follows the user file"
7196 +
    );
7197 +
    // A repository-scoped identity leaves the user file alone.
7198 +
    let other_home = root.join("home2");
7199 +
    let mut c = Repo::init(&root.join("c")).unwrap().with_home(&other_home);
7200 +
    let (ck, _) = c
7201 +
        .create_identity("Cy", "[email protected]", IdentityScope::Repo)
7202 +
        .unwrap();
7203 +
    assert!(!other_home.join("config.toml").exists());
7204 +
    let r2 = c.rotate_identity().unwrap();
7205 +
    assert_ne!(r2.new, ck);
7206 +
    assert_eq!(
7207 +
        read_config_file(&root.join("c/.oxy/config")).unwrap()["author.key"],
7208 +
        r2.new.to_hex()
7209 +
    );
7210 +
    assert!(
7211 +
        !other_home.join("config.toml").exists(),
7212 +
        "a repository-scoped identity stays repository-scoped"
7213 +
    );
7214 +
    // Several delegates in one rotation; a repeat is refused.
7215 +
    let (d1, d2) = (
7216 +
        SecretKey::generate().identity("D1", "d1@x", 1),
7217 +
        SecretKey::generate().identity("D2", "d2@x", 1),
7218 +
    );
7219 +
    let r3 = c
7220 +
        .delegate_add_many(vec![d1.clone(), d2], crate::identity::Scope::CHANGES, None)
7221 +
        .unwrap();
7222 +
    assert_eq!(r3.identity.delegates.len(), 2);
7223 +
    assert!(c
7224 +
        .delegate_add(d1, crate::identity::Scope::CHANGES, None)
7225 +
        .is_err());
7226 +
}
modified docs/guide.md +6 −2
…
440 unchanged lines
441 441
442 442
Then, in your own shell, in the repository: `oxy identity delegate add
443 443
~/.oxy-agents/claude/keys/<KeyId>.identity --scope changes --expires 8h`
444 -
(this rotates your identity; put the new KeyId in the agent's
445 -
`author.key`, and re-link it on your hub). Every commit the agent makes
444 +
(several files at once are one rotation). This rotates your identity:
445 +
the new key is written to your user config, and to this repository's
446 +
file too if it pinned the old one, so other checkouts follow; put the
447 +
new KeyId in each agent's `author.key`, and re-link it on your hub.
448 +
`oxy identity show` warns if a repository still pins an older key
449 +
(`oxy config --unset author.key` there). Every commit the agent makes
446 450
carries you as author and its signature: `oxy log -r 'delegated()'`
447 451
lists them, `oxy verify` names the agent, and its own `oxy push` is
448 452
refused under any policy, so you land its work.
…
194 unchanged lines
modified src/cli/identity.rs +71 −14
…
237 unchanged lines
238 238
    if let Some(p) = id.previous {
239 239
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
240 240
    }
241 +
    // A repository that pins one key while the user file names another is
242 +
    // how checkouts drift apart after a rotation (oxygen#69).
243 +
    if let Some(pinned) = repo
244 +
        .config
245 +
        .repo_layer()
246 +
        .get("author.key")
247 +
        .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
248 +
    {
249 +
        let user_key = repo::read_config_file(&repo::default_user_config())
250 +
            .ok()
251 +
            .and_then(|m| {
252 +
                m.get("author.key")
253 +
                    .and_then(|k| oxy_core::hash::KeyId::from_hex(k))
254 +
            });
255 +
        if let Some(u) = user_key.filter(|u| *u != pinned) {
256 +
            let related = |from: oxy_core::hash::KeyId, to: oxy_core::hash::KeyId| {
257 +
                let mut cur = Some(from);
258 +
                for _ in 0..64 {
259 +
                    match cur {
260 +
                        Some(k) if k == to => return true,
261 +
                        Some(k) => cur = repo.identity(&k).ok().and_then(|i| i.previous),
262 +
                        None => break,
263 +
                    }
264 +
                }
265 +
                false
266 +
            };
267 +
            let how = if related(pinned, u) {
268 +
                "an older key of this identity"
269 +
            } else if related(u, pinned) {
270 +
                "a newer key of this identity"
271 +
            } else {
272 +
                "another key"
273 +
            };
274 +
            println!(
275 +
                "  {} {}",
276 +
                ui::caution(),
277 +
                ui::dim(format!(
278 +
                    "this repository pins author.key {}; your user config names {how}, {}. Other checkouts follow the user config: `oxy config --unset author.key` here, or `oxy config --user author.key {}`",
279 +
                    pinned.short(12),
280 +
                    u.short(12),
281 +
                    pinned.to_hex()
282 +
                ))
283 +
            );
284 +
        }
285 +
    }
241 286
    print_delegates(&repo, &id.delegates);
242 287
    if repo.is_revoked(&key)? {
243 288
        println!(
…
118 unchanged lines
362 407
    Ok(if bad > 0 { 1 } else { 0 })
363 408
}
364 409
365 -
pub fn config(key: &str, value: &str, user: bool) -> Result<i32> {
410 +
pub fn config(key: &str, value: &str, user: bool, unset: bool) -> Result<i32> {
411 +
    // An empty value removes the key, which is what --unset says plainly.
412 +
    let value = if unset { "" } else { value };
413 +
    let said = if unset {
414 +
        format!("{} {key}", ui::done("Unset"))
415 +
    } else {
416 +
        format!("{} {key} = {value}", ui::done("Set"))
417 +
    };
366 418
    if user {
367 419
        let path = repo::default_user_config();
368 420
        repo::Config::set_user(&path, key, value)?;
369 -
        println!(
370 -
            "{} {key} = {value} {}",
371 -
            ui::done("Set"),
372 -
            ui::dim(format!("(in {})", path.display()))
373 -
        );
421 +
        println!("{said} {}", ui::dim(format!("(in {})", path.display())));
374 422
    } else {
375 423
        let mut repo = open()?;
376 424
        repo.config.set(key, value)?;
377 425
        repo.save_config()?;
378 -
        println!(
379 -
            "{} {key} = {value} {}",
380 -
            ui::done("Set"),
381 -
            ui::dim("(in this repository)")
382 -
        );
426 +
        println!("{said} {}", ui::dim("(in this repository)"));
383 427
    }
384 428
    Ok(0)
385 429
}
…
82 unchanged lines
468 512
    Ok(0)
469 513
}
470 514
471 -
pub fn delegate_add(identity_file: &Path, scope: &str, expires: Option<&str>) -> Result<i32> {
515 +
pub fn delegate_add(identity_files: &[PathBuf], scope: &str, expires: Option<&str>) -> Result<i32> {
472 516
    let mut repo = open()?;
473 -
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
517 +
    let ids = identity_files
518 +
        .iter()
519 +
        .map(|f| {
520 +
            Identity::decode(
521 +
                &std::fs::read(f).map_err(|e| anyhow::anyhow!("{}: {e}", f.display()))?,
522 +
            )
523 +
            .map_err(Into::into)
524 +
        })
525 +
        .collect::<Result<Vec<_>>>()?;
474 526
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
475 527
    let expires = expires.map(expiry_from_duration).transpose()?;
476 -
    let r = repo.delegate_add(id, scope, expires)?;
528 +
    let r = repo.delegate_add_many(ids, scope, expires)?;
477 529
    report_rotation(&repo, &r, "Delegated")?;
530 +
    println!(
531 +
        "  {} {}",
532 +
        ui::note(),
533 +
        ui::dim("an agent configured with your author.key needs the new key: oxy config --user author.key NEW in its home")
534 +
    );
478 535
    if !scope.has(Scope::REFS) {
479 536
        println!(
480 537
            "  {} {}",
…
355 unchanged lines
modified src/cli/mod.rs +15 −5
…
476 unchanged lines
477 477
    Config {
478 478
        /// The key, such as author.name or remote.origin.token
479 479
        key: String,
480 -
        /// The value to set
481 -
        value: String,
480 +
        /// The value to set (none with --unset)
481 +
        #[arg(required_unless_present = "unset")]
482 +
        value: Option<String>,
482 483
        /// Write to the user file (~/.config/oxy/config.toml) instead of this repository
483 484
        #[arg(long)]
484 485
        user: bool,
486 +
        /// Remove the key from this repository's file (or the user file with --user)
487 +
        #[arg(long)]
488 +
        unset: bool,
485 489
    },
486 490
}
487 491
…
363 unchanged lines
851 855
enum DelegateCmd {
852 856
    /// Add a delegate from an exported identity file
853 857
    Add {
854 -
        /// The delegate's exported identity file
855 -
        identity_file: PathBuf,
858 +
        /// The delegates' exported identity files: one rotation for all of them
859 +
        #[arg(required = true)]
860 +
        identity_file: Vec<PathBuf>,
856 861
        /// What it may do for you: changes, refs, seal (comma-separated) or all.
857 862
        /// An agent gets `changes`; a host that lands for you needs `changes,refs`
858 863
        #[arg(long, default_value = "changes")]
…
466 unchanged lines
1325 1330
            revset,
1326 1331
            json,
1327 1332
        } => identity::verify(change.as_deref(), revset.as_deref(), json),
1328 -
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
1333 +
        Cmd::Config {
1334 +
            key,
1335 +
            value,
1336 +
            user,
1337 +
            unset,
1338 +
        } => identity::config(&key, value.as_deref().unwrap_or(""), user, unset),
1329 1339
    }
1330 1340
}
1331 1341
…
37 unchanged lines