OXYGENHUB

Agents push with their grant token 559c39204234

An agent's grant token is now also its credential for the protocol: a
live grant acts as an agent's token of its owner's account, writing when
the grant gives refs and reading otherwise, recorded as via the agent,
and refused once the grant is revoked or ends. A keyless agent's home
then holds one secret, as the design intended (oxy offers the grant when
the home has no hub token). Pins oxygen with that fallback.

Refs #241
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c54fce64ea56
When
· 2026-10-17 21:17 UTC
Signature
✔ signed by agent:claude@andymccall
Hash
559c392042340dc03180c163b407dd81c2f042df30441918b4254b482e7b3b64
Depends on
026a76d251ce 19bb31ff1f5c 1f78655a0466 380996db84e5 42fa95540468 4cc40ccc6034 6a1780558353 8aa5ef384ef1 93be9ce4653e d23464c0ac60
Depended on by
205d8f863e5c 965b950f9b96
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 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
13 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified Cargo.lock +3 −3
…
2300 unchanged lines
2301 2301
2302 2302
[[package]]
2303 2303
name = "oxy-core"
2304 -
version = "0.4.4"
2304 +
version = "0.4.5"
2305 2305
dependencies = [
2306 2306
 "blake3",
2307 2307
 "ed25519-dalek",
…
17 unchanged lines
2325 2325
2326 2326
[[package]]
2327 2327
name = "oxy-server"
2328 -
version = "0.4.4"
2328 +
version = "0.4.5"
2329 2329
dependencies = [
2330 2330
 "axum",
2331 2331
 "axum-server",
…
4 unchanged lines
2336 2336
2337 2337
[[package]]
2338 2338
name = "oxy-strata"
2339 -
version = "0.4.4"
2339 +
version = "0.4.5"
2340 2340
dependencies = [
2341 2341
 "async-trait",
2342 2342
 "bytes",
…
2529 unchanged lines
modified crates/hub/src/protocol.rs +29 −2
…
96 unchanged lines
97 97
            let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
98 98
            return Ok(Caller::Job { repo_id: run.repo_id, also: job.also_read_ids() });
99 99
        }
100 -
        let token = self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?;
100 +
        // An agent's grant token (keyless agents, #241): its owner's
101 +
        // account, as an agent's token, writing when the grant lets its
102 +
        // keys move references. Revoking the grant stops it at once.
103 +
        let grant_token = presented.starts_with(crate::keyless::GRANT_TOKEN_PREFIX);
104 +
        let token = if grant_token {
105 +
            let grant = self.block(db::accounts::agent_grant_by_token(&pool, &hash)).map_err(internal)?;
106 +
            let Some(g) = grant.filter(|g| g.live(db::now_ms())) else {
107 +
                return Err(Failure(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)".into()));
108 +
            };
109 +
            let _ = self.block(db::accounts::touch_agent_grant(&pool, g.id));
110 +
            Some(db::accounts::Token {
111 +
                id: 0,
112 +
                account_id: g.account_id,
113 +
                name: g.agent.clone(),
114 +
                hash: String::new(),
115 +
                scopes: if g.scope.split(',').any(|s| s.trim() == "refs") { "repo:read,repo:write" } else { "repo:read" }.into(),
116 +
                repos: String::new(),
117 +
                created_ms: g.created_ms,
118 +
                expires_ms: Some(g.expires_ms),
119 +
                last_used_ms: g.last_used_ms,
120 +
                renew_ms: None,
121 +
                agent: true,
122 +
            })
123 +
        } else {
124 +
            self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?
125 +
        };
101 126
        let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
102 127
        if token.expired(db::now_ms()) {
103 128
            return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into()));
…
5 unchanged lines
109 134
        }
110 135
        if account.disabled {
111 136
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is disabled; contact the hub's administrator".into()));
137 +
        }
138 +
        if !grant_token {
139 +
            let _ = self.block(db::accounts::touch_token(&pool, token.id));
112 140
        }
113 -
        let _ = self.block(db::accounts::touch_token(&pool, token.id));
114 141
        Ok(Caller::Token(Box::new(account), Box::new(token)))
115 142
    }
116 143
}
…
306 unchanged lines
modified crates/hub/tests/flow.rs +6 −2
…
5012 unchanged lines
5013 5013
        let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
5014 5014
        let dir = work.path().join(format!("claude-{n}"));
5015 5015
        let mut r = Repo::clone(&url, &dir).unwrap().with_home(agent_home.clone());
5016 -
        r.config.set("remote.origin.token", "site").unwrap();
5016 +
        // The grant token is its only credential: it pushes with it too.
5017 +
        r.config.set("remote.origin.token", &token).unwrap();
5017 5018
        r.save_config().unwrap();
5018 5019
        r.set_user_config("author.key", &andy_key.to_hex()).unwrap();
5019 5020
        r.set_user_config("author.signer", "keyless").unwrap();
…
29 unchanged lines
5049 5050
    write(&dir, "late.txt", "after the revocation\n");
5050 5051
    late.commit("Late", &[]).unwrap();
5051 5052
    let err = late.push("origin", false).unwrap_err().to_string();
5052 -
    assert!(err.contains("would not certify"), "{err}");
5053 +
    assert!(err.contains("not valid") || err.contains("would not certify"), "{err}");
5054 +
    // The hub recorded the push as the agent's, for its owner.
5055 +
    let by_agent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pushes WHERE via = 'claude'").fetch_one(&h.app.db).await.unwrap();
5056 +
    assert!(by_agent >= 1, "recorded as via the agent");
5053 5057
    let (_, body) = call(&h.app, "GET", "/api/v1/agents/grants", Some(&pat), None).await;
5054 5058
    let list: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap();
5055 5059
    assert!(list[0]["revoked_ms"].is_i64() && list[0].get("token").is_none(), "{body}");
…
1 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
591 unchanged lines
592 592
593 593
`POST /api/v1/keyless/certify` gives a fresh key a certificate (Oxygen spec §14.7) for ten minutes, signed with the hub's key. The bearer is the credential: a running job's token (subject `repo:OWNER/NAME:bookmark:B`, claims `repository`, `bookmark`, `run`, `job`, `runner`, and `environment` with `approved_by` once an approved job may deploy), or one of this hub's OIDC tokens for the audience `{base_url}/keyless` (its `sub` the subject, and the same claims it carries). The body is `{"identity": base64 identity object, "proof": base64 CBOR Signature, "scope": "changes,refs"}`, the proof the fresh key's challenge signature (§14.3) over `certify_challenge(credential)` (`"oxy1 certify "` and the BLAKE3 of the credential), so it cannot be replayed with another credential. The answer is `{"certificate": base64 [certificate, signature], "subject", "expires_ms"}`; a certificate never outlives its credential, and it starts 30 s back for clocks a little behind. Each one is audited (`keyless.certified`). A runner may sign a job's receipt with such a key, certified just before it signs (scope `seal`): the receipt carries the certificate (Oxygen spec §14.6), and the hub keeps it when the certificate is this hub's, its subject is the run's repository and bookmark, its `run` and `job` claims are the run's and the job's, and it covers the moment the hub accepts it; no runner key is bound then, and the run names the signer as the runner's name with "(short-lived key)". Which issuers and subjects a repository trusts for changes and references is its policy's (docs/keyless.md, step 4). When a key it certified moves a bookmark, the hub signs an `accepted` attestation of the update with its own clock and serves it with the bookmark (Oxygen spec §14.7, step 4c), so fetches check the certificate at the hub's time. The scope given is what was asked within what the credential allows; asking for none of it is refused.
594 594
595 -
**Agent grants** (step 5, built 2026-10-17). An agent holds no key: its owner signs an Oxygen grant (§14.7) letting `agent:NAME@ACCOUNT` act for them, with the key their oxy home names (`oh agent grant NAME [--scope changes,seal,refs] [--for 30d]`), and `POST /api/v1/agents/grants {"grant": base64 signed grant}` keeps it and answers with the agent's token (`oxyagent_…`, shown once, kept as its BLAKE3 hash). The hub accepts a grant only for the caller's own `agent:NAME@USERNAME`, naming this hub's key as issuer, signed by a key linked to the caller's account and not yet ended; any personal token of the account may send it, since the signature is what authorises it. The agent exchanges the token at `keyless/certify` for ten-minute certificates carrying the grant (claims `agent` and `grant`, scope at most the grant's, never past its end), so what its fresh keys sign counts in the owner's name; oxy does this itself with `author.signer = keyless` and the token from `oxy auth grant`. `GET /api/v1/agents/grants` lists the caller's grants without tokens; `DELETE /api/v1/agents/grants/{id}` revokes one at once (`oh agent list`, `oh agent revoke ID`). Migration 0075 (`agent_grants`); audited as `agent.granted` and `agent.revoked`.
595 +
**Agent grants** (step 5, built 2026-10-17). An agent holds no key: its owner signs an Oxygen grant (§14.7) letting `agent:NAME@ACCOUNT` act for them, with the key their oxy home names (`oh agent grant NAME [--scope changes,seal,refs] [--for 30d]`), and `POST /api/v1/agents/grants {"grant": base64 signed grant}` keeps it and answers with the agent's token (`oxyagent_…`, shown once, kept as its BLAKE3 hash). The hub accepts a grant only for the caller's own `agent:NAME@USERNAME`, naming this hub's key as issuer, signed by a key linked to the caller's account and not yet ended; any personal token of the account may send it, since the signature is what authorises it. The agent exchanges the token at `keyless/certify` for ten-minute certificates carrying the grant (claims `agent` and `grant`, scope at most the grant's, never past its end), so what its fresh keys sign counts in the owner's name; oxy does this itself with `author.signer = keyless` and the token from `oxy auth grant`. The token is also the agent's credential for the protocol (fetch and push): it acts as an agent's token of the owner's account, writing when the grant gives `refs` and reading otherwise, so the agent's home holds no other secret, and oxy offers it when the home has no hub token. `GET /api/v1/agents/grants` lists the caller's grants without tokens; `DELETE /api/v1/agents/grants/{id}` revokes one at once (`oh agent list`, `oh agent revoke ID`). Migration 0075 (`agent_grants`); audited as `agent.granted` and `agent.revoked`.
596 596
597 597
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
598 598
…
168 unchanged lines