Who did the work: via AGENT in log and show, delegates by name 5cde7882d767
A change a delegate signed for its author now says so: oxy log puts 'via NAME' beside the author, oxy show has a via row under the author (an arrow, the delegate's identity, the delegation it acted under and its expiry or revocation, its key), and --json carries a via object. oxy identity show and the rotation reports name each delegate with its key, scope and expiry, instead of keys alone. The agent test pushes as the owner through the owner's own handle, since a test module's OXY_HOME can move under it. refs #64
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 12:44 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
5cde7882d7674a3606418903739ac09b25e64910d5c2768d31de95e8a0a1106c- Depends on
- 28fc23d7706a 2bf22305bc48 2c9ddac3c387 4cce912a0953 75167672ebcd 7ba155f20aa0 84b9e9ac0ab4 94084087a876 b277870b0749 b35df36b7dc5 c6aa5bd9d1c8 fce44ae9ad29 ff1a64f81c1c
- Depended on by
- 284fe47d98b9 767ac6cd0545 68dcf21fe055 614f55e14eee
- 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.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, 5 files
Browse the tree at this change
modified crates/oxy-core/src/repo/hosted.rs +3 −5
| … | 574 unchanged lines |
||
| 575 | 575 | fn an_agent_signs_its_owner_s_changes_through_author_signer() { |
|
| 576 | 576 | let dir = tempfile::tempdir().unwrap(); |
|
| 577 | 577 | let (host, mut user, _, _, _) = setup(dir.path(), true, false); |
|
| 578 | - | // The owner's keys are in the hermetic home; keep its path, since |
|
| 579 | - | // another test module may point `OXY_HOME` elsewhere meanwhile. |
|
| 580 | - | let owner_home = std::env::var("OXY_HOME").unwrap(); |
|
| 581 | 578 | let owner = user.config.author_key.unwrap(); |
|
| 582 | 579 | // The agent makes its own key in its own home; the owner delegates. |
|
| 583 | 580 | let agent_home = dir.path().join("agent-home"); |
|
| … | 37 unchanged lines |
||
| 621 | 618 | let r = as_agent.push("origin", false).unwrap(); |
|
| 622 | 619 | assert!(r.iter().all(|x| !x.ok), "{r:?}"); |
|
| 623 | 620 | assert!(!host.bookmark_heads().unwrap().contains(&h)); |
|
| 624 | - | let owner_repo = Repo::open(&work).unwrap().with_home(owner_home); |
|
| 625 | - | let r = owner_repo.push("origin", false).unwrap(); |
|
| 621 | + | // The owner's own handle (its key directory was fixed at `setup`; |
|
| 622 | + | // the environment's `OXY_HOME` may have moved since). |
|
| 623 | + | let r = user.push("origin", false).unwrap(); |
|
| 626 | 624 | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 627 | 625 | assert!(host.bookmark_heads().unwrap().contains(&h)); |
|
| 628 | 626 | assert!(matches!( |
|
| … | 133 unchanged lines |
modified docs/ai_native_agent_workflows.md +10 −2
| … | 145 unchanged lines |
||
| 146 | 146 | ||
| 147 | 147 | ## 6. Review and policy on the hub |
|
| 148 | 148 | ||
| 149 | - | - **Badges:** changes signed by a delegate with `changes` scope show |
|
| 150 | - | "agent: NAME on behalf of HUMAN" on the hub and in `oxy log`. |
|
| 149 | + | - **Badges (implemented 2026-10-04):** a change signed by a delegate |
|
| 150 | + | shows its author and, beside it, `via NAME` with an `agent` badge: in |
|
| 151 | + | history rows on the hub, as a `via` row under the author on the change |
|
| 152 | + | page (`↳ Antigravity <…> · delegate for changes, until … · key …`), |
|
| 153 | + | under the author in Contributors ("via Antigravity 3"), as `via` in |
|
| 154 | + | the API's change summaries, and the same in `oxy log` (`via NAME`) and |
|
| 155 | + | `oxy show` (the `via` row). The author stays the human; the agent is |
|
| 156 | + | the instrument, and a revoked or withdrawn delegation turns the badge |
|
| 157 | + | red. The hub also links a rotated identity to its owner's account by |
|
| 158 | + | itself when a push brings it, so delegating never breaks attribution. |
|
| 151 | 159 | - **Approvals:** approvals count only signatures by keys that are not |
|
| 152 | 160 | agent-scoped delegates, so an agent cannot approve its own work or |
|
| 153 | 161 | anyone else's. |
|
| … | 80 unchanged lines |
modified src/cli/format.rs +73 −9
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | use super::ui; |
|
| 4 | 4 | use anyhow::Result; |
|
| 5 | - | use oxy_core::hash::ChangeHash; |
|
| 5 | + | use oxy_core::hash::{ChangeHash, KeyId}; |
|
| 6 | 6 | use oxy_core::objects::Change; |
|
| 7 | 7 | use oxy_core::repo::{Repo, SignatureStatus}; |
|
| 8 | 8 | use oxy_core::state::ChangeMeta; |
|
| … | 53 unchanged lines |
||
| 62 | 62 | }) |
|
| 63 | 63 | } |
|
| 64 | 64 | ||
| 65 | - | /// A compact, styled authentication marker for log lines, from the |
|
| 66 | - | /// change's metadata when the caller has it (so the change itself need not |
|
| 67 | - | /// be read). |
|
| 68 | - | pub fn signature_mark(repo: &Repo, h: &ChangeHash, known: Option<&ChangeMeta>) -> Result<String> { |
|
| 69 | - | Ok(match repo.authenticate_change_known(h, known)? { |
|
| 65 | + | /// Who signed for the author when it was not the author: a delegate such |
|
| 66 | + | /// as an agent (`author.signer`), with the delegation that let it. |
|
| 67 | + | #[derive(Clone, Debug)] |
|
| 68 | + | pub struct Via { |
|
| 69 | + | pub name: String, |
|
| 70 | + | pub email: String, |
|
| 71 | + | pub key: KeyId, |
|
| 72 | + | /// The delegation as the author's identity lists it, if it still does. |
|
| 73 | + | pub scope: Option<String>, |
|
| 74 | + | pub expires: Option<u64>, |
|
| 75 | + | pub revoked: bool, |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | impl Via { |
|
| 79 | + | /// `↳ Antigravity <[email protected]> · delegate for changes, until … · key 3195…` |
|
| 80 | + | pub fn line(&self) -> String { |
|
| 81 | + | let delegation = match (&self.scope, self.revoked) { |
|
| 82 | + | (_, true) => ui::err("delegation revoked"), |
|
| 83 | + | (Some(scope), false) => ui::dim(format!( |
|
| 84 | + | "delegate for {scope}{}", |
|
| 85 | + | self.expires |
|
| 86 | + | .map(|e| format!(", until {}", when(e))) |
|
| 87 | + | .unwrap_or_default() |
|
| 88 | + | )), |
|
| 89 | + | (None, false) => ui::warn("not a delegate of the author now"), |
|
| 90 | + | }; |
|
| 91 | + | format!( |
|
| 92 | + | "{} {} <{}> · {} · key {}", |
|
| 93 | + | ui::glyph("↳", "->"), |
|
| 94 | + | ui::author(&self.name), |
|
| 95 | + | self.email, |
|
| 96 | + | delegation, |
|
| 97 | + | ui::hash(self.key.short(12)) |
|
| 98 | + | ) |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | /// The delegate that signed for `author`, when one did. `None` when the |
|
| 103 | + | /// author signed, or nothing valid did. |
|
| 104 | + | pub fn via(repo: &Repo, status: &SignatureStatus, author: &KeyId) -> Option<Via> { |
|
| 105 | + | match status { |
|
| 106 | + | SignatureStatus::Valid { |
|
| 107 | + | key, |
|
| 108 | + | name, |
|
| 109 | + | email, |
|
| 110 | + | revoked, |
|
| 111 | + | .. |
|
| 112 | + | } if !author.is_zero() && key != author => { |
|
| 113 | + | let entry = repo |
|
| 114 | + | .identity(author) |
|
| 115 | + | .ok() |
|
| 116 | + | .and_then(|a| a.delegate(key).copied()); |
|
| 117 | + | Some(Via { |
|
| 118 | + | name: name.clone(), |
|
| 119 | + | email: email.clone(), |
|
| 120 | + | key: *key, |
|
| 121 | + | scope: entry.map(|d| d.scope.to_string()), |
|
| 122 | + | expires: entry.and_then(|d| d.expires), |
|
| 123 | + | revoked: *revoked || entry.is_none(), |
|
| 124 | + | }) |
|
| 125 | + | } |
|
| 126 | + | _ => None, |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// A compact, styled authentication marker for log lines, from a status |
|
| 131 | + | /// already in hand (the caller also reads who signed, see [`via`]). |
|
| 132 | + | pub fn signature_mark_of(status: &SignatureStatus) -> String { |
|
| 133 | + | match status { |
|
| 70 | 134 | SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))), |
|
| 71 | 135 | SignatureStatus::Valid { |
|
| 72 | 136 | sealed, |
|
| … | 2 unchanged lines |
||
| 75 | 139 | } => ui::warn(format!( |
|
| 76 | 140 | "{} {} (key revoked)", |
|
| 77 | 141 | ui::glyph("⚠", "!"), |
|
| 78 | - | if sealed { "sealed" } else { "signed" } |
|
| 142 | + | if *sealed { "sealed" } else { "signed" } |
|
| 79 | 143 | )), |
|
| 80 | 144 | SignatureStatus::Valid { sealed, .. } => ui::ok(format!( |
|
| 81 | 145 | "{} {}", |
|
| 82 | 146 | ui::glyph("✔", "v"), |
|
| 83 | - | if sealed { "sealed" } else { "signed" } |
|
| 147 | + | if *sealed { "sealed" } else { "signed" } |
|
| 84 | 148 | )), |
|
| 85 | 149 | SignatureStatus::Invalid(why) => ui::err(format!("{} INVALID: {why}", ui::glyph("✖", "x"))), |
|
| 86 | - | }) |
|
| 150 | + | } |
|
| 87 | 151 | } |
|
| 88 | 152 | ||
| 89 | 153 | /// The author's display name: the identity's name, or a Git-Author trailer |
|
| … | 47 unchanged lines |
modified src/cli/identity.rs +34 −31
| … | 40 unchanged lines |
||
| 41 | 41 | Ok(Hlc::now().wall_ms + n * ms) |
|
| 42 | 42 | } |
|
| 43 | 43 | ||
| 44 | - | /// One line per delegate: key, scope, expiry. |
|
| 45 | - | fn delegate_lines(delegates: &[Delegate]) -> String { |
|
| 46 | - | delegates |
|
| 47 | - | .iter() |
|
| 48 | - | .map(|d| { |
|
| 49 | - | format!( |
|
| 50 | - | "{} ({}{})", |
|
| 51 | - | d.key.short(12), |
|
| 52 | - | d.scope, |
|
| 53 | - | d.expires |
|
| 54 | - | .map(|e| format!(", until {}", when(e))) |
|
| 55 | - | .unwrap_or_default() |
|
| 56 | - | ) |
|
| 57 | - | }) |
|
| 58 | - | .collect::<Vec<_>>() |
|
| 59 | - | .join(" ") |
|
| 44 | + | /// Print the delegates, one per line: who, their key, what they may do |
|
| 45 | + | /// and until when, so an agent's name is seen, not only its key. |
|
| 46 | + | fn print_delegates(repo: &Repo, delegates: &[Delegate]) { |
|
| 47 | + | if delegates.is_empty() { |
|
| 48 | + | return; |
|
| 49 | + | } |
|
| 50 | + | println!("{}", ui::kv("delegates", delegates.len().to_string())); |
|
| 51 | + | let now = Hlc::now().wall_ms; |
|
| 52 | + | for d in delegates { |
|
| 53 | + | let who = repo |
|
| 54 | + | .identity(&d.key) |
|
| 55 | + | .map(|i| format!("{} <{}>", i.name, i.email)) |
|
| 56 | + | .unwrap_or_else(|_| "(unknown identity)".into()); |
|
| 57 | + | let until = match d.expires { |
|
| 58 | + | Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))), |
|
| 59 | + | Some(e) => ui::dim(format!("until {}", when(e))), |
|
| 60 | + | None => String::new(), |
|
| 61 | + | }; |
|
| 62 | + | println!( |
|
| 63 | + | " {} {} {} {}", |
|
| 64 | + | ui::author(who), |
|
| 65 | + | ui::hash(d.key.short(12)), |
|
| 66 | + | ui::dim(d.scope.to_string()), |
|
| 67 | + | until |
|
| 68 | + | ); |
|
| 69 | + | } |
|
| 60 | 70 | } |
|
| 61 | 71 | ||
| 62 | 72 | /// Resolve a KeyId prefix among the identities the repository knows. |
|
| … | 52 unchanged lines |
||
| 115 | 125 | Ok(()) |
|
| 116 | 126 | } |
|
| 117 | 127 | ||
| 118 | - | fn report_rotation(r: &Rotation, verb: &str) -> Result<()> { |
|
| 128 | + | fn report_rotation(repo: &Repo, r: &Rotation, verb: &str) -> Result<()> { |
|
| 119 | 129 | println!( |
|
| 120 | 130 | "{} identity {} <{}>", |
|
| 121 | 131 | ui::done(verb), |
|
| … | 2 unchanged lines |
||
| 124 | 134 | ); |
|
| 125 | 135 | println!("{}", ui::kv("old key", ui::dim(r.old.to_hex()))); |
|
| 126 | 136 | println!("{}", ui::kv("new key", r.new.to_hex())); |
|
| 127 | - | if !r.identity.delegates.is_empty() { |
|
| 128 | - | println!( |
|
| 129 | - | "{}", |
|
| 130 | - | ui::kv("delegates", delegate_lines(&r.identity.delegates)) |
|
| 131 | - | ); |
|
| 132 | - | } |
|
| 137 | + | print_delegates(repo, &r.identity.delegates); |
|
| 133 | 138 | if let Some(e) = r.identity.expires { |
|
| 134 | 139 | println!("{}", ui::kv("expires", when(e))); |
|
| 135 | 140 | } |
|
| … | 96 unchanged lines |
||
| 232 | 237 | } |
|
| 233 | 238 | if let Some(p) = id.previous { |
|
| 234 | 239 | println!("{}", ui::kv("previous", ui::dim(p.short(12)))); |
|
| 235 | - | } |
|
| 236 | - | if !id.delegates.is_empty() { |
|
| 237 | - | println!("{}", ui::kv("delegates", delegate_lines(&id.delegates))); |
|
| 238 | 240 | } |
|
| 241 | + | print_delegates(&repo, &id.delegates); |
|
| 239 | 242 | if repo.is_revoked(&key)? { |
|
| 240 | 243 | println!( |
|
| 241 | 244 | "{}", |
|
| … | 161 unchanged lines |
||
| 403 | 406 | pub fn rotate() -> Result<i32> { |
|
| 404 | 407 | let mut repo = open()?; |
|
| 405 | 408 | let r = repo.rotate_identity()?; |
|
| 406 | - | report_rotation(&r, "Rotated")?; |
|
| 409 | + | report_rotation(&repo, &r, "Rotated")?; |
|
| 407 | 410 | Ok(0) |
|
| 408 | 411 | } |
|
| 409 | 412 | ||
| 410 | 413 | pub fn renew(expires: u64) -> Result<i32> { |
|
| 411 | 414 | let mut repo = open()?; |
|
| 412 | 415 | let r = repo.renew_identity(expiry_from_days(expires))?; |
|
| 413 | - | report_rotation(&r, "Renewed")?; |
|
| 416 | + | report_rotation(&repo, &r, "Renewed")?; |
|
| 414 | 417 | Ok(0) |
|
| 415 | 418 | } |
|
| 416 | 419 | ||
| … | 35 unchanged lines |
||
| 452 | 455 | if delegated { |
|
| 453 | 456 | let mut repo = repo; |
|
| 454 | 457 | let r = repo.delegate_remove(&key)?; |
|
| 455 | - | report_rotation(&r, "Undelegated")?; |
|
| 458 | + | report_rotation(&repo, &r, "Undelegated")?; |
|
| 456 | 459 | return Ok(0); |
|
| 457 | 460 | } |
|
| 458 | 461 | if repo.config.author_key == Some(key) { |
|
| … | 12 unchanged lines |
||
| 471 | 474 | let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 472 | 475 | let expires = expires.map(expiry_from_duration).transpose()?; |
|
| 473 | 476 | let r = repo.delegate_add(id, scope, expires)?; |
|
| 474 | - | report_rotation(&r, "Delegated")?; |
|
| 477 | + | report_rotation(&repo, &r, "Delegated")?; |
|
| 475 | 478 | if !scope.has(Scope::REFS) { |
|
| 476 | 479 | println!( |
|
| 477 | 480 | " {} {}", |
|
| … | 8 unchanged lines |
||
| 486 | 489 | let mut repo = open()?; |
|
| 487 | 490 | let k = resolve_key(&repo, key)?; |
|
| 488 | 491 | let r = repo.delegate_remove(&k)?; |
|
| 489 | - | report_rotation(&r, "Undelegated")?; |
|
| 492 | + | report_rotation(&repo, &r, "Undelegated")?; |
|
| 490 | 493 | Ok(0) |
|
| 491 | 494 | } |
|
| 492 | 495 | ||
| … | 340 unchanged lines |
modified src/cli/workspace.rs +31 −10
| 1 | 1 | //! Commands that act on the local repository and working copy. |
|
| 2 | 2 | ||
| 3 | 3 | use super::format::{ |
|
| 4 | - | author_name, deps_line, deps_of, plural, short_id, signature_line, signature_mark, subject_of, |
|
| 5 | - | when, |
|
| 4 | + | author_name, deps_line, deps_of, plural, short_id, signature_line, signature_mark_of, |
|
| 5 | + | subject_of, via, when, |
|
| 6 | 6 | }; |
|
| 7 | 7 | use super::json; |
|
| 8 | 8 | use super::open; |
|
| … | 538 unchanged lines |
||
| 547 | 547 | ("change_id", json::s(&c.change_id.to_hex())), |
|
| 548 | 548 | ("author", json::s(&author_name(repo, c))), |
|
| 549 | 549 | ("author_key", json::s(&c.author.to_hex())), |
|
| 550 | + | ( |
|
| 551 | + | "via", |
|
| 552 | + | match via( |
|
| 553 | + | repo, |
|
| 554 | + | &repo.authenticate_change_known(&e.hash, Some(c))?, |
|
| 555 | + | &c.author, |
|
| 556 | + | ) { |
|
| 557 | + | Some(v) => json::obj([ |
|
| 558 | + | ("name", json::s(&v.name)), |
|
| 559 | + | ("email", json::s(&v.email)), |
|
| 560 | + | ("key", json::s(&v.key.to_hex())), |
|
| 561 | + | ("scope", json::opt(v.scope.as_deref(), json::s)), |
|
| 562 | + | ("expires_ms", json::opt(v.expires, json::n)), |
|
| 563 | + | ("revoked", json::b(v.revoked)), |
|
| 564 | + | ]), |
|
| 565 | + | None => "null".to_string(), |
|
| 566 | + | }, |
|
| 567 | + | ), |
|
| 550 | 568 | ("when_ms", json::n(c.hlc.wall_ms)), |
|
| 551 | 569 | ("message", json::s(&c.message)), |
|
| 552 | 570 | ("kind", json::s(&format!("{:?}", c.kind).to_lowercase())), |
|
| … | 108 unchanged lines |
||
| 661 | 679 | } |
|
| 662 | 680 | if !e.is_working_copy { |
|
| 663 | 681 | head.push(ui::hash(format!("#{}", e.hash.short(8)))); |
|
| 664 | - | head.push(signature_mark(&repo, &e.hash, Some(c))?); |
|
| 682 | + | let status = repo.authenticate_change_known(&e.hash, Some(c))?; |
|
| 683 | + | if let Some(agent) = via(&repo, &status, &c.author) { |
|
| 684 | + | head.push(ui::dim(format!("via {}", agent.name))); |
|
| 685 | + | } |
|
| 686 | + | head.push(signature_mark_of(&status)); |
|
| 665 | 687 | } |
|
| 666 | 688 | println!("{}{}", node_line(row, &bullet), head.join(" ")); |
|
| 667 | 689 | let subject = if e.is_working_copy && c.message.is_empty() { |
|
| … | 448 unchanged lines |
||
| 1116 | 1138 | ) |
|
| 1117 | 1139 | ); |
|
| 1118 | 1140 | } |
|
| 1119 | - | println!( |
|
| 1120 | - | "{}", |
|
| 1121 | - | ui::kv( |
|
| 1122 | - | "author", |
|
| 1123 | - | ui::author(author_name(repo, &oxy_core::state::ChangeMeta::of(&c))) |
|
| 1124 | - | ) |
|
| 1125 | - | ); |
|
| 1141 | + | let meta = oxy_core::state::ChangeMeta::of(&c); |
|
| 1142 | + | let status = repo.authenticate_change_known(&h, Some(&meta))?; |
|
| 1143 | + | println!("{}", ui::kv("author", ui::author(author_name(repo, &meta)))); |
|
| 1144 | + | if let Some(agent) = via(repo, &status, &meta.author) { |
|
| 1145 | + | println!("{}", ui::kv("via", agent.line())); |
|
| 1146 | + | } |
|
| 1126 | 1147 | println!( |
|
| 1127 | 1148 | "{}", |
|
| 1128 | 1149 | ui::kv( |
|
| … | 1407 unchanged lines |