OXYGENHUB

Send a keyless agent's grant signer with its certificate (oxygen#134) a66c05a6a881

A change an agent signed in a repository its owner never pushed to read
INVALID: the certificate answer now carries the grantor's identity, which
oxy keeps and pushes beside the certificate. Pins oxygenflow with that
oxy.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 1aca4406b116
When
· 2026-10-24 22:00 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
a66c05a6a8810613fb902650d949569c2b18cde7ad34f2ea644b5de4f9a44944
Depends on
026a76d251ce 380996db84e5 42fa95540468 8aa5ef384ef1 8ad8e656a3a6 965b950f9b96 a7e6e8e6094c ab2d8b40f9f4 b5668108487f
Depended on by
27babfa16580 ba0a55bb5b3c
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
7 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **An agent's changes verify in a new repository (oxygen#134):** a keyless agent's change in a repository its owner had never pushed to read INVALID ("grant is by an unknown author"). The hub now sends the grant's signer with each certificate, and with oxy's fix the agent keeps it and pushes it beside the certificate, so the repository can check the grant.
7 8
- **A mistyped address shows the not-found page (#294):** an address the hub could not read, such as a project or issue number that is not a number, answered with a raw parse error ("Invalid URL: Cannot parse value …"); it now shows the site's not-found page, signed-in header and all.
8 9
- **Embed a drop (#280):** a drop's page offers an `<iframe>` snippet; `/{owner}/drops/{id}/embed` shows its files with a link back, framed by any site, with no scripts and no forms.
9 10
- **Comments on drops (#280):** a drop's page has comments: signed-in readers write them in Markdown, the drop's owner is told, and the author, the owner or a site administrator deletes one.
…
285 unchanged lines
modified crates/hub/src/keyless.rs +21 −1
…
48 unchanged lines
49 49
    pub certificate: String,
50 50
    pub subject: String,
51 51
    pub expires_ms: i64,
52 +
    /// For an agent's certificate, the identity that signed its grant
53 +
    /// (base64), so a repository that never saw the owner's current key can
54 +
    /// check the grant, and pass it on with its push (oxygen#134).
55 +
    #[serde(skip_serializing_if = "Option::is_none")]
56 +
    pub grantor_identity: Option<String>,
52 57
}
53 58
54 59
/// Who a credential says its holder is.
…
152 unchanged lines
207 212
        not_before: (now - SKEW_MS) as u64,
208 213
        expires: expires as u64,
209 214
        grant: proven.grant,
215 +
    };
216 +
    // The grant's signer, as linked to its account (the grant was checked
217 +
    // against it when it was made).
218 +
    let grantor_identity = match &cert.grant {
219 +
        Some(g) => db::accounts::identity_by_key(&app.db, &g.grant.grantor.to_hex())
220 +
            .await
221 +
            .ok()
222 +
            .flatten()
223 +
            .map(|l| oxy_core::dsse::base64(&l.object)),
224 +
        None => None,
210 225
    };
211 226
    let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret);
212 227
    let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires);
…
10 unchanged lines
223 238
        }],
224 239
    )
225 240
    .await;
226 -
    Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires }))
241 +
    Ok(Json(CertifyResponse {
242 +
        certificate: oxy_core::dsse::base64(&signed.encode()),
243 +
        subject: proven.subject,
244 +
        expires_ms: expires,
245 +
        grantor_identity,
246 +
    }))
227 247
}
228 248
229 249
#[derive(Deserialize)]
…
142 unchanged lines
modified crates/hub/tests/flow.rs +24 −0
…
6170 unchanged lines
6171 6171
    a.pull("origin").unwrap();
6172 6172
    assert!(matches!(a.authenticate_change(&change).unwrap(), SignatureStatus::Valid { .. }));
6173 6173
6174 +
    // oxygen#134: in a repository that has never seen Andy's identity, the
6175 +
    // agent's change still verifies, locally and on the hub: the grant's
6176 +
    // signer comes with the certificate and travels with the push.
6177 +
    create(&h.app, "andy", "fresh").await;
6178 +
    let fresh_url = format!("{}/andy/fresh", h.base);
6179 +
    let fresh_dir = work.path().join("claude-fresh");
6180 +
    let mut fresh = client(&fresh_url, &fresh_dir).with_home(agent_home.clone());
6181 +
    fresh.config.set("remote.origin.token", &token).unwrap();
6182 +
    fresh.save_config().unwrap();
6183 +
    fresh.set_user_config("author.key", &andy_key.to_hex()).unwrap();
6184 +
    fresh.set_user_config("author.signer", "keyless").unwrap();
6185 +
    assert!(fresh.store().get_identity(&andy_key).is_err(), "the fresh repository has never seen Andy");
6186 +
    write(&fresh_dir, "first.txt", "first\n");
6187 +
    let first = fresh.commit("First, by the agent", &[]).unwrap();
6188 +
    let r = fresh.push("origin", false).unwrap();
6189 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
6190 +
    assert!(matches!(fresh.authenticate_change(&first).unwrap(), SignatureStatus::Valid { .. }), "locally");
6191 +
    let fresh_host = h.app.repos.open(&RepoName::parse("andy/fresh").unwrap()).unwrap();
6192 +
    assert!(
6193 +
        matches!(fresh_host.authenticate_change(&first).unwrap(), SignatureStatus::Valid { sealed: true, .. }),
6194 +
        "on the hub: {:?}",
6195 +
        fresh_host.authenticate_change(&first).unwrap()
6196 +
    );
6197 +
6174 6198
    // Revoked: the next command gets no certificate.
6175 6199
    let (st, _) = call(&h.app, "DELETE", &format!("/api/v1/agents/grants/{grant_id}"), Some(&pat), None).await;
6176 6200
    assert_eq!(st, StatusCode::NO_CONTENT);
…
97 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.