From 9c80f2c85757b75820dae9bc887eda69a0b3eb89 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 11 Nov 2026 10:01:07 +0100 Subject: [PATCH] Commits of agents carry a co-author trailer naming the model A git wrapper (docker/git-coauthor -> /usr/local/lib/codecraft/bin/git) adds --trailer "$CODECRAFT_COMMIT_TRAILER" right after the commit subcommand; the agent shell puts it first on PATH and sets the trailer in the script itself (works inside the sandbox, which clears the environment). Default text: "Co-Authored-By: {model} ({provider}) via CodeCraft" (server.commit_trailer, Settings -> General; empty = off). Git skips duplicates (amend, model wrote it). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++ Dockerfile | 2 ++ docker/git-coauthor | 21 +++++++++++++++ server/src/config.rs | 6 +++++ server/src/tools/bash.rs | 47 ++++++++++++++++++++++++++++++++- web/src/components/Settings.tsx | 5 ++++ web/src/i18n/de.ts | 3 +++ web/src/i18n/en.ts | 3 +++ web/src/whatsnew.ts | 4 +++ 9 files changed, 99 insertions(+), 1 deletion(-) create mode 100755 docker/git-coauthor diff --git a/CHANGELOG.md b/CHANGELOG.md index adfc602..ad526d9 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] + +### Added + +- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer + `Co-Authored-By: () via CodeCraft` (a git wrapper in the agent shell, also in + the sandbox; your own commits stay unchanged). The text is set under *Settings → General* + (`server.commit_trailer`, `{model}`, `{provider}`, `{model_id}`; empty = off). + ## [v202611-00020] – 2026-11-11 ### Added diff --git a/Dockerfile b/Dockerfile index 3740996..cf20d16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,8 @@ COPY --from=cli /out /app/cli # Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock). COPY --from=dockercli /docker /usr/local/bin/docker COPY server/ghidra /app/ghidra +# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer). +COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git COPY server/resources /app/resources # The agent's home lives on the data volume: tools it installs in user space # (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds. diff --git a/docker/git-coauthor b/docker/git-coauthor new file mode 100755 index 0000000..a6f8b53 --- /dev/null +++ b/docker/git-coauthor @@ -0,0 +1,21 @@ +#!/bin/bash +# git for the shells of CodeCraft agents: `git commit` gets the co-author trailer from +# $CODECRAFT_COMMIT_TRAILER (model, provider, CodeCraft). Everything else goes to the real +# git unchanged. Git itself skips the trailer when the message already ends with it. +real=${CODECRAFT_REAL_GIT:-/usr/bin/git} +[[ -n $CODECRAFT_COMMIT_TRAILER ]] || exec "$real" "$@" +# The subcommand comes after git's global options (-C dir, -c key=value, --git-dir=…). +i=1 +while (( i <= $# )); do + case ${!i} in + -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) i=$((i + 2)) ;; + --) break ;; + -*) i=$((i + 1)) ;; + *) break ;; + esac +done +if (( i <= $# )) && [[ ${!i} == commit ]]; then + # Right after "commit", so it stays an option even before "-- ". + exec "$real" "${@:1:i}" --trailer "$CODECRAFT_COMMIT_TRAILER" "${@:i+1}" +fi +exec "$real" "$@" diff --git a/server/src/config.rs b/server/src/config.rs index 72eee26..85da1c6 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -307,8 +307,13 @@ pub struct ServerConfig { /// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled. #[serde(skip_serializing_if = "String::is_empty")] pub metrics_token: String, + /// Trailer added to every git commit an agent makes ({model}, {provider}, {model_id}); + /// empty = off. + pub commit_trailer: String, } +pub const DEFAULT_COMMIT_TRAILER: &str = "Co-Authored-By: {model} ({provider}) via CodeCraft"; + impl Default for ServerConfig { fn default() -> Self { Self { @@ -327,6 +332,7 @@ impl Default for ServerConfig { max_upload_mb: 500, update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(), metrics_token: String::new(), + commit_trailer: DEFAULT_COMMIT_TRAILER.into(), } } } diff --git a/server/src/tools/bash.rs b/server/src/tools/bash.rs index 227ba89..44dec2c 100644 --- a/server/src/tools/bash.rs +++ b/server/src/tools/bash.rs @@ -135,7 +135,7 @@ impl Tool for Bash { /// The bash command for a script, sandboxed for non-admin users. pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result { // stderr of every command (not just the last one) goes into the output. - let script = format!("exec 2>&1\n{command}"); + let script = format!("exec 2>&1\n{}{command}", commit_attribution(ctx)); Ok(match &ctx.sandbox { Some(spec) => { if !crate::sandbox::available() { @@ -161,6 +161,36 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result String { + std::env::var("CODECRAFT_GIT_WRAPPER").unwrap_or_else(|_| "/usr/local/lib/codecraft/bin".into()) +} + +/// Shell lines that make `git commit` in this command carry the co-author trailer of +/// the session's model (see docker/git-coauthor). Set in the script itself, so it +/// works inside the sandbox (which clears the environment) too. +fn commit_attribution(ctx: &ToolCtx) -> String { + let template = ctx.app.config().server.commit_trailer.clone(); + let dir = git_wrapper_dir(); + if template.trim().is_empty() || !std::path::Path::new(&dir).join("git").exists() { + return String::new(); + } + let Ok(session) = ctx.app.session(&ctx.session_id) else { return String::new() }; + let (provider, model_id) = session.model.split_once('/').unwrap_or(("", session.model.as_str())); + let name = ctx.app.registry().resolve(&session.model).ok().and_then(|(_, m)| m.name).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| model_id.to_string()); + let trailer = commit_trailer(&template, &name, provider, model_id); + format!("export PATH={}:\"$PATH\" CODECRAFT_COMMIT_TRAILER={}\n", shell_quote(&dir), shell_quote(&trailer)) +} + +/// The trailer text for a model ("Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"). +pub fn commit_trailer(template: &str, model: &str, provider: &str, model_id: &str) -> String { + template.replace("{model}", model).replace("{provider}", provider).replace("{model_id}", model_id).replace(['\n', '\r'], " ").trim().to_string() +} + +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + fn tail(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); @@ -171,3 +201,18 @@ fn tail(s: &str, max: usize) -> String { } s[start..].to_string() } + +#[cfg(test)] +mod commit_tests { + use super::*; + + #[test] + fn commit_trailer_text() { + assert_eq!( + commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"), + "Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft" + ); + assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x"); + assert_eq!(shell_quote("it's"), "'it'\\''s'"); + } +} diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 4b63eba..e65d9f1 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -248,6 +248,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) { small_model: s.small_model ?? "", currency: s.currency ?? "USD", auto_compact_threshold: s.auto_compact_threshold ?? 0.85, + commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string, max_parallel_subagents: s.max_parallel_subagents ?? 4, monthly_budget: s.monthly_budget ?? 0, budget_warning: s.budget_warning ?? 0.8, @@ -271,6 +272,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) { { path: ["server", "small_model"], value: form.small_model }, { path: ["server", "currency"], value: form.currency }, { path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) }, + { path: ["server", "commit_trailer"], value: form.commit_trailer.trim() }, { path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) }, { path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 }, { path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 }, @@ -305,6 +307,9 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) { setForm({ ...form, auto_compact_threshold: e.target.value })} />
{t("settings.autoCompactHint")}
+ + setForm({ ...form, commit_trailer: e.target.value })} /> +
{t("settings.commitTrailerHint")}
setForm({ ...form, monthly_budget: e.target.value })} />
{t("settings.monthlyBudgetHint")}
diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 3b38f07..c0f10e7 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -770,6 +770,9 @@ export const de: Messages = { "settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.", "settings.currency": "Währung", "settings.autoCompact": "Auto-Komprimierung bei", + "settings.commitTrailer": "Commit-Vermerk der Agenten", + "settings.commitTrailerOff": "aus", + "settings.commitTrailerHint": "Wird als Trailer an jeden Git-Commit eines Agenten angehängt. {model} = Modellname, {provider} = Anbieter, {model_id} = Modell-ID. Leer = aus. Deine eigenen Commits bleiben unverändert.", "settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).", "settings.craft": "Craft-Modus", "settings.cleanRoom": "Clean Room", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index aeae526..7bdadc4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -769,6 +769,9 @@ export const en = { "settings.smallModelHint": "Used for session titles. Compaction uses the session model.", "settings.currency": "Currency", "settings.autoCompact": "Auto-compact at", + "settings.commitTrailer": "Commit note of agents", + "settings.commitTrailerOff": "off", + "settings.commitTrailerHint": "Added as a trailer to every git commit an agent makes. {model} = model name, {provider} = provider, {model_id} = model id. Empty = off. Your own commits stay unchanged.", "settings.autoCompactHint": "Fraction of the context window (0 = off).", "settings.craft": "Craft mode", "settings.cleanRoom": "Clean room", diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 1c68289..3db551c 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: ["Git commits of agents name the model: “Co-Authored-By: () via CodeCraft” (Settings → General)"], + de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: () via CodeCraft“ (Einstellungen → Allgemein)"], + }, "202611-00020": { en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"], de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],