OXYGENHUB

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