author.for: a keyless home follows its owner's newest identity (refs #82) 2452b4407ab7
With author.for = ACCOUNT in a keyless home, the identity the hub names with each certificate (author_chain, newest first, back to the grant's key) is kept in the home's keys/author.for, its chain stored beside the change so the grant checks, and the home authors as it from then on. A repository's own author.key pin still wins. The hub's half follows.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3f98fa274e23
- When
- · 2026-10-26 10:35 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
2452b4407ab7f6eb8fdb1d4b4427343da74643f1d4650f304dba8e6951f5604a- Depends on
- 4afe288b7946 767ac6cd0545 8fd78eb52ad2 982864068a59 c895bc52e4e0 e4595038502a ee06a6c557b7 f0ef9778e587 ff0af1ee299d ff1f62fee654
- Depended on by
- 6f4226bfb87f 80b069facc69
- In bookmarks
- identical-sides main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.8.0 v0.9.0
- Edits
- 15 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **A keyless agent follows its owner's newest identity (#82).** Every delegation rotates its owner's identity, so the `author.key` an agent's home was given goes stale, and its changes named an identity two rotations old (verifying only because a grant from before a rotation still counts). With `author.for = ACCOUNT` in the home, the hub sends the account's newest linked identity with each certificate, with its chain back to the grant's key; oxy keeps it in the home (`keys/author.for`), stores the chain beside the change so the grant checks, and authors as it from then on. A repository's own `author.key` pin still wins. |
|
| 6 | + | ||
| 5 | 7 | - **Changelogs stop conflicting (#145).** A file marked `merge=union` in `.oxyattributes` (`CHANGELOG.md merge=union`) has its adjacent insertions settled by the checkout itself: both sides kept, the earlier change's first, as `oxy resolve --both` does, and the next commit records the resolution. Only adjacent insertions: where two changes changed the same lines, the markers stay. Oxygen's own changelog is marked so. |
|
| 6 | 8 | ||
| 7 | 9 | - **Two changes adding lines at the same place are settled with one command (#121).** Like Git, oxy leaves a conflict when two changes each add lines after the same line, since either order can be wrong for code; settling it meant editing out the markers by hand. `oxy resolve --both [PATH]…` now keeps every side of each such *adjacent insertion*, the earlier change's first (`--reverse` for the other order), and `oxy status` says which conflicts qualify. It leaves the markers where the sides changed the same lines (keeping both would keep two versions of them) or where the lines between the markers were edited. Conflict markers now list their sides earliest change first, not in hash order. |
|
| … | 277 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +23 −0
| … | 24 unchanged lines |
||
| 25 | 25 | /// `keyless.hub`: the hub that certifies them (default: the default |
|
| 26 | 26 | /// remote's hub). |
|
| 27 | 27 | pub keyless_hub: Option<String>, |
|
| 28 | + | /// `author.for` (oxygen#82): in a keyless agent's home, the account it |
|
| 29 | + | /// acts for; the home then authors as that person's newest identity, |
|
| 30 | + | /// which the hub names with each certificate, rather than a pinned |
|
| 31 | + | /// `author.key` that goes stale with every rotation. |
|
| 32 | + | pub author_for: Option<String>, |
|
| 28 | 33 | /// `core.sign`: sign every change at commit (`true`) or leave signing to |
|
| 29 | 34 | /// push-time seals (`false`, the spec default). |
|
| 30 | 35 | pub sign_changes: bool, |
|
| … | 64 unchanged lines |
||
| 95 | 100 | .and_then(|v| KeyId::from_hex(&v)), |
|
| 96 | 101 | signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"), |
|
| 97 | 102 | keyless_hub: None, |
|
| 103 | + | author_for: None, |
|
| 98 | 104 | sign_changes: false, |
|
| 99 | 105 | bookmark: "main".into(), |
|
| 100 | 106 | remotes: BTreeMap::new(), |
|
| … | 79 unchanged lines |
||
| 180 | 186 | } |
|
| 181 | 187 | "workspace.bookmark" => self.bookmark = value.into(), |
|
| 182 | 188 | "keyless.hub" => self.keyless_hub = (!value.is_empty()).then(|| value.to_string()), |
|
| 189 | + | "author.for" => self.author_for = (!value.is_empty()).then(|| value.to_string()), |
|
| 183 | 190 | // Read by the credential store from the user file. |
|
| 184 | 191 | "auth.helper" => {} |
|
| 185 | 192 | "core.op-retention" => self.op_retention_days = parse_days(value)?, |
|
| … | 105 unchanged lines |
||
| 291 | 298 | c.repo_layer.insert(k, v); |
|
| 292 | 299 | } |
|
| 293 | 300 | } |
|
| 301 | + | // author.for (oxygen#82): the identity the hub last named as the |
|
| 302 | + | // person's newest, kept in the home; a repository's own |
|
| 303 | + | // author.key pin still wins. |
|
| 304 | + | if c.author_for.is_some() && !c.repo_layer.contains_key("author.key") { |
|
| 305 | + | if let Some(k) = user_path |
|
| 306 | + | .and_then(Path::parent) |
|
| 307 | + | .and_then(|home| fs::read_to_string(home.join("keys").join(AUTHOR_FOR_FILE)).ok()) |
|
| 308 | + | .and_then(|s| KeyId::from_hex(s.trim())) |
|
| 309 | + | { |
|
| 310 | + | c.author_key = Some(k); |
|
| 311 | + | } |
|
| 312 | + | } |
|
| 294 | 313 | Ok(c) |
|
| 295 | 314 | } |
|
| 296 | 315 | ||
| … | 6 unchanged lines |
||
| 303 | 322 | &self.repo_layer |
|
| 304 | 323 | } |
|
| 305 | 324 | } |
|
| 325 | + | ||
| 326 | + | /// In a home's `keys/`: the key of the identity `author.for` follows, |
|
| 327 | + | /// as the hub last named it (oxygen#82). |
|
| 328 | + | pub const AUTHOR_FOR_FILE: &str = "author.for"; |
|
| 306 | 329 | ||
| 307 | 330 | /// A byte count with an optional `K`, `M`, or `G` suffix (binary units). |
|
| 308 | 331 | /// `storage.cache` when unset: 2 GiB. |
|
| … | 52 unchanged lines |
modified crates/oxy-core/src/repo/keyless.rs +57 −0
| … | 151 unchanged lines |
||
| 152 | 152 | } |
|
| 153 | 153 | } |
|
| 154 | 154 | } |
|
| 155 | + | if let (Some(owner), Some(g)) = (&self.config.author_for, &cert.cert.grant) { |
|
| 156 | + | self.follow_author(owner, &cert.cert.subject, g.grant.grantor, &reply, &home)?; |
|
| 157 | + | } |
|
| 155 | 158 | self.store.put_identity(&issuer)?; |
|
| 156 | 159 | self.store.put_identity(&id)?; |
|
| 157 | 160 | self.store.put_certificate(&cert)?; |
|
| 158 | 161 | *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires)); |
|
| 159 | 162 | Ok((key, sk)) |
|
| 163 | + | } |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | impl Repo { |
|
| 167 | + | /// `author.for` (oxygen#82): keep the identity the hub names as the |
|
| 168 | + | /// grant's owner's newest (`author_chain`, newest first, each the |
|
| 169 | + | /// successor of the next, the last the grant's key's), store the chain |
|
| 170 | + | /// here so the grant checks against it, and note its key in the home, |
|
| 171 | + | /// so this and the home's next commands author as it. |
|
| 172 | + | fn follow_author( |
|
| 173 | + | &self, |
|
| 174 | + | owner: &str, |
|
| 175 | + | subject: &str, |
|
| 176 | + | grantor: KeyId, |
|
| 177 | + | reply: &serde_json::Value, |
|
| 178 | + | home: &Path, |
|
| 179 | + | ) -> Result<()> { |
|
| 180 | + | let granted_by = subject.rsplit_once('@').map(|(_, o)| o).unwrap_or(subject); |
|
| 181 | + | if !granted_by.eq_ignore_ascii_case(owner) { |
|
| 182 | + | return fail(format!( |
|
| 183 | + | "author.for is {owner}, but this home's grant is from {granted_by}: set author.for to the account that granted it" |
|
| 184 | + | )); |
|
| 185 | + | } |
|
| 186 | + | let chain: Vec<Identity> = reply["author_chain"] |
|
| 187 | + | .as_array() |
|
| 188 | + | .map(|a| { |
|
| 189 | + | a.iter() |
|
| 190 | + | .filter_map(|v| v.as_str().and_then(crate::dsse::base64_decode)) |
|
| 191 | + | .filter_map(|b| Identity::decode(&b).ok()) |
|
| 192 | + | .collect() |
|
| 193 | + | }) |
|
| 194 | + | .unwrap_or_default(); |
|
| 195 | + | let linked = chain.windows(2).all(|w| w[0].previous == Some(w[1].hash())) |
|
| 196 | + | && chain.last().is_none_or(|l| l.previous == Some(grantor)); |
|
| 197 | + | if !linked { |
|
| 198 | + | return fail( |
|
| 199 | + | "the hub's author chain does not lead back to the grant's key".to_string(), |
|
| 200 | + | ); |
|
| 201 | + | } |
|
| 202 | + | let keys = home.join("keys"); |
|
| 203 | + | std::fs::create_dir_all(&keys)?; |
|
| 204 | + | for id in &chain { |
|
| 205 | + | self.store.put_identity(id)?; |
|
| 206 | + | let path = keys.join(format!("{}.identity", id.hash().to_hex())); |
|
| 207 | + | if !path.exists() { |
|
| 208 | + | crate::durable::write_atomic(&path, &id.encode())?; |
|
| 209 | + | } |
|
| 210 | + | } |
|
| 211 | + | let newest = chain.first().map(Identity::hash).unwrap_or(grantor); |
|
| 212 | + | crate::durable::write_atomic( |
|
| 213 | + | &keys.join(super::config::AUTHOR_FOR_FILE), |
|
| 214 | + | format!("{}\n", newest.to_hex()).as_bytes(), |
|
| 215 | + | )?; |
|
| 216 | + | Ok(()) |
|
| 160 | 217 | } |
|
| 161 | 218 | } |
|
| 162 | 219 | ||
| … | 61 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +48 −0
| … | 10548 unchanged lines |
||
| 10549 | 10549 | assert!(!backend.has("pack-copy.idx").unwrap()); |
|
| 10550 | 10550 | crate::store::TEST_BACKEND.with(|t| *t.borrow_mut() = None); |
|
| 10551 | 10551 | } |
|
| 10552 | + | ||
| 10553 | + | /// oxygen#82: a home with `author.for` authors as the identity its hub |
|
| 10554 | + | /// last named (kept in `keys/author.for`), not its stale `author.key`; a |
|
| 10555 | + | /// repository's own `author.key` pin still wins, and without `author.for` |
|
| 10556 | + | /// the note is ignored. |
|
| 10557 | + | #[test] |
|
| 10558 | + | fn author_for_follows_the_key_the_hub_named() { |
|
| 10559 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 10560 | + | let home = dir.path().join("home"); |
|
| 10561 | + | fs::create_dir_all(home.join("keys")).unwrap(); |
|
| 10562 | + | let user = home.join("config.toml"); |
|
| 10563 | + | let repo_cfg = dir.path().join("config"); |
|
| 10564 | + | let (old, new, pin) = ( |
|
| 10565 | + | KeyId::from_hex(&"a".repeat(64)).unwrap(), |
|
| 10566 | + | KeyId::from_hex(&"b".repeat(64)).unwrap(), |
|
| 10567 | + | KeyId::from_hex(&"c".repeat(64)).unwrap(), |
|
| 10568 | + | ); |
|
| 10569 | + | fs::write(&user, format!("author.key = \"{}\"\n", old.to_hex())).unwrap(); |
|
| 10570 | + | fs::write( |
|
| 10571 | + | home.join("keys").join(super::config::AUTHOR_FOR_FILE), |
|
| 10572 | + | format!("{}\n", new.to_hex()), |
|
| 10573 | + | ) |
|
| 10574 | + | .unwrap(); |
|
| 10575 | + | fs::write(&repo_cfg, "").unwrap(); |
|
| 10576 | + | assert_eq!( |
|
| 10577 | + | Config::load(Some(&user), &repo_cfg).unwrap().author_key, |
|
| 10578 | + | Some(old), |
|
| 10579 | + | "no author.for: the note is ignored" |
|
| 10580 | + | ); |
|
| 10581 | + | fs::write( |
|
| 10582 | + | &user, |
|
| 10583 | + | format!( |
|
| 10584 | + | "author.key = \"{}\"\nauthor.for = \"andymccall\"\n", |
|
| 10585 | + | old.to_hex() |
|
| 10586 | + | ), |
|
| 10587 | + | ) |
|
| 10588 | + | .unwrap(); |
|
| 10589 | + | assert_eq!( |
|
| 10590 | + | Config::load(Some(&user), &repo_cfg).unwrap().author_key, |
|
| 10591 | + | Some(new) |
|
| 10592 | + | ); |
|
| 10593 | + | fs::write(&repo_cfg, format!("author.key = \"{}\"\n", pin.to_hex())).unwrap(); |
|
| 10594 | + | assert_eq!( |
|
| 10595 | + | Config::load(Some(&user), &repo_cfg).unwrap().author_key, |
|
| 10596 | + | Some(pin), |
|
| 10597 | + | "a repository's pin wins" |
|
| 10598 | + | ); |
|
| 10599 | + | } |
modified docs/guide.md +7 −1
| … | 499 unchanged lines |
||
| 500 | 500 | oxy auth grant < token # the agent's shell (its own OXY_HOME) |
|
| 501 | 501 | oxy config --user author.key <YOUR KeyId> |
|
| 502 | 502 | oxy config --user author.signer keyless |
|
| 503 | + | oxy config --user author.for <YOUR ACCOUNT> # follow your newest identity |
|
| 503 | 504 | ``` |
|
| 504 | 505 | ||
| 505 | 506 | Each certificate lasts ten minutes and carries your grant, so the |
|
| … | 2 unchanged lines |
||
| 508 | 509 | `oxy policy grant-subject agent:claude@YOU --issuer hub.identity --scope |
|
| 509 | 510 | changes` (add `refs` to let it push), with the hub's identity from |
|
| 510 | 511 | `/.well-known/oxyhub-key`. `oh agent list` shows your grants and `oh |
|
| 511 | - | agent revoke ID` stops one at once; there is no key to rotate. |
|
| 512 | + | agent revoke ID` stops one at once; there is no key to rotate. When you |
|
| 513 | + | rotate your own key (every delegation does), an agent with `author.for` |
|
| 514 | + | follows: the hub names your newest identity with each certificate, and |
|
| 515 | + | the agent authors as it from then on, rather than as the key you gave it. |
|
| 516 | + | A repository whose own config pins `author.key` keeps that pin. |
|
| 512 | 517 | ||
| 513 | 518 | ``` |
|
| 514 | 519 | oxy identity revoke # retire your own key |
|
| … | 142 unchanged lines |
||
| 657 | 662 | | Key | Meaning | |
|
| 658 | 663 | |---|---| |
|
| 659 | 664 | | `author.name`, `author.email`, `author.key` | who you are; `key` is set by `identity create` | |
|
| 665 | + | | `author.for` | in a keyless agent's home, the account it acts for: it authors as that person's newest identity, as the hub names it (a repository's own `author.key` pin still wins) | |
|
| 660 | 666 | | `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate; or `keyless`, for fresh keys the hub certifies under the agent's grant (also `$OXY_SIGNER`) | |
|
| 661 | 667 | | `keyless.hub` | the hub that certifies keyless keys (default: the default remote's hub) | |
|
| 662 | 668 | | `core.sign` | `change` to sign every commit; otherwise seals at push | |
|
| … | 71 unchanged lines |
modified docs/spec.md +1 −1
| … | 954 unchanged lines |
||
| 955 | 955 | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. A directory's `.gitignore` and `.ignore` files are read too, with lower precedence than its `.oxyignore` (a `!` rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. | |
|
| 956 | 956 | | `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `merge=union` (adjacent insertions settled by keeping both sides, §9.5), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. | |
|
| 957 | 957 | | `.oxy/config` | Repository-local settings, TOML. | |
|
| 958 | - | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; or `keyless`, for fresh keys the hub certifies under an agent's grant, §14.7, from the grant token `oxy auth grant` keeps; also `$OXY_SIGNER`), `keyless.hub` (the hub that certifies them; default the default remote's), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 958 | + | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; or `keyless`, for fresh keys the hub certifies under an agent's grant, §14.7, from the grant token `oxy auth grant` keeps; also `$OXY_SIGNER`), `keyless.hub` (the hub that certifies them; default the default remote's), `author.for` (in a keyless home, the account it acts for: it authors as that account's newest identity, which the hub sends with each certificate with its chain back to the grant's key and the home keeps in `keys/author.for`; a repository's own `author.key` wins), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 959 | 959 | | `~/.config/oxy/credentials.toml` | Tokens from `oxy auth login`, one `[scheme://host[:port]]` section per hub with `token`, `account` and `ca`. Written owner-only; ignored when others can read it. With `auth.helper` set, the token is kept by that helper (Git's credential-helper protocol, user name `oxy-token`) and the section holds the rest. | |
|
| 960 | 960 | ||
| 961 | 961 | Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. A remote's token and CA without `remote.<name>.token` or `.ca` come from `OXY_TOKEN` and `OXY_CA`, then from `credentials.toml` for the remote's hub, then from a remote on the same hub in an enclosing repository's `.oxy/config`. A client MUST NOT send a token over plain `http` except to a loopback address, and MUST NOT forward it across a redirect. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs. |
|
| … | 230 unchanged lines |