Keys say when each was last used; Help explains AI identities (closes #286, closes #287) b349e15a3eab
Settings › Keys shows, per linked key, when the hub last accepted a push it signed (a change or a seal, noted while the push is vetted and recorded once accepted, at most once a minute) and when the identity expires, as Tokens do (identities.last_used_ms, migration 0089 in both trees). Help gains an AI identities section: keyless agents (oh agent grant, an agent home with oxy auth grant), how their work shows, admitting them to a trust policy (oxy policy grant-subject), delegation for agents with keys, agent-marked tokens, gates and revoking. Letting the hub act for you now says to export the identity again and upload that.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 229fe27297be
- When
- · 2026-10-20 23:37 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
b349e15a3eab6ff9436d9e4d39833a98fe7d3217bf79a7b77b72a3dad658d6fe- Depends on
- 0ee5c3fb32eb 19bb31ff1f5c 3b1bc27fe805 43e8ca33bc5d 5d90e0e045e2 8597fb5ca16c a7e6e8e6094c b5668108487f d23464c0ac60 ffb67a81188c
- Depended on by
- 42c77f6515c2 84e3c09656b2 737de0fac722
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 20 atoms, 2 tree operations, 10 files
Browse the tree at this change
added crates/hub/migrations-pg/0089_identity_last_used.sql +3 −0
| 1 | + | -- When the hub last received something a linked key signed (oxygenhub#286): |
|
| 2 | + | -- a pushed change or a seal. NULL until then. |
|
| 3 | + | ALTER TABLE identities ADD COLUMN last_used_ms BIGINT; |
added crates/hub/migrations/0089_identity_last_used.sql +3 −0
| 1 | + | -- When the hub last received something a linked key signed (oxygenhub#286): |
|
| 2 | + | -- a pushed change or a seal. NULL until then. |
|
| 3 | + | ALTER TABLE identities ADD COLUMN last_used_ms INTEGER; |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Help: AI identities (#287):** a section on letting an AI agent work for you: keyless agents (a grant with `oh agent grant`, an agent home with `oxy auth grant`, ten-minute certified keys, nothing secret on disk), how its work shows (`via agent:NAME@YOU`), admitting it to a repository's trust policy (`oxy policy grant-subject`), agents with keys of their own (delegation), agent-marked tokens, approval gates, and revoking. "Letting the hub act for you" now says to export your identity again after delegating and upload that. |
|
| 8 | + | - **Settings › Keys says when each key was last used (#286):** like tokens, each linked key shows when it was linked, when the hub last accepted a push it signed (a change or a seal; "never" until then) and when the identity expires. |
|
| 7 | 9 | - **Settings › Security is balanced (#285):** every box runs the page's width: two-factor shows whether it is on in its head, with its field and button on one row; connected accounts are rows with Connect or Disconnect; signed-in browsers are rows with Sign out, and Sign out everywhere else sits in that box's head with the session limits under it. |
|
| 8 | 10 | - **Push without starting a run (#284):** `[skip flow]`, `[skip ci]` or `[ci skip]` in the newest pushed change's message starts no jobs; the run list shows a skipped run naming the change and who pushed it, and the next run still tests what it brought. Tags always run. Maintainers choose where it is allowed under Settings › Runs: any bookmark (the default), any but the default bookmark, or never. |
|
| 9 | 11 | - **A drop's page is centred and its header clearer (#283):** the page ran the full width with everything against the left edge; it is now centred like other pages, with the title, description and details on the left, EDIT and DELETE together on the right (DELETE only for whoever may delete it), the clone line with a copy button under them, and a Markdown file filling its box. |
|
| … | 268 unchanged lines |
modified crates/hub/src/db/accounts.rs +25 −0
| … | 127 unchanged lines |
||
| 128 | 128 | pub email: String, |
|
| 129 | 129 | pub object: Vec<u8>, |
|
| 130 | 130 | pub linked_ms: i64, |
|
| 131 | + | /// When the hub last received something this key signed (#286). |
|
| 132 | + | #[sqlx(default)] |
|
| 133 | + | pub last_used_ms: Option<i64>, |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | impl LinkedIdentity { |
|
| 137 | + | /// When the identity itself expires, if it says. |
|
| 138 | + | pub fn expires_ms(&self) -> Option<i64> { |
|
| 139 | + | oxy_core::identity::Identity::decode(&self.object).ok()?.expires.map(|e| e as i64) |
|
| 140 | + | } |
|
| 131 | 141 | } |
|
| 132 | 142 | ||
| 133 | 143 | #[derive(Clone, Debug, FromRow)] |
|
| … | 546 unchanged lines |
||
| 680 | 690 | ||
| 681 | 691 | pub async fn identities_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<LinkedIdentity>> { |
|
| 682 | 692 | sqlx::query_as("SELECT * FROM identities WHERE account_id = $1 ORDER BY linked_ms DESC").bind(account_id).fetch_all(pool).await |
|
| 693 | + | } |
|
| 694 | + | ||
| 695 | + | /// Note that the hub received something signed by each of `keys` (#286): |
|
| 696 | + | /// linked keys only, at most once a minute each. |
|
| 697 | + | pub async fn identities_used(pool: &Pool, keys: &[String]) -> sqlx::Result<()> { |
|
| 698 | + | let now = now_ms(); |
|
| 699 | + | for k in keys { |
|
| 700 | + | sqlx::query("UPDATE identities SET last_used_ms = $1 WHERE key = $2 AND (last_used_ms IS NULL OR last_used_ms < $3)") |
|
| 701 | + | .bind(now) |
|
| 702 | + | .bind(k) |
|
| 703 | + | .bind(now - 60_000) |
|
| 704 | + | .execute(pool) |
|
| 705 | + | .await?; |
|
| 706 | + | } |
|
| 707 | + | Ok(()) |
|
| 683 | 708 | } |
|
| 684 | 709 | ||
| 685 | 710 | pub async fn identity_by_key(pool: &Pool, key: &str) -> sqlx::Result<Option<LinkedIdentity>> { |
|
| … | 358 unchanged lines |
modified crates/hub/src/protocol.rs +27 −0
| … | 32 unchanged lines |
||
| 33 | 33 | /// note goes on the push log entry of the push that moves references |
|
| 34 | 34 | /// (the parts of a long push before it move none). |
|
| 35 | 35 | scan_notes: Arc<std::sync::Mutex<std::collections::HashMap<i64, String>>>, |
|
| 36 | + | /// The keys that signed what a push to each repository (by name) |
|
| 37 | + | /// brought, noted when it is vetted and recorded once it is accepted |
|
| 38 | + | /// (#286, Settings › Keys' "last used"). |
|
| 39 | + | signers: Arc<std::sync::Mutex<std::collections::HashMap<String, std::collections::BTreeSet<String>>>>, |
|
| 36 | 40 | } |
|
| 37 | 41 | ||
| 38 | 42 | /// Who a request's credential resolved to. |
|
| … | 28 unchanged lines |
||
| 67 | 71 | acceptor: None, |
|
| 68 | 72 | scan_budget: crate::push_protection::Budget::default(), |
|
| 69 | 73 | scan_notes: Arc::default(), |
|
| 74 | + | signers: Arc::default(), |
|
| 70 | 75 | } |
|
| 71 | 76 | } |
|
| 72 | 77 | ||
| … | 241 unchanged lines |
||
| 314 | 319 | updates: &[oxy_core::protocol::RefUpdate], |
|
| 315 | 320 | headers: &HeaderMap, |
|
| 316 | 321 | ) -> Result<(), Failure> { |
|
| 322 | + | let keys: std::collections::BTreeSet<String> = bundle |
|
| 323 | + | .changes |
|
| 324 | + | .iter() |
|
| 325 | + | .flat_map(|(_, sigs)| sigs.iter()) |
|
| 326 | + | .chain(bundle.attests.iter().flat_map(|a| a.sigs.iter())) |
|
| 327 | + | .map(|sig| sig.key.to_hex()) |
|
| 328 | + | .collect(); |
|
| 329 | + | if !keys.is_empty() { |
|
| 330 | + | self.signers.lock().unwrap().entry(name.to_string()).or_default().extend(keys); |
|
| 331 | + | } |
|
| 317 | 332 | if bundle.changes.is_empty() { |
|
| 318 | 333 | return Ok(()); |
|
| 319 | 334 | } |
|
| … | 70 unchanged lines |
||
| 390 | 405 | } |
|
| 391 | 406 | ||
| 392 | 407 | fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) { |
|
| 408 | + | // The keys that signed what it brought were used (#286): linked |
|
| 409 | + | // ones show it under Settings › Keys. |
|
| 410 | + | let signers = self.signers.lock().unwrap().remove(name); |
|
| 411 | + | if let Some(keys) = signers.filter(|_| report.results.iter().any(|r| r.ok)) { |
|
| 412 | + | let pool = self.pool.clone(); |
|
| 413 | + | let keys: Vec<String> = keys.into_iter().collect(); |
|
| 414 | + | tokio::spawn(async move { |
|
| 415 | + | if let Err(e) = db::accounts::identities_used(&pool, &keys).await { |
|
| 416 | + | tracing::warn!("noting keys used: {e}"); |
|
| 417 | + | } |
|
| 418 | + | }); |
|
| 419 | + | } |
|
| 393 | 420 | let Some(parsed) = RepoName::parse(name) else { return }; |
|
| 394 | 421 | // A wiki push starts no run and rings no webhook. |
|
| 395 | 422 | if crate::wiki::parent_of(&parsed).1 { |
|
| … | 227 unchanged lines |
modified crates/hub/src/web/help.rs +16 −0
| … | 39 unchanged lines |
||
| 40 | 40 | format!("{}/{}/REPOSITORY", self.base, self.who) |
|
| 41 | 41 | } |
|
| 42 | 42 | ||
| 43 | + | /// An agent's own home, keyless, with its grant kept (#287). |
|
| 44 | + | pub fn agent_home_cmd(&self) -> String { |
|
| 45 | + | 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", |
|
| 47 | + | base = self.base |
|
| 48 | + | ) |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /// A repository's trust policy admitting an agent's certified keys (#287). |
|
| 52 | + | pub fn subject_grant_cmd(&self) -> String { |
|
| 53 | + | format!( |
|
| 54 | + | "curl -fsSL {}/.well-known/oxyhub-key -o hub.identity\noxy policy grant-subject agent:claude@{} --issuer hub.identity --scope changes,refs", |
|
| 55 | + | self.base, self.who |
|
| 56 | + | ) |
|
| 57 | + | } |
|
| 58 | + | ||
| 43 | 59 | pub fn delegate_cmd(&self) -> String { |
|
| 44 | 60 | format!( |
|
| 45 | 61 | "curl -fsSL {}/.well-known/oxyhub-key -o hub.identity\noxy identity delegate add hub.identity --scope changes,refs", |
|
| … | 12 unchanged lines |
modified crates/hub/tests/accounts.rs +17 −0
| … | 258 unchanged lines |
||
| 259 | 259 | let sig_hex = hex::encode(oxy_core::identity::Signature::encode_list(&[sig])); |
|
| 260 | 260 | let r = post(app, "/settings/keys/verify", Some(&andy), &format!("_csrf={csrf}&challenge={challenge_id}&signature={sig_hex}")).await; |
|
| 261 | 261 | assert!(r.body.contains("Key linked"), "{}", r.body); |
|
| 262 | + | // oxygenhub#286: a linked key is "last used" when a push it signed is |
|
| 263 | + | // accepted. |
|
| 264 | + | let keys = get(app, "/settings/keys", Some(&andy)).await.body; |
|
| 265 | + | assert!(keys.contains("last used never") && keys.contains("never expires"), "{keys}"); |
|
| 266 | + | write(work.path(), "b.txt", b"b\n"); |
|
| 267 | + | client.snapshot("t").unwrap(); |
|
| 268 | + | client.commit("second", &[]).unwrap(); |
|
| 269 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 270 | + | let mut used = String::new(); |
|
| 271 | + | for _ in 0..100 { |
|
| 272 | + | used = get(app, "/settings/keys", Some(&andy)).await.body; |
|
| 273 | + | if !used.contains("last used never") { |
|
| 274 | + | break; |
|
| 275 | + | } |
|
| 276 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 277 | + | } |
|
| 278 | + | assert!(used.contains("last used <time"), "{used}"); |
|
| 262 | 279 | let hist = get(app, "/andy/secret/changes/main", Some(&andy)).await.body; |
|
| 263 | 280 | assert!(hist.contains("href=\"/andy\">Andy McCall</a>"), "author linked to the account: {hist}"); |
|
| 264 | 281 | let change = get(app, &format!("/andy/secret/change/{}", c1.to_hex()), Some(&andy)).await.body; |
|
| … | 1492 unchanged lines |
modified crates/hub/tests/browse.rs +5 −1
| … | 112 unchanged lines |
||
| 113 | 113 | assert!(help.contains(&format!("oxy clone {}/OWNER/REPOSITORY", h.base))); |
|
| 114 | 114 | assert!(help.contains(&format!("curl -fsSL {}/.well-known/oxyhub-key -o hub.identity", h.base)), "the delegation recipe"); |
|
| 115 | 115 | assert!(!help.contains("oxyhub-ca.pem"), "no certificate step for a hub without a self-signed certificate"); |
|
| 116 | - | for id in ["start", "identity", "push", "clone", "proposals", "delegate", "flow", "keys", "glossary"] { |
|
| 116 | + | for id in ["start", "identity", "push", "clone", "proposals", "delegate", "agents", "flow", "keys", "glossary"] { |
|
| 117 | 117 | assert!(help.contains(&format!("id=\"{id}\"")) && help.contains(&format!("href=\"#{id}\"")), "section {id}"); |
|
| 118 | 118 | } |
|
| 119 | + | // oxygenhub#287: AI identities, with this hub in the commands. |
|
| 120 | + | assert!(help.contains("oh agent grant claude") && help.contains(&format!("oxy auth grant {}", h.base)), "agent grants"); |
|
| 121 | + | assert!(help.contains("oxy policy grant-subject agent:claude@OWNER --issuer hub.identity"), "subject grants"); |
|
| 122 | + | assert!(help.contains("via agent:claude@OWNER"), "how agent work shows"); |
|
| 119 | 123 | assert!(help.contains("<strong>bookmark</strong>") && help.contains("<strong>proposal</strong>"), "glossary"); |
|
| 120 | 124 | assert!(home_has_help(&page(&h.app, "/explore").await), "linked from every page"); |
|
| 121 | 125 | ||
| … | 206 unchanged lines |
modified templates/help.html +29 −1
| … | 9 unchanged lines |
||
| 10 | 10 | <a href="#clone">Cloning</a> |
|
| 11 | 11 | <a href="#proposals">Proposals</a> |
|
| 12 | 12 | <a href="#delegate">Letting the hub act for you</a> |
|
| 13 | + | <a href="#agents">AI identities</a> |
|
| 13 | 14 | <a href="#flow">Pipelines</a> |
|
| 14 | 15 | <a href="#keys">Keyboard</a> |
|
| 15 | 16 | <a href="#backups">Backups</a> |
|
| … | 63 unchanged lines |
||
| 79 | 80 | <p>Edits in the browser and landings from the proposal page are changes too, and they must be signed. The hub signs them as your <strong>delegate</strong>, only if you allow it. Allowing it is a one-off step:</p> |
|
| 80 | 81 | {% let cmd = self.delegate_cmd() %} |
|
| 81 | 82 | <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div> |
|
| 82 | - | <p>Then link your identity again under Settings › Keys, so the hub sees the delegation. You can withdraw it at any time with <code>oxy identity delegate remove</code>; a repository's trust policy still decides whether a delegated change may update a bookmark.</p> |
|
| 83 | + | <p>That makes a new version of your identity: export it in the same checkout (<code>oxy identity export</code>) and upload that file under Settings › Keys, signing the challenge there too, so the hub sees the delegation. The hub's key file is not the one to upload. You can withdraw it at any time with <code>oxy identity delegate remove</code>; a repository's trust policy still decides whether a delegated change may update a bookmark.</p> |
|
| 83 | 84 | </section> |
|
| 84 | 85 | ||
| 86 | + | <section class="panel" id="agents"> |
|
| 87 | + | <h2>AI identities</h2> |
|
| 88 | + | <p>An AI agent (a coding assistant, a bot) can work on your repositories in its own name and in yours: its changes are signed, they say which agent made them, and you decide what it may do and for how long. Nothing it is given is a password or a key of yours.</p> |
|
| 89 | + | <h3>Keyless agents (recommended)</h3> |
|
| 90 | + | <p>A keyless agent holds no key at all. You give it a <strong>grant</strong>: a token your own key signs, valid for a time you choose. Whenever the agent signs, it makes a fresh key, the hub certifies it for ten minutes under your grant, and the key is thrown away; a stolen home holds nothing that signs by itself.</p> |
|
| 91 | + | <p>On your side, name the agent and how long it may act for you (the token is printed once):</p> |
|
| 92 | + | {% let cmd = "oh agent grant claude --for 30d --scope changes,seal,refs" %} |
|
| 93 | + | <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div> |
|
| 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 | + | {% let cmd = self.agent_home_cmd() %} |
|
| 96 | + | <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div> |
|
| 97 | + | <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 | + | <h3>Where it may push</h3> |
|
| 99 | + | <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> |
|
| 100 | + | {% let cmd = self.subject_grant_cmd() %} |
|
| 101 | + | <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div> |
|
| 102 | + | <p>A repository without a policy takes any signed change its token may push.</p> |
|
| 103 | + | <h3>Agents with a key of their own</h3> |
|
| 104 | + | <p>An agent that keeps its own identity acts for you through a <strong>delegation</strong>: from your checkout, <code>oxy identity delegate add agent.identity --scope changes --expires 7d</code>, then link your identity again under {% if shell.user.is_some() %}<a href="/settings/keys">Settings › Keys</a>{% else %}Settings › Keys{% endif %}. <code>oxy identity delegate remove KEY</code> withdraws it. Keyless grants are simpler and leave nothing behind.</p> |
|
| 105 | + | <h3>Tokens, notifications and gates</h3> |
|
| 106 | + | <ul> |
|
| 107 | + | <li>A personal access token an agent uses can be marked <strong>agent</strong> under {% if shell.user.is_some() %}<a href="/settings/tokens">Settings › Tokens</a>{% else %}Settings › Tokens{% endif %}: proposals it opens and comments it makes reach your inbox and email like anyone else's.</li> |
|
| 108 | + | <li>An approval gate is for a person: an agent's token cannot approve or reject one unless a repository's maintainers allow it under Settings › Gates & agents, and each decision still says which agent made it.</li> |
|
| 109 | + | <li>Commits whose message says a skip marker (<code>[skip flow]</code>) start no pipeline run; agents should leave them out unless asked.</li> |
|
| 110 | + | </ul> |
|
| 111 | + | </section> |
|
| 112 | + | ||
| 85 | 113 | <section class="panel" id="flow"> |
|
| 86 | 114 | <h2>Pipelines</h2> |
|
| 87 | 115 | <p>Pipelines run in <strong>OxygenFlow</strong>. Put a pipeline in <code>.flow/pipeline.kdl</code> and push: each job runs in its own microVM with no network, and its result shows on the repository's Flow tab and as a check named <code>flow/JOB</code> on proposals. A site administrator enables Flow for a repository.</p> |
|
| … | 56 unchanged lines |
modified templates/settings_keys.html +1 −1
| … | 28 unchanged lines |
||
| 29 | 29 | <li> |
|
| 30 | 30 | <div class="token-main"> |
|
| 31 | 31 | <div class="token-name"><code>{{ k.key|short }}</code> <strong>{{ k.name }}</strong> <span class="mute">{{ k.email }}</span></div> |
|
| 32 | - | <div class="small mute">linked <time title="{{ k.linked_ms|when_i }}">{{ k.linked_ms|ago_i }}</time></div> |
|
| 32 | + | <div class="small mute">linked <time title="{{ k.linked_ms|when_i }}">{{ k.linked_ms|ago_i }}</time> · last used {% if let Some(u) = k.last_used_ms %}<time title="{{ u|when_i }}">{{ u|ago_i }}</time>{% else %}never{% endif %} · {% if let Some(x) = k.expires_ms() %}expires {{ x|when_i }}{% else %}never expires{% endif %}</div> |
|
| 33 | 33 | </div> |
|
| 34 | 34 | <div class="token-actions"> |
|
| 35 | 35 | <form class="inline" method="post" action="/settings/keys/unlink"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="key" value="{{ k.key }}"><button class="small danger" title="Changes it signs stop counting as yours">Unlink</button></form> |
|
| … | 16 unchanged lines |