OXYGENHUB

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