OXYGENHUB

Tell an owner what their keyless agent does through the API d4a0043fded4

A grant token's API calls were not marked as an agent's, so an issue
or comment an agent made that way looked like its owner's own doing and
the owner was not told of it. The request's agent is now the grant's
agent, as it is for an agent-marked token (#183).

Refs #253
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c0dea1e00658
When
· 2026-10-18 05:59 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
d4a0043fded4b0533c2f0377373bce57e38279a2b3935cdc62be08123505630b
Depends on
205d8f863e5c 42fa95540468 8aa5ef384ef1 bff9106edcba d23464c0ac60
Depended on by
nothing yet
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
4 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/hub/src/lib.rs +9 −2
…
231 unchanged lines
232 232
        .get(axum::http::header::AUTHORIZATION)
233 233
        .and_then(|v| v.to_str().ok())
234 234
        .and_then(|v| v.strip_prefix("Bearer "))
235 -
        .map(|t| auth::token_hash(t.trim()));
235 +
        .map(|t| t.trim().to_string());
236 236
    let agent = match token {
237 -
        Some(hash) => db::accounts::token_by_hash(&app.db, &hash).await.ok().flatten().filter(|t| t.agent).map(|t| t.name),
237 +
        // A keyless agent's grant token (#253) is always an agent's.
238 +
        Some(t) if t.starts_with(keyless::GRANT_TOKEN_PREFIX) => db::accounts::agent_grant_by_token(&app.db, &auth::token_hash(&t))
239 +
            .await
240 +
            .ok()
241 +
            .flatten()
242 +
            .filter(|g| g.live(db::now_ms()))
243 +
            .map(|g| g.agent),
244 +
        Some(t) => db::accounts::token_by_hash(&app.db, &auth::token_hash(&t)).await.ok().flatten().filter(|t| t.agent).map(|t| t.name),
238 245
        None => None,
239 246
    };
240 247
    notify::AGENT.scope(agent, next.run(req)).await
…
104 unchanged lines
modified crates/hub/tests/flow.rs +13 −0
…
5010 unchanged lines
5011 5011
    assert!(st == StatusCode::OK && me.contains("\"andy\""), "{st} {me}");
5012 5012
    let (st, _) = call(&h.app, "GET", "/api/v1/agents/grants", Some(&token), None).await;
5013 5013
    assert_eq!(st, StatusCode::FORBIDDEN, "an agent does not manage grants");
5014 +
    // What the agent does through the API is told to its owner, as an
5015 +
    // agent token's is (#183).
5016 +
    let repo = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
5017 +
    db::notify::set_watch(&h.app.db, andy.id, repo.id, true).await.unwrap();
5018 +
    let body = serde_json::json!({ "title": "Filed by the agent", "body": "" }).to_string();
5019 +
    let (st, out) = call(&h.app, "POST", "/api/v1/repos/andy/demo/issues", Some(&token), Some(body)).await;
5020 +
    assert_eq!(st, StatusCode::CREATED, "{out}");
5021 +
    let actor: String = sqlx::query_scalar("SELECT actor FROM notifications WHERE account_id = $1 AND title LIKE '%Filed by the agent%'")
5022 +
        .bind(andy.id)
5023 +
        .fetch_one(&h.app.db)
5024 +
        .await
5025 +
        .expect("told of the agent's issue");
5026 +
    assert!(actor.starts_with("andy (agent token "), "{actor}");
5014 5027
5015 5028
    // The agent: a home holding only the grant token, Andy the author.
5016 5029
    let agent_home = work.path().join("home-claude");
…
50 unchanged lines