OXYGENHUB

Accept an agent's grant token for the API, as its owner's agent 205d8f863e5c

oh run from an agent's home offered the grant token to the API, which
refused it. A live grant token now acts there as it does for the
protocol: an agent's token of the owner, reading (repo:read, user:read)
and writing (repo:write) when the grant lets its keys move references.
Grants stay their owner's to list, make and revoke: an agent's token is
refused there.

Closes #253
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f2ab4df5c317
When
· 2026-10-18 04:18 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
205d8f863e5c9d8c51ae9402c4a1c0ec3b7e8342d57524cfe584ad9f418efd38
Depends on
026a76d251ce 42fa95540468 559c39204234 8aa5ef384ef1 93be9ce4653e b64e2537d702 d23464c0ac60
Depended on by
d4a0043fded4
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
5 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +16 −0
…
358 unchanged lines
359 359
            token_id: None,
360 360
        });
361 361
    }
362 +
    // An agent's grant token (keyless agents, #241, #253): an agent's token
363 +
    // of its owner, reading, and writing when the grant lets its keys move
364 +
    // references, as it is for the protocol.
365 +
    if t.starts_with(crate::keyless::GRANT_TOKEN_PREFIX) {
366 +
        let grant = db::accounts::agent_grant_by_token(&app.db, &auth::token_hash(t)).await?.filter(|g| g.live(db::now_ms()));
367 +
        let Some(g) = grant else {
368 +
            return Err(ApiError(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)".into()));
369 +
        };
370 +
        let account = db::accounts::account_by_id(&app.db, g.account_id).await?.filter(|a| !a.blocked());
371 +
        db::accounts::touch_agent_grant(&app.db, g.id).await.ok();
372 +
        let mut scopes = vec!["repo:read".to_string(), "user:read".to_string()];
373 +
        if g.scope.split(',').any(|s| s.trim() == "refs") {
374 +
            scopes.push("repo:write".into());
375 +
        }
376 +
        return Ok(Caller { account, scopes, site: false, token: g.agent.clone(), token_id: None, agent: true });
377 +
    }
362 378
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
363 379
    match token {
364 380
        Some(tok) if !tok.expired(db::now_ms()) => {
…
2320 unchanged lines
modified crates/hub/src/keyless.rs +4 −0
…
256 unchanged lines
257 257
/// what makes it is the owner's signature, by a key linked to the account.
258 258
async fn owner(app: &Shared, headers: &HeaderMap) -> Result<db::accounts::Account, ApiError> {
259 259
    let c = crate::api::caller(app, headers).await?;
260 +
    // Grants are their owner's to make and end, not an agent's.
261 +
    if c.agent {
262 +
        return Err(bad(StatusCode::FORBIDDEN, "an agent's token cannot manage agent grants: use your own"));
263 +
    }
260 264
    c.account.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "a personal access token is required"))
261 265
}
262 266
…
97 unchanged lines
modified crates/hub/tests/flow.rs +6 −0
…
5004 unchanged lines
5005 5005
    let token = v["token"].as_str().unwrap().to_string();
5006 5006
    assert!(token.starts_with("oxyagent_"));
5007 5007
    let grant_id = v["id"].as_i64().unwrap();
5008 +
    // The grant token works for the API too, as its owner's agent
5009 +
    // (#253), but cannot manage grants.
5010 +
    let (st, me) = call(&h.app, "GET", "/api/v1/user", Some(&token), None).await;
5011 +
    assert!(st == StatusCode::OK && me.contains("\"andy\""), "{st} {me}");
5012 +
    let (st, _) = call(&h.app, "GET", "/api/v1/agents/grants", Some(&token), None).await;
5013 +
    assert_eq!(st, StatusCode::FORBIDDEN, "an agent does not manage grants");
5008 5014
5009 5015
    // The agent: a home holding only the grant token, Andy the author.
5010 5016
    let agent_home = work.path().join("home-claude");
…
50 unchanged lines
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`. 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`.
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) and the API: it acts as an agent's token of the owner's account (`repo:read` and `user:read`, and `repo:write` when the grant gives `refs`), so the agent's home holds no other secret, and oxy and oh offer it when the home has no hub token. It cannot list, make or revoke grants (#253). `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