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 |