feat(git): use agent display name as git author name (#3040)

Agent commits were authored by a raw 63-character npub, which makes `git
log`, `git blame`, and GitHub's author column effectively unreadable.
This uses the agent's display name for `user.name` instead, while
leaving the pubkey where it does real work.

## What changes

`build_git_env` in `crates/buzz-dev-mcp/src/shim.rs` now reads
`BUZZ_ACP_DISPLAY_NAME`, sanitizes it, and uses the result as
`user.name`. When the variable is absent or unusable it falls back to
`info.npub` — byte-identical to today's behavior.

`user.email`, `user.signingkey`, and the whole credential/signing block
are untouched. The pubkey is what NIP-98 auth, NIP-GS signing, and
contributor matching key on, and it stays in the email verbatim.

`crates/buzz-acp/src/lib.rs` forwards the variable into the dev-mcp
server's declared env, mirroring the existing `BUZZ_AUTH_TAG` block. It
reads `std::env::var` directly rather than going through `Config`, so
the variable is picked up whenever the process has it.
`crates/buzz-agent/src/mcp.rs` adds one `PASSTHROUGH_ENV` entry so ACP
clients that spawn `buzz-agent` without declaring the variable on the
wire still propagate it.

## Why a dedicated variable

`BUZZ_ACP_DISPLAY_NAME` is its own contract rather than a reuse of the
ACP session title. Commits outlive sessions: a session title is
per-session UI chrome and may be composed downstream into `Agent ·
#channel`, and if that composed form ever reached the env var, git
attribution would change silently with no test able to catch it. Git
identity gets a variable whose contract is "bare agent display name,
never channel-qualified."

Nothing writes it yet — a one-line Desktop write lands as a follow-up.
Until then `std::env::var` returns `Err`, the npub fallback fires, and
behavior is byte-for-byte current `main`.

## Sanitizing

Strip control characters, Unicode format characters, and angle brackets;
collapse whitespace runs, trim, cap at 80 characters (by `chars()`, so a
multi-byte name is never split mid-UTF-8).

Angle brackets go because git drops them silently rather than erroring:
`Duncan <evil@x.com>` renders as `Duncan evil@x.com <hex@relay>`. It
forges nothing, but it reads as though it might.

The empty result also has to cover more than literal emptiness. git's
`ident.c` treats a set of characters as "crud" — stripped from both
ends, and fatal when a name is *nothing but* those characters:

```
$ git -c user.name=';;' commit -m t
fatal: name consists only of disallowed characters: ;;
```

Verified against git 2.54.0 by committing with each ASCII byte 32..=126
as the entire `user.name`: exactly space, `"`, `'`, `,`, `:`, `;`, `<`,
`>`, `\` abort, plus all control characters (the predicate is `c <=
32`). `.` is not crud in this version, despite older lore. Names that
merely *contain* crud are fine — `O'Brien` and `Smith, Jr.` both commit
cleanly — so the check is "at least one non-crud character survives,"
not "no crud present." Without it, a display name of `;;` or `""` would
abort every commit that agent makes.

## Unicode format characters

`char::is_control` covers only category `Cc`. Category `Cf` — zero-width
spaces and joiners, bidi embedding and override marks, invisible math
operators, tag characters — is neither control, nor whitespace, nor git
crud, so those characters survived every one of the checks above. A
display name of nothing but U+200B ZERO WIDTH SPACE therefore satisfied
"at least one non-crud character survives" and git accepted the commit
with a visually blank author:

```
# pre-fix, BUZZ_ACP_DISPLAY_NAME set to two U+200B
$ git log -1 --format='%an' | xxd -p
e2808be2808b0a
```

Embedded marks were the other half: a trailing U+202E RIGHT-TO-LEFT
OVERRIDE reorders everything after it, so a stored author line renders
as something other than what it stores — the same confusion class the
angle-bracket filtering exists to prevent.

`is_unicode_format` rejects the whole `Cf` category rather than the
known-bad marks, because the boundary that matters is "invisible or
reorders text", not "the codepoint someone thought of". The 21 ranges
come from the UCD's `DerivedGeneralCategory.txt` (17.0.0), cross-checked
against Python's `unicodedata` (16.0.0); both yield exactly the same
set. They are inlined as a `matches!` rather than pulling in a
Unicode-tables crate for one predicate, and a test asserts both
endpoints of every range plus the codepoints immediately outside them —
including U+2065, which sits inside the U+2060 block but is unassigned
rather than `Cf`.

Filtering happens inside the existing per-word filter, so a format-only
name collapses to empty and falls out through the same `None` → npub
path as a crud-only name. No new fallback logic. And because filtering
precedes truncation, invisible padding cannot eat the 80-character
budget.

## NUL is handled one layer up

An interior NUL is a sibling constraint that cannot be fixed here: it
makes `Command::env` fail the entire spawn before this code runs, so it
has to die at the writer. #3028 establishes that pattern for the session
title in `resolve_session_title` via `filter(|c| !c.is_control())`, and
the Desktop follow-up that writes `BUZZ_ACP_DISPLAY_NAME` inherits it.
The shim sanitizer is a second line of defense for values that arrive
from somewhere other than Desktop.

## Verified end to end

Driving the real `buzz-dev-mcp` binary over stdio MCP and committing
inside its shimmed environment:

```
# BUZZ_ACP_DISPLAY_NAME="Duncan Idaho"
Duncan Idaho <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME unset
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME=";;"  (crud-only; would otherwise be fatal)
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME=U+200B U+200B  (format-only; would otherwise be blank)
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME="Duncan" + U+202E  (bidi override stripped)
Duncan <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME="Dun" + U+200B + "can"  (zero-width removed, word not split)
Duncan <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0
```

Signature verification passes in every case — the signing identity is
unchanged.

`Related: #3028` — it establishes the Desktop-side env plumbing this
builds beside; the one-line Desktop follow-up that writes
`BUZZ_ACP_DISPLAY_NAME` alongside the session title ships after it
merges. Not a dependency: with the variable absent, `std::env::var`
returns `Err` and the npub fallback keeps current behavior exactly.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-07-27 12:28:32 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent e2e0079101
commit 18eef633d8
3 changed files with 520 additions and 2 deletions
+66
View File
@@ -4178,6 +4178,18 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
});
}
}
// Forward the agent's display name so dev-mcp can use it as the git
// author name instead of the raw npub. Read from the process env
// rather than Config: this is a pass-through of a contract owned
// upstream, and absent simply means dev-mcp falls back to the npub.
if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") {
if !display_name.is_empty() {
env.push(EnvVar {
name: "BUZZ_ACP_DISPLAY_NAME".into(),
value: display_name,
});
}
}
env
},
}]
@@ -5036,6 +5048,60 @@ mod build_mcp_servers_tests {
assert!(!has_auth_tag, "empty BUZZ_AUTH_TAG should not be forwarded");
}
#[test]
fn test_display_name_set_is_forwarded_to_mcp_server() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("BUZZ_ACP_DISPLAY_NAME", "Duncan");
let config = test_config();
let servers = build_mcp_servers(&config);
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
let entry = servers[0]
.env
.iter()
.find(|e| e.name == "BUZZ_ACP_DISPLAY_NAME");
assert_eq!(
entry.map(|e| e.value.as_str()),
Some("Duncan"),
"a set display name should reach the MCP server verbatim"
);
}
#[test]
fn test_display_name_unset_omits_the_key_entirely() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
let config = test_config();
let servers = build_mcp_servers(&config);
// Absent, not empty-valued: dev-mcp distinguishes the two and only
// falls back to the npub when the key is missing or blank.
assert!(
!servers[0]
.env
.iter()
.any(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"),
"unset display name should not add the key"
);
}
#[test]
fn test_display_name_empty_omits_the_key_entirely() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("BUZZ_ACP_DISPLAY_NAME", "");
let config = test_config();
let servers = build_mcp_servers(&config);
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
assert!(
!servers[0]
.env
.iter()
.any(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"),
"empty display name should not be forwarded"
);
}
#[test]
fn empty_mcp_command_returns_no_servers() {
let mut config = test_config();
+5
View File
@@ -61,6 +61,11 @@ const PASSTHROUGH_ENV: &[&str] = &[
"BUZZ_PRIVATE_KEY",
"BUZZ_RELAY_URL",
"BUZZ_AUTH_TAG",
// Agent display name — dev-mcp uses it as the git author name. On the
// Desktop path this arrives via the wire `mcpServers[].env` declaration
// (which wins here anyway); the allowlist entry covers ACP clients that
// spawn buzz-agent without declaring it.
"BUZZ_ACP_DISPLAY_NAME",
];
// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what
+449 -2
View File
@@ -171,15 +171,126 @@ fn derive_git_email(pubkey_hex: &str) -> String {
format!("{pubkey_hex}@{host}")
}
/// Stable identity contract for git attribution: the bare agent display name,
/// never channel-qualified, safe to embed in commit history.
///
/// Deliberately distinct from `BUZZ_ACP_SESSION_TITLE`, which is per-session UI
/// chrome and may be composed (`Agent · #channel`) by consumers. Commits
/// outlive sessions, so git attribution must not follow a mutable title.
///
/// Nothing writes this yet — when unset, [`build_git_env`] falls back to the
/// npub, which is byte-for-byte today's behavior.
const DISPLAY_NAME_ENV_VAR: &str = "BUZZ_ACP_DISPLAY_NAME";
/// Max characters in a git author name. Nostr display names are unbounded.
const MAX_GIT_USER_NAME_CHARS: usize = 80;
/// Characters git's `ident.c` treats as "crud": stripped from both ends of a
/// name, and — when a name is *nothing but* these — rejected outright with
/// `fatal: name consists only of disallowed characters`.
///
/// Verified empirically against git 2.54.0 by committing with each ASCII byte
/// 32..=126 as the entire `user.name`: exactly space, `"`, `'`, `,`, `:`, `;`,
/// `<`, `>`, and `\` abort. Control characters abort too (the predicate is
/// `c <= 32`). Note `.` is *not* crud in this version despite older lore.
fn is_git_crud(c: char) -> bool {
c <= ' ' || matches!(c, '"' | '\'' | ',' | ':' | ';' | '<' | '>' | '\\')
}
/// Characters in Unicode general category `Cf` (format): zero-width space and
/// joiners, bidi embedding/override marks, invisible math operators, interlinear
/// annotations, and tag characters.
///
/// `char::is_control` covers only `Cc`, so every one of these survives it — and
/// none is whitespace or [`is_git_crud`]. A display name of nothing but U+200B
/// ZERO WIDTH SPACE would therefore satisfy the "at least one non-crud
/// character" gate and hand git a visually blank author instead of falling back
/// to the npub. An embedded U+202E RIGHT-TO-LEFT OVERRIDE is worse: it makes a
/// commit's persisted author line render as something other than what it says,
/// the same confusion the angle-bracket filter exists to prevent.
///
/// The whole category is rejected rather than the two known-bad marks, because
/// the boundary that matters is "invisible or reorders text", not "the codepoint
/// someone thought of". Ranges transcribed from the UCD's
/// `DerivedGeneralCategory.txt` (17.0.0) and independently cross-checked against
/// Python's `unicodedata` (16.0.0); both yield exactly these 21 ranges. Inlined
/// rather than taking a Unicode-tables dependency for one predicate.
fn is_unicode_format(c: char) -> bool {
matches!(c,
'\u{00AD}'
| '\u{0600}'..='\u{0605}'
| '\u{061C}'
| '\u{06DD}'
| '\u{070F}'
| '\u{0890}'..='\u{0891}'
| '\u{08E2}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{110BD}'
| '\u{110CD}'
| '\u{13430}'..='\u{1343F}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{1D173}'..='\u{1D17A}'
| '\u{E0001}'
| '\u{E0020}'..='\u{E007F}'
)
}
/// Normalize a Buzz display name into a git author name, or `None` to fall
/// back to the npub.
///
/// Strips control and Unicode format characters plus angle brackets, collapses
/// whitespace runs, trims, and caps at [`MAX_GIT_USER_NAME_CHARS`] by `chars()`
/// so a multi-byte name cannot be split mid-UTF-8. Angle brackets go because git
/// silently drops them rather than erroring — `Duncan <evil@x.com>` would
/// render as `Duncan evil@x.com <hex@relay>`, which forges nothing but reads as
/// though it might.
///
/// Returns `None` unless at least one non-crud character survives. A bare
/// emptiness check is not sufficient: git rejects a name built only of crud,
/// so a display name of `;;` or `""` would abort **every commit** the agent
/// makes. Falling back to the npub keeps the agent able to commit.
fn sanitize_git_user_name(raw: &str) -> Option<String> {
let collapsed = raw
.split_whitespace()
.map(|word| {
word.chars()
.filter(|c| !c.is_control() && !is_unicode_format(*c) && *c != '<' && *c != '>')
.collect::<String>()
})
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ");
let name: String = collapsed
.chars()
.take(MAX_GIT_USER_NAME_CHARS)
.collect::<String>()
.trim_end()
.to_string();
name.chars().any(|c| !is_git_crud(c)).then_some(name)
}
/// Build GIT_CONFIG_COUNT/KEY/VALUE env vars for ephemeral nostr git config.
/// Composes with any existing GIT_CONFIG_COUNT in the environment. When launched
/// via buzz-agent (which clears env), the base is always 0 — composition only
/// matters when dev-mcp is run directly with pre-existing GIT_CONFIG vars.
fn build_git_env(info: &KeyInfo) -> Vec<(String, String)> {
let email = derive_git_email(&info.pubkey_hex);
// Display name for humans reading `git log`; the pubkey stays in the email,
// which is what NIP-98 auth, NIP-GS signing, and contributor matching key on.
let user_name = std::env::var(DISPLAY_NAME_ENV_VAR)
.ok()
.as_deref()
.and_then(sanitize_git_user_name)
.unwrap_or_else(|| info.npub.clone());
let entries: Vec<(&str, String)> = vec![
// Identity — npub as display name, NIP-05-style email
("user.name", info.npub.clone()),
// Identity — Buzz display name (npub fallback), NIP-05-style email
("user.name", user_name),
("user.email", email),
// Nostr credential helper is additive — it silently declines non-Buzz
// remotes (exits 0, no credential), so git falls through to system
@@ -246,3 +357,339 @@ pub fn artifact_dir(session_root: &Path) -> PathBuf {
let _ = std::fs::create_dir_all(&p);
p
}
#[cfg(test)]
mod git_user_name_tests {
use super::{
build_git_env, is_git_crud, is_unicode_format, sanitize_git_user_name, KeyInfo,
MAX_GIT_USER_NAME_CHARS,
};
use std::sync::Mutex;
/// Env-var-touching tests must run serially — env vars are process-global.
static ENV_LOCK: Mutex<()> = Mutex::new(());
const PUBKEY_HEX: &str = "dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95";
const NPUB: &str = "npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7";
fn key_info() -> KeyInfo {
KeyInfo {
keyfile_path: "/tmp/.nostr-key".into(),
pubkey_hex: PUBKEY_HEX.into(),
npub: NPUB.into(),
}
}
/// Read a git config value back out of the flat GIT_CONFIG_KEY_n/VALUE_n pairs.
fn git_config(env: &[(String, String)], key: &str) -> Option<String> {
let idx = env
.iter()
.find(|(k, v)| k.starts_with("GIT_CONFIG_KEY_") && v == key)?
.0
.strip_prefix("GIT_CONFIG_KEY_")?
.to_owned();
env.iter()
.find(|(k, _)| *k == format!("GIT_CONFIG_VALUE_{idx}"))
.map(|(_, v)| v.clone())
}
#[test]
fn test_ordinary_name_passes_through_unchanged() {
assert_eq!(sanitize_git_user_name("Duncan"), Some("Duncan".into()));
}
#[test]
fn test_angle_brackets_are_stripped_so_no_second_email_is_rendered() {
// git drops the brackets itself and renders `Duncan evil@x.com
// <hex@relay>` — no forgery, but a confusing author line.
assert_eq!(
sanitize_git_user_name("Duncan <evil@x.com>"),
Some("Duncan evil@x.com".into())
);
}
#[test]
fn test_whitespace_control_characters_become_a_single_separator() {
// Newline, tab and carriage return are whitespace: they collapse to one
// space like any other run, so a multi-line name stays readable.
assert_eq!(
sanitize_git_user_name("Dun\ncan\tThe\r\nIdaho"),
Some("Dun can The Idaho".into())
);
}
#[test]
fn test_non_whitespace_control_characters_are_dropped_outright() {
// NUL is the important one: an interior NUL makes `Command::env` fail
// the entire spawn upstream, so it must never survive to git config.
let got = sanitize_git_user_name("Idaho\0Blade\u{7}").expect("non-empty");
assert_eq!(got, "IdahoBlade");
assert!(!got.chars().any(char::is_control));
}
#[test]
fn test_internal_whitespace_runs_collapse_to_one_space() {
assert_eq!(
sanitize_git_user_name(" Duncan Idaho "),
Some("Duncan Idaho".into())
);
}
#[test]
fn test_whitespace_only_name_falls_back_to_npub() {
assert_eq!(sanitize_git_user_name(" \t\n "), None);
}
#[test]
fn test_empty_name_falls_back_to_npub() {
assert_eq!(sanitize_git_user_name(""), None);
}
#[test]
fn test_crud_only_name_falls_back_rather_than_aborting_every_commit() {
// git rejects a name built only of crud with `fatal: name consists
// only of disallowed characters`, which would break EVERY commit the
// agent makes. Verified against git 2.54.0.
for raw in ["<>", ";;", "\"\"", "''", ",", ":", "\\", ",;:"] {
assert_eq!(
sanitize_git_user_name(raw),
None,
"crud-only name {raw:?} must fall back to the npub"
);
}
}
#[test]
fn test_crud_mixed_with_real_characters_is_kept() {
// Legitimate names contain crud; only an all-crud result is fatal.
assert_eq!(sanitize_git_user_name("O'Brien"), Some("O'Brien".into()));
assert_eq!(
sanitize_git_user_name("Smith, Jr."),
Some("Smith, Jr.".into())
);
}
#[test]
fn test_over_length_name_is_truncated_to_the_cap() {
let long = "a".repeat(200);
let got = sanitize_git_user_name(&long).expect("non-empty");
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
}
#[test]
fn test_truncation_never_splits_a_multibyte_character() {
let long = "🐝".repeat(200);
let got = sanitize_git_user_name(&long).expect("non-empty");
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
assert!(got.chars().all(|c| c == '🐝'), "no replacement chars");
}
#[test]
fn test_truncation_does_not_leave_a_trailing_space() {
// Cutting mid-word would otherwise strand the separator at the end.
let raw = format!("{} tail", "a".repeat(MAX_GIT_USER_NAME_CHARS - 1));
let got = sanitize_git_user_name(&raw).expect("non-empty");
assert!(!got.ends_with(' '), "got {got:?}");
}
#[test]
fn test_non_ascii_names_survive() {
assert_eq!(
sanitize_git_user_name("Élodie 🐝"),
Some("Élodie 🐝".into())
);
}
#[test]
fn test_format_only_name_falls_back_to_npub() {
// U+200B is neither control, nor whitespace, nor crud, so before Cf
// filtering this passed the non-crud gate and handed git a visually
// blank author instead of falling back.
assert_eq!(sanitize_git_user_name("\u{200B}\u{200B}"), None);
// Same class, different marks: joiner, word joiner, BOM, bidi override.
for raw in ["\u{200D}", "\u{2060}", "\u{FEFF}", "\u{202E}", "\u{00AD}"] {
assert_eq!(
sanitize_git_user_name(raw),
None,
"format-only name {raw:?} must fall back to the npub"
);
}
}
#[test]
fn test_bidi_override_is_stripped_and_the_name_is_kept() {
// A trailing RLO would reorder everything after it in `git log`, so the
// mark goes and the readable name stays.
assert_eq!(
sanitize_git_user_name("Duncan\u{202E}"),
Some("Duncan".into())
);
assert_eq!(
sanitize_git_user_name("Dun\u{202E}can Idaho"),
Some("Duncan Idaho".into())
);
}
#[test]
fn test_zero_width_space_inside_a_word_is_removed_without_splitting_it() {
// U+200B is not whitespace, so it must not become a separator: the word
// rejoins rather than turning into "Dun can".
assert_eq!(
sanitize_git_user_name("Dun\u{200B}can"),
Some("Duncan".into())
);
}
#[test]
fn test_format_characters_do_not_consume_the_length_budget() {
// Filtering happens before truncation, so invisible padding cannot
// shorten the visible name.
let raw = format!("{}{}", "\u{200B}".repeat(200), "a".repeat(90));
let got = sanitize_git_user_name(&raw).expect("non-empty");
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
assert!(got.chars().all(|c| c == 'a'), "got {got:?}");
}
#[test]
fn test_unicode_format_covers_every_cf_range_and_nothing_adjacent() {
// Both endpoints of each of the 21 `Cf` ranges in UCD 17.0.0. Endpoints
// are what a transcription error moves, so they are what gets asserted.
for c in [
'\u{00AD}',
'\u{0600}',
'\u{0605}',
'\u{061C}',
'\u{06DD}',
'\u{070F}',
'\u{0890}',
'\u{0891}',
'\u{08E2}',
'\u{180E}',
'\u{200B}',
'\u{200F}',
'\u{202A}',
'\u{202E}',
'\u{2060}',
'\u{2064}',
'\u{2066}',
'\u{206F}',
'\u{FEFF}',
'\u{FFF9}',
'\u{FFFB}',
'\u{110BD}',
'\u{110CD}',
'\u{13430}',
'\u{1343F}',
'\u{1BCA0}',
'\u{1BCA3}',
'\u{1D173}',
'\u{1D17A}',
'\u{E0001}',
'\u{E0020}',
'\u{E007F}',
] {
assert!(is_unicode_format(c), "U+{:04X} is Cf", c as u32);
}
// Codepoints immediately outside those ranges, plus ordinary characters.
// U+2065 is the notable one: it sits *inside* the 2060..206F block but
// is unassigned, not `Cf`.
for c in [
'\u{00AC}',
'\u{00AE}',
'\u{05FF}',
'\u{0606}',
'\u{061B}',
'\u{061D}',
'\u{200A}',
'\u{2010}',
'\u{2029}',
'\u{202F}',
'\u{2065}',
'\u{205F}',
'\u{2070}',
'\u{FEFE}',
'\u{FFF8}',
'\u{FFFC}',
'\u{110BC}',
'\u{1342F}',
'\u{E0000}',
'\u{E0080}',
'a',
' ',
'🐝',
'É',
] {
assert!(!is_unicode_format(c), "U+{:04X} is not Cf", c as u32);
}
}
#[test]
fn test_build_git_env_uses_display_name_and_leaves_email_on_the_pubkey() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("BUZZ_ACP_DISPLAY_NAME", "Duncan");
std::env::remove_var("BUZZ_RELAY_URL");
std::env::remove_var("GIT_CONFIG_COUNT");
let env = build_git_env(&key_info());
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
assert_eq!(git_config(&env, "user.name").as_deref(), Some("Duncan"));
// The pubkey — the thing NIP-98 auth, NIP-GS signing, and contributor
// matching key on — must stay in the email untouched.
assert_eq!(
git_config(&env, "user.email").as_deref(),
Some(format!("{PUBKEY_HEX}@buzz").as_str())
);
assert_eq!(
git_config(&env, "user.signingkey").as_deref(),
Some(PUBKEY_HEX)
);
}
#[test]
fn test_build_git_env_falls_back_to_npub_when_display_name_unset() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
std::env::remove_var("BUZZ_RELAY_URL");
std::env::remove_var("GIT_CONFIG_COUNT");
let env = build_git_env(&key_info());
// Today's behavior, and what every agent gets until a writer for
// BUZZ_ACP_DISPLAY_NAME lands on the Desktop side.
assert_eq!(git_config(&env, "user.name").as_deref(), Some(NPUB));
assert_eq!(
git_config(&env, "user.email").as_deref(),
Some(format!("{PUBKEY_HEX}@buzz").as_str())
);
}
#[test]
fn test_build_git_env_falls_back_to_npub_when_display_name_is_unusable() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("BUZZ_RELAY_URL");
std::env::remove_var("GIT_CONFIG_COUNT");
// Crud-only and format-only names both reach git as the npub — one
// would abort every commit, the other would render as blank.
for raw in ["<>", "\u{200B}"] {
std::env::set_var("BUZZ_ACP_DISPLAY_NAME", raw);
let env = build_git_env(&key_info());
assert_eq!(
git_config(&env, "user.name").as_deref(),
Some(NPUB),
"unusable display name {raw:?} must reach git as the npub"
);
}
std::env::remove_var("BUZZ_ACP_DISPLAY_NAME");
}
#[test]
fn test_git_crud_set_matches_observed_git_behavior() {
// Empirically derived from git 2.54.0: these bytes, alone, abort a commit.
for c in [' ', '"', '\'', ',', ':', ';', '<', '>', '\\', '\t', '\n'] {
assert!(is_git_crud(c), "{c:?} should be crud");
}
for c in ['.', '-', '_', '@', '(', 'a', '🐝'] {
assert!(!is_git_crud(c), "{c:?} should not be crud");
}
}
}