OXYGENHUB

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