OXYGENHUB

Agents show on the website: seals count, the via line knows certified keys, Contributors names agents; help sets an agent's author (closes #352) 42c77f6515c2

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 41477af97008
When
· 2026-10-31 23:22 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
42c77f6515c2cecf3b98ba6310feab38c362e69955388d8ec4d4618886d1ef05
Depends on
42fa95540468 43e8ca33bc5d 84e3c09656b2 8933faff2043 ac689cd21adf b349e15a3eab b5668108487f d23464c0ac60 dc5c64296b0a
Depended on by
1c52d940b4a0 b7e1be264c0a
In bookmarks
main
In tags
v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
21 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Fixed: agents show on the website (#352).** A change a keyless agent sealed (Kimi, Antigravity, Claude: `author.signer = keyless` seals at push) showed as unsigned on change pages, history, proposals and the API, because the hub read only a change's own signature. It now reads seals too: the change says sealed, with "via agent:NAME@you" and "keyless, under your grant", and the Contributors panel names the agent under you. **Help › AI identities** now sets the agent's author (your name, email and the key you make its grant with, filled in when you are signed in) and copies your public identity into its home; without them an agent's changes were unattributed.
7 8
- **Kimi has its own avatar (#351).** Changes signed via an agent named Kimi (`agent:kimi@…`, or Moonshot) now show a Kimi mark, a white K with a blue dot on a dark disc, instead of an identicon, as Claude, Gemini and the others have theirs.
8 9
9 10
## 0.9.17 (2026-10-29)
…
427 unchanged lines
modified crates/hub/src/repos/view.rs +60 −6
…
214 unchanged lines
215 215
    }
216 216
}
217 217
218 +
/// How a change is authenticated: by its own signature, or by a seal over
219 +
/// it (a keyless agent seals at push, as `oxy seal` does; oxygenhub#352).
218 220
pub fn signature(repo: &Repo, hash: &ChangeHash) -> SigView {
219 -
    match repo.verify_change(hash) {
221 +
    match repo.authenticate_change(hash) {
220 222
        Ok(SignatureStatus::Unsigned) => SigView::Unsigned,
221 223
        Ok(SignatureStatus::Valid { key, name, sealed, revoked, .. }) => SigView::Valid { key: key.to_hex(), name, sealed, revoked },
222 224
        Ok(SignatureStatus::Invalid(why)) => SigView::Invalid(why),
…
16 unchanged lines
239 241
    /// The signing key was revoked, or the delegation is no longer in the
240 242
    /// author's identity: the change deserves a second look.
241 243
    pub revoked: bool,
244 +
    /// A short-lived key a hub certified under the author's grant (a
245 +
    /// keyless agent, spec §14.7), rather than a delegate in the author's
246 +
    /// identity.
247 +
    pub certified: bool,
242 248
}
243 249
244 250
impl ViaView {
245 251
    /// The delegation, as the change page's `via` row says it.
246 252
    pub fn delegation(&self) -> String {
253 +
        if self.certified {
254 +
            return format!("keyless, under your grant for {}", self.scope);
255 +
        }
247 256
        if self.revoked {
248 257
            return "delegation revoked".into();
249 258
        }
…
9 unchanged lines
259 268
            Some(e) => format!(", until {}", when_utc(e)),
260 269
            None => String::new(),
261 270
        };
262 -
        if self.revoked {
271 +
        if self.certified {
272 +
            format!(
273 +
                "sealed by {} with a short-lived key the hub certified under the author's grant ({}), key {}",
274 +
                self.name, self.scope, self.short
275 +
            )
276 +
        } else if self.revoked {
263 277
            format!("signed by {} as a delegate of the author; that delegation has since been revoked", self.name)
264 278
        } else {
265 279
            format!("signed by {} as a delegate of the author ({}{until}), key {}", self.name, self.scope, self.short)
…
16 unchanged lines
282 296
        return None;
283 297
    }
284 298
    let signer = KeyId::from_hex(key)?;
299 +
    let email = repo.store().get_identity(&signer).map(|i| i.email).unwrap_or_default();
300 +
    // A keyless agent's key is in no identity: its certificate says for
301 +
    // whom and what; authenticating the change checked its grant.
302 +
    if let Some(cert) = certificate(repo, &signer) {
303 +
        return Some(ViaView {
304 +
            name: name.clone(),
305 +
            email,
306 +
            key: key.clone(),
307 +
            short: signer.short(12),
308 +
            scope: cert.scope.to_string(),
309 +
            expires_ms: Some(cert.expires),
310 +
            revoked: *revoked,
311 +
            certified: true,
312 +
        });
313 +
    }
285 314
    let entry = repo.store().get_identity(author).ok().and_then(|a| a.delegate(&signer).copied());
286 -
    let email = repo.store().get_identity(&signer).map(|i| i.email).unwrap_or_default();
287 315
    Some(ViaView {
288 316
        name: name.clone(),
289 317
        email,
…
2 unchanged lines
292 320
        scope: entry.map(|d| d.scope.to_string()).unwrap_or_else(|| "changes".into()),
293 321
        expires_ms: entry.and_then(|d| d.expires),
294 322
        revoked: *revoked || entry.is_none(),
323 +
        certified: false,
295 324
    })
296 325
}
297 326
327 +
/// The certificate a hub gave short-lived key `key`, if the store keeps one.
328 +
fn certificate(repo: &Repo, key: &KeyId) -> Option<oxy_core::cert::Certificate> {
329 +
    let r = repo.store().certificate_of(key).ok()??;
330 +
    repo.store().get_certificate(&r).ok().map(|c| c.cert)
331 +
}
332 +
333 +
/// The key that sealed `hash` for its author, when another key did: the
334 +
/// first seal's signer (read, not verified: the change rows do that).
335 +
fn sealer(repo: &Repo, hash: &ChangeHash, author: &KeyId) -> Option<KeyId> {
336 +
    let seals = repo.store().seals_for(hash).ok()?;
337 +
    seals.iter().find_map(|a| repo.store().signatures(a).ok()?.into_iter().map(|s| s.key).find(|k| k != author))
338 +
}
339 +
298 340
/// One change as history rows and change pages show it.
299 341
#[derive(Clone, Debug)]
300 342
pub struct ChangeView {
…
690 unchanged lines
991 1033
        });
992 1034
        entry.changes += 1;
993 1035
        if !meta.author.is_zero() {
994 -
            let signer = repo.store().signatures(hash).ok().and_then(|s| s.first().map(|s| s.key)).filter(|k| *k != meta.author);
1036 +
            // A delegate signs the change; a keyless agent seals it (#352).
1037 +
            let signer = repo
1038 +
                .store()
1039 +
                .signatures(hash)
1040 +
                .ok()
1041 +
                .and_then(|s| s.first().map(|s| s.key))
1042 +
                .filter(|k| *k != meta.author)
1043 +
                .or_else(|| sealer(repo, hash, &meta.author));
995 1044
            if let Some(k) = signer {
996 -
                let who =
997 -
                    names.entry(k).or_insert_with(|| repo.store().get_identity(&k).map(|i| i.name).unwrap_or_else(|_| k.short(12))).clone();
1045 +
                let who = names
1046 +
                    .entry(k)
1047 +
                    .or_insert_with(|| match certificate(repo, &k) {
1048 +
                        Some(c) => c.subject,
1049 +
                        None => repo.store().get_identity(&k).map(|i| i.name).unwrap_or_else(|_| k.short(12)),
1050 +
                    })
1051 +
                    .clone();
998 1052
                match entry.via.iter_mut().find(|(n, _)| *n == who) {
999 1053
                    Some((_, n)) => *n += 1,
1000 1054
                    None => entry.via.push((who, 1)),
…
576 unchanged lines
modified crates/hub/src/web/help.rs +30 −3
…
16 unchanged lines
17 17
    base: String,
18 18
    /// The signed-in user, or a placeholder, for example URLs.
19 19
    who: String,
20 +
    /// What an agent's changes are authored as: the signed-in user's
21 +
    /// name, email and newest linked key, or placeholders (#352).
22 +
    author: (String, String, String),
20 23
}
21 24
22 25
pub async fn help(State(app): State<Shared>, viewer: Viewer) -> Page<HelpPage> {
23 26
    let shell = Shell::new(&app, "/help", &viewer);
24 27
    let who = shell.user.clone().unwrap_or_else(|| "OWNER".into());
25 28
    let base = app.config.base_url().trim_end_matches('/').to_string();
26 -
    Page(HelpPage { shell, q: Quickstart::new(&app, true), base, who })
29 +
    let mut author = ("YOUR NAME".to_string(), "[email protected]".to_string(), "YOUR_KEY".to_string());
30 +
    if let Some(name) = &shell.user {
31 +
        if let Ok(Some(a)) = crate::db::accounts::account_by_name(&app.db, name).await {
32 +
            let newest = crate::db::accounts::identities_of(&app.db, a.id).await.ok().and_then(|ids| ids.into_iter().next());
33 +
            if let Some(id) = newest {
34 +
                author = (id.name, id.email, id.key);
35 +
            } else {
36 +
                author.0 = if a.display_name.is_empty() { a.username } else { a.display_name };
37 +
            }
38 +
        }
39 +
    }
40 +
    Page(HelpPage { shell, q: Quickstart::new(&app, true), base, who, author })
27 41
}
28 42
29 43
impl HelpPage {
…
10 unchanged lines
40 54
        format!("{}/{}/REPOSITORY", self.base, self.who)
41 55
    }
42 56
43 -
    /// An agent's own home, keyless, with its grant kept (#287).
57 +
    /// An agent's own home, keyless, with its grant kept (#287), authored
58 +
    /// as you with the key you make its grant with (#352): without an
59 +
    /// author its changes are unattributed, and a grant made by a newer key
60 +
    /// than its author key does not count for it.
44 61
    pub fn agent_home_cmd(&self) -> String {
62 +
        let (name, email, key) = &self.author;
45 63
        format!(
46 -
            "export OXY_HOME=~/.oxy-agents/claude\noxy config --user author.signer keyless\noxy config --user keyless.hub {base}\noxy config --user core.sign seal\noxy auth grant {base}   # paste the token, then Ctrl-D",
64 +
            "mkdir -p ~/.oxy-agents/claude/keys\n\
65 +
             oxy identity export ~/.oxy-agents/claude/keys/{key}.identity   # in a checkout of yours, as you\n\
66 +
             export OXY_HOME=~/.oxy-agents/claude\n\
67 +
             oxy config --user author.name \"{name}\"\n\
68 +
             oxy config --user author.email \"{email}\"\n\
69 +
             oxy config --user author.key {key}\n\
70 +
             oxy config --user author.signer keyless\n\
71 +
             oxy config --user keyless.hub {base}\n\
72 +
             oxy config --user core.sign seal\n\
73 +
             oxy auth grant {base}   # paste the token, then Ctrl-D",
47 74
            base = self.base
48 75
        )
49 76
    }
…
24 unchanged lines
modified crates/hub/tests/browse.rs +9 −0
…
126 unchanged lines
127 127
    assert!(help.contains("oh agent grant claude") && help.contains(&format!("oxy auth grant {}", h.base)), "agent grants");
128 128
    assert!(help.contains("oxy policy grant-subject agent:claude@OWNER --issuer hub.identity"), "subject grants");
129 129
    assert!(help.contains("via agent:claude@OWNER"), "how agent work shows");
130 +
    // An agent's home is authored as its owner, or its changes are unattributed (#352).
131 +
    for line in [
132 +
        "oxy config --user author.name",
133 +
        "oxy config --user author.email",
134 +
        "oxy config --user author.key YOUR_KEY",
135 +
        "keys/YOUR_KEY.identity",
136 +
    ] {
137 +
        assert!(help.contains(line), "the agent setup sets {line}");
138 +
    }
130 139
    assert!(help.contains("<strong>bookmark</strong>") && help.contains("<strong>proposal</strong>"), "glossary");
131 140
    assert!(home_has_help(&page(&h.app, "/explore").await), "linked from every page");
132 141
…
206 unchanged lines
modified crates/hub/tests/flow.rs +86 −0
…
7396 unchanged lines
7397 7397
    assert!(a1.updates && a1.channel == "canary" && a1.from[..2] == ["owner acme".to_string(), "group canary".to_string()], "{a1:?}");
7398 7398
    assert!(!ask(creds[1].clone()).await.updates, "a2's own setting wins");
7399 7399
}
7400 +
7401 +
/// oxygenhub#352: a keyless agent's change, authored by its owner and sealed
7402 +
/// with a short-lived key the hub certified under the owner's grant, shows
7403 +
/// as sealed via the agent: on the change over the API (not "unsigned"), on
7404 +
/// the change page, and among the repository's contributors.
7405 +
#[tokio::test(flavor = "multi_thread")]
7406 +
async fn a_change_a_keyless_agent_sealed_shows_the_agent() {
7407 +
    use oxy_core::cert::{Certificate, Grant, SignedCertificate, SignedGrant};
7408 +
    use oxy_core::identity::Scope;
7409 +
    use oxy_core::repo::IdentityScope;
7410 +
    let h = hub().await;
7411 +
    create(&h.app, "andy", "demo").await;
7412 +
    let url = format!("{}/andy/demo", h.base);
7413 +
    let work = tempfile::tempdir().unwrap();
7414 +
    let mut a = client(&url, &work.path().join("a")).with_home(work.path().join("home-a"));
7415 +
    let (_, andy) = a.create_identity("Andy", "[email protected]", IdentityScope::User).unwrap();
7416 +
    write(&work.path().join("a"), "a.txt", "a\n");
7417 +
    a.commit("base", &[]).unwrap();
7418 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
7419 +
7420 +
    // The owner's grant to agent:kimi@andy, and the hub's certificate for
7421 +
    // the agent's fresh key carrying it, as `oxy auth grant` and keyless
7422 +
    // signing make them.
7423 +
    let hub_id = h.app.hub_key.identity.clone();
7424 +
    let (_, hub_secret) = oxygenhub::keys::load_secret(&h.app.config.keys_dir()).unwrap();
7425 +
    let (_, andy_secret) = a.secret_key().unwrap().unwrap();
7426 +
    let now = db::now_ms() as u64;
7427 +
    let scope = Scope::CHANGES | Scope::REFS | Scope::SEAL;
7428 +
    let grant = SignedGrant::sign(
7429 +
        Grant {
7430 +
            grantor: andy.hash(),
7431 +
            subject: "agent:kimi@andy".into(),
7432 +
            issuer: hub_id.hash(),
7433 +
            scope,
7434 +
            not_before: now - 60_000,
7435 +
            expires: now + 86_400_000,
7436 +
        },
7437 +
        &andy,
7438 +
        &andy_secret,
7439 +
    );
7440 +
    let mut b = Repo::clone(&url, &work.path().join("b")).unwrap().with_home(work.path().join("home-b"));
7441 +
    b.config.set("remote.origin.token", "site").unwrap();
7442 +
    b.save_config().unwrap();
7443 +
    let (_, agent) = b.create_identity("kimi", "[email protected]", IdentityScope::User).unwrap();
7444 +
    let cert = SignedCertificate::sign(
7445 +
        Certificate {
7446 +
            key: agent.clone(),
7447 +
            issuer: hub_id.hash(),
7448 +
            subject: "agent:kimi@andy".into(),
7449 +
            claims: vec![],
7450 +
            scope,
7451 +
            not_before: now - 60_000,
7452 +
            expires: now + 600_000,
7453 +
            grant: Some(grant),
7454 +
        },
7455 +
        &hub_id,
7456 +
        &hub_secret,
7457 +
    );
7458 +
    b.store().put_identity(&hub_id).unwrap();
7459 +
    b.store().put_certificate(&cert).unwrap();
7460 +
    // Authored as the owner, sealed (not signed) by the agent's key.
7461 +
    b.config.set("author.key", &andy.hash().to_hex()).unwrap();
7462 +
    b.config.set("author.signer", &agent.hash().to_hex()).unwrap();
7463 +
    b.save_config().unwrap();
7464 +
    write(&work.path().join("b"), "b.txt", "agent work\n");
7465 +
    let change = b.commit("agent work", &[]).unwrap();
7466 +
    assert!(b.store().signatures(&change).unwrap().is_empty(), "the change itself is unsigned");
7467 +
    b.seal(&[change]).unwrap();
7468 +
    let r = b.push("origin", false).unwrap();
7469 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
7470 +
7471 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/changes/{}", change.to_hex()), None, None).await;
7472 +
    assert!(st.is_success(), "{st} {body}");
7473 +
    let v: serde_json::Value = serde_json::from_str(&body).unwrap();
7474 +
    let sig = &v["change"]["signature"];
7475 +
    assert_eq!(sig["status"], "valid", "a sealed change is not unsigned: {sig}");
7476 +
    assert_eq!(sig["sealed"], true, "{sig}");
7477 +
    assert_eq!(sig["key"], agent.hash().to_hex());
7478 +
7479 +
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/change/{}", change.to_hex()), None, None).await;
7480 +
    assert!(page.contains("agent:kimi@andy"), "the change page names the agent");
7481 +
    assert!(page.contains("keyless, under your grant"), "a certified key is not flagged as a lost delegation");
7482 +
    let (_, overview) = call(&h.app, "GET", "/andy/demo", None, None).await;
7483 +
    let contributors = &overview[overview.find("Contributors").expect("a Contributors panel")..];
7484 +
    assert!(contributors.contains("agent:kimi@andy"), "Contributors names the agent under its owner");
7485 +
}
modified templates/help.html +1 −0
…
93 unchanged lines
94 94
  <p>Give the agent its own oxy home (never your own), and keep the token there; it is read from standard input, so it stays out of the shell's history:</p>
95 95
  {% let cmd = self.agent_home_cmd() %}
96 96
  <div class="cmds"><pre><code>{{ cmd }}</code></pre><button type="button" class="copy-btn" data-copy="{{ cmd }}" aria-label="Copy to clipboard"></button></div>
97 +
  <p>The <code>author.*</code> lines make its changes yours{% if shell.user.is_some() %} (filled in with your newest linked key){% endif %}: without them they show as unattributed. Use the key you make the grant with: after you rotate your identity, make a new grant and set the new key here too.</p>
97 98
  <p>The agent then commits and pushes with <code>OXY_HOME</code> set to its home. Its changes are authored as you and sealed <code>via agent:claude@{{ who }}</code>, which history, proposals and your profile show. <code>oh agent list</code> shows your grants; <code>oh agent revoke ID</code> ends one at once, and a grant ends by itself when its time is up.</p>
98 99
  <h3>Where it may push</h3>
99 100
  <p>A repository with a trust policy accepts an agent's work only if the policy names it. Grant the agent's subject, with this hub as the issuer, from a checkout of the repository:</p>
…
72 unchanged lines