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 |