diff --git a/CHANGELOG.md b/CHANGELOG.md index a242910..f0edf6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Fixed + +- Commits by agents, terminals and the git panel always use the name and e-mail from + *Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email` + (agents had set those as a workaround while no identity was configured). Agents are told not + to change the git identity. + ## [v202611-00004] – 2026-11-09 ### Added diff --git a/core/src/prompts.rs b/core/src/prompts.rs index 28b0188..b391522 100644 --- a/core/src/prompts.rs +++ b/core/src/prompts.rs @@ -14,6 +14,7 @@ pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent w - Use the task tool for broad searches or independent sub-tasks to keep your own context focused. - If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it. - Never commit, push or perform destructive/irreversible actions unless the user asked for it. +- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`). - Reference code as path:line. - Answer in the language the user writes in."#; diff --git a/server/src/git.rs b/server/src/git.rs index 65c7043..875166c 100644 --- a/server/src/git.rs +++ b/server/src/git.rs @@ -44,6 +44,8 @@ tokio::task_local! { static SANDBOX: Option; /// The user's git config (credentials, identity) for unsandboxed runs. static GITCONFIG: Option; + /// The user's commit identity (overrides a repository's own user.name/email). + static GITENV: Vec<(String, String)>; } async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result { @@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result { if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() { c.env("GIT_CONFIG_GLOBAL", cfg); } + c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default()); c } }; @@ -171,7 +174,8 @@ fn parse_status(raw: &str) -> Value { pub async fn handle(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { let spec = crate::sandbox::spec_for(&app, Some(&user.id)); let cfg = crate::gitcreds::global_config(&app, &user); - SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await + let env = crate::gitcreds::identity_env(&app, &user.id); + SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await } async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult { @@ -388,8 +392,9 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str { pub async fn worktree(app: &std::sync::Arc, user: &User, session: &mut crate::types::Session, action: &str) -> Result { let spec = crate::sandbox::spec_for(app, Some(&user.id)); let cfg = crate::gitcreds::global_config(app, user); + let env = crate::gitcreds::identity_env(app, &user.id); let s = session.clone(); - let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?; + let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?; if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) { session.cwd = cwd.to_string(); } diff --git a/server/src/gitcreds.rs b/server/src/gitcreds.rs index 4383ad2..4c68b27 100644 --- a/server/src/gitcreds.rs +++ b/server/src/gitcreds.rs @@ -35,6 +35,24 @@ pub struct GitSettings { pub credentials: Vec, } +/// The user's commit identity as environment variables. They take precedence over a +/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and +/// the git panel always carry the identity from the settings. +pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> { + let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default(); + let mut env = Vec::new(); + let (name, email) = (s.name.trim(), s.email.trim()); + if !name.is_empty() { + env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string())); + env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string())); + } + if !email.is_empty() { + env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string())); + env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string())); + } + env +} + pub fn home_of(app: &App, user: &User) -> PathBuf { app.data_dir().join("users").join(&user.username).join("home") } diff --git a/server/src/sandbox.rs b/server/src/sandbox.rs index 1150ea7..bac3706 100644 --- a/server/src/sandbox.rs +++ b/server/src/sandbox.rs @@ -18,6 +18,8 @@ pub struct SandboxSpec { pub roots: Vec, /// Host directory mounted as the user's home. pub home: PathBuf, + /// Extra environment (the user's git identity). + pub env: Vec<(String, String)>, } impl SandboxSpec { @@ -69,7 +71,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option { } let home = app.data_dir().join("users").join(&user.username).join("home"); let _ = std::fs::create_dir_all(&home); - Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home }) + Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) }) } /// Build a command that runs `program args` inside the sandbox in `cwd`. @@ -148,9 +150,31 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio ] { push(&[&"--setenv", &k, &v]); } + for (k, v) in &spec.env { + push(&[&"--setenv", k, v]); + } push(&[&"--chdir", &cwd, &program]); for a in args { push(&[a]); } c } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn git_identity_reaches_the_sandbox() { + let spec = SandboxSpec { + username: "ann".into(), + roots: vec![], + home: PathBuf::from("/tmp/ann"), + env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())], + }; + let args: Vec = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect(); + let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set"); + assert_eq!(args[i - 1], "--setenv"); + assert_eq!(args[i + 1], "Ann Example"); + } +} diff --git a/server/src/terminal.rs b/server/src/terminal.rs index ae3b9df..1b70a7d 100644 --- a/server/src/terminal.rs +++ b/server/src/terminal.rs @@ -157,7 +157,12 @@ pub fn spawn(app: &Arc, user: &User, cwd: &Path, session_id: Option pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..])) } None => { - let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1"); + let c = pty_process::Command::new(shell[0]) + .args(&shell[1..]) + .current_dir(cwd) + .env("TERM", "xterm-256color") + .env("CODECRAFT", "1") + .envs(crate::gitcreds::identity_env(app, &user.id)); match crate::gitcreds::global_config(app, user) { Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg), None => c, diff --git a/server/src/tools/bash.rs b/server/src/tools/bash.rs index e146889..227ba89 100644 --- a/server/src/tools/bash.rs +++ b/server/src/tools/bash.rs @@ -150,8 +150,11 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result = { + unreleased: { + en: ["Agents commit with the git name and e-mail from your settings – also in repositories with their own identity"], + de: ["Agents committen mit Git-Name und -E-Mail aus deinen Einstellungen – auch in Repos mit eigener Identität"], + }, "202611-00004": { en: [ "Chats side by side: “Open beside” in a chat's menu in the sidebar",