From f0cd404f5987bf85aa1e1139a71e893080550796 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 15 Nov 2026 10:52:07 +0100 Subject: [PATCH] Tell agents about stored git credentials; give Claude Code the user's git config Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++++++++ server/src/claude_code.rs | 17 +++++++++++++--- server/src/gitcreds.rs | 42 +++++++++++++++++++++++++++++++++++++++ server/src/prompts.rs | 3 +++ web/src/whatsnew.ts | 4 ++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4419e1a..035d594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Fixed + +- Agents now know that git credentials are stored (hosts, no secrets) and no longer search the + environment for them; the system prompt explains `git push` just works and how to read the token + for REST calls without printing it. Claude Code runs get the user's git credentials and identity too. + ## [v202611-00031] – 2026-11-15 ### Fixed diff --git a/server/src/claude_code.rs b/server/src/claude_code.rs index 9672f32..589a2d5 100644 --- a/server/src/claude_code.rs +++ b/server/src/claude_code.rs @@ -97,7 +97,14 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul c.env(k, v); } } - c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args); + c.env("TERM", "xterm-256color").env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").args(args); + // The user's git credentials and identity (the CLI runs git for the agent). + if let Some(user) = owner.and_then(|id| app.store.user(id).ok().flatten()) { + if let Some(cfg) = crate::gitcreds::global_config(app, &user) { + c.env("GIT_CONFIG_GLOBAL", cfg); + } + c.envs(crate::gitcreds::identity_env(app, &user.id)); + } Ok(c) } @@ -357,8 +364,12 @@ pub async fn run(app: Arc, session_id: &str, cancel: CancellationToken) -> if let Some(r) = &resume { args.extend(["--resume".into(), r.clone()]); } - if !session.instructions.trim().is_empty() { - args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]); + let mut appended = session.instructions.trim().to_string(); + if let Some(git) = owner.as_deref().and_then(|u| crate::gitcreds::prompt_section(&app, u)) { + appended = format!("{appended}\n{git}").trim().to_string(); + } + if !appended.is_empty() { + args.extend(["--append-system-prompt".into(), appended]); } let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?; diff --git a/server/src/gitcreds.rs b/server/src/gitcreds.rs index 4c68b27..0eabbbf 100644 --- a/server/src/gitcreds.rs +++ b/server/src/gitcreds.rs @@ -53,6 +53,35 @@ pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> { env } +/// What an agent needs to know about the stored git access (no secrets): git already has the +/// credentials, so it must not look for them. None when nothing is stored. +pub fn prompt_section(app: &App, user_id: &str) -> Option { + render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default()) +} + +fn render_prompt(s: &GitSettings) -> Option { + let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect(); + if creds.is_empty() { + return None; + } + let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n"); + for c in &creds { + t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim())); + } + t.push_str( + "git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \ + Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \ + Use https:// remote URLs (SSH keys are not set up). \ + For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \ + `curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \ + (GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n", + ); + if !s.name.trim().is_empty() || !s.email.trim().is_empty() { + t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim())); + } + Some(t) +} + pub fn home_of(app: &App, user: &User) -> PathBuf { app.data_dir().join("users").join(&user.username).join("home") } @@ -159,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings { mod tests { use super::*; + #[test] + fn prompt_names_hosts_but_never_tokens() { + assert!(render_prompt(&GitSettings::default()).is_none()); + let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] }; + let p = render_prompt(&settings).unwrap(); + println!("{p}"); + assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann ")); + assert!(!p.contains("s3cret-token")); + // Entries without a token are not offered. + let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() }; + assert!(render_prompt(&none).is_none()); + } + #[test] fn keeps_masked_tokens() { let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() }; diff --git a/server/src/prompts.rs b/server/src/prompts.rs index 8911f73..12885dd 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -298,6 +298,9 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: & s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim())); } } + if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) { + s.push_str(&git); + } match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::())), Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"), diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index b9dab53..bb7d896 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Agents now use your stored git credentials instead of searching for them (also Claude Code)"], + de: ["Agenten nutzen deine hinterlegten Git-Zugangsdaten, statt danach zu suchen (auch Claude Code)"], + }, "202611-00031": { en: ["Images an agent reads are now shown in the chat (also with Claude Code)"], de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],