Compare commits

...
2 Commits
Author SHA1 Message Date
Richard Zink c77dcf5890 Release v202611-00021 2026-11-11 10:01:07 +01:00
Richard ZinkandClaude Opus 5.5 9c80f2c857 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 <noreply@anthropic.com>
2026-11-11 10:01:07 +01:00
10 changed files with 100 additions and 2 deletions

No files matched your search

+9
View File
@@ -3,6 +3,15 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-00021] – 2026-11-11
### Added
- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer
`Co-Authored-By: <model> (<provider>) 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 ## [v202611-00020] – 2026-11-11
### Added ### Added
+2
View File
@@ -96,6 +96,8 @@ COPY --from=cli /out /app/cli
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock). # Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
COPY --from=dockercli /docker /usr/local/bin/docker COPY --from=dockercli /docker /usr/local/bin/docker
COPY server/ghidra /app/ghidra 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 COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space # 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. # (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00020" src="https://img.shields.io/badge/version-v202611--00020-d69e2e?style=flat-square"> <img alt="Version v202611-00021" src="https://img.shields.io/badge/version-v202611--00021-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+21
View File
@@ -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 "-- <paths>".
exec "$real" "${@:1:i}" --trailer "$CODECRAFT_COMMIT_TRAILER" "${@:i+1}"
fi
exec "$real" "$@"
+6
View File
@@ -307,8 +307,13 @@ pub struct ServerConfig {
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled. /// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
#[serde(skip_serializing_if = "String::is_empty")] #[serde(skip_serializing_if = "String::is_empty")]
pub metrics_token: String, 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 { impl Default for ServerConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -327,6 +332,7 @@ impl Default for ServerConfig {
max_upload_mb: 500, max_upload_mb: 500,
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(), update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
metrics_token: String::new(), metrics_token: String::new(),
commit_trailer: DEFAULT_COMMIT_TRAILER.into(),
} }
} }
} }
+46 -1
View File
@@ -135,7 +135,7 @@ impl Tool for Bash {
/// The bash command for a script, sandboxed for non-admin users. /// The bash command for a script, sandboxed for non-admin users.
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> { pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
// stderr of every command (not just the last one) goes into the output. // 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 { Ok(match &ctx.sandbox {
Some(spec) => { Some(spec) => {
if !crate::sandbox::available() { if !crate::sandbox::available() {
@@ -161,6 +161,36 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
}) })
} }
/// Directory with the git wrapper that adds the co-author trailer to commits.
fn git_wrapper_dir() -> 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 { fn tail(s: &str, max: usize) -> String {
if s.len() <= max { if s.len() <= max {
return s.to_string(); return s.to_string();
@@ -171,3 +201,18 @@ fn tail(s: &str, max: usize) -> String {
} }
s[start..].to_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'");
}
}
+5
View File
@@ -248,6 +248,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
small_model: s.small_model ?? "", small_model: s.small_model ?? "",
currency: s.currency ?? "USD", currency: s.currency ?? "USD",
auto_compact_threshold: s.auto_compact_threshold ?? 0.85, 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, max_parallel_subagents: s.max_parallel_subagents ?? 4,
monthly_budget: s.monthly_budget ?? 0, monthly_budget: s.monthly_budget ?? 0,
budget_warning: s.budget_warning ?? 0.8, 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", "small_model"], value: form.small_model },
{ path: ["server", "currency"], value: form.currency }, { path: ["server", "currency"], value: form.currency },
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) }, { 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", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 }, { path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 }, { path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
@@ -305,6 +307,9 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<label>{t("settings.autoCompact")}</label> <label>{t("settings.autoCompact")}</label>
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} /> <input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
<div className="hint">{t("settings.autoCompactHint")}</div> <div className="hint">{t("settings.autoCompactHint")}</div>
<label>{t("settings.commitTrailer")}</label>
<input className="mono" value={form.commit_trailer} placeholder={t("settings.commitTrailerOff")} onChange={(e) => setForm({ ...form, commit_trailer: e.target.value })} />
<div className="hint">{t("settings.commitTrailerHint")}</div>
<label>{t("settings.monthlyBudgetTotal")}</label> <label>{t("settings.monthlyBudgetTotal")}</label>
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} /> <input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
<div className="hint">{t("settings.monthlyBudgetHint")}</div> <div className="hint">{t("settings.monthlyBudgetHint")}</div>
+3
View File
@@ -770,6 +770,9 @@ export const de: Messages = {
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.", "settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
"settings.currency": "Währung", "settings.currency": "Währung",
"settings.autoCompact": "Auto-Komprimierung bei", "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.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
"settings.craft": "Craft-Modus", "settings.craft": "Craft-Modus",
"settings.cleanRoom": "Clean Room", "settings.cleanRoom": "Clean Room",
+3
View File
@@ -769,6 +769,9 @@ export const en = {
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.", "settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
"settings.currency": "Currency", "settings.currency": "Currency",
"settings.autoCompact": "Auto-compact at", "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.autoCompactHint": "Fraction of the context window (0 = off).",
"settings.craft": "Craft mode", "settings.craft": "Craft mode",
"settings.cleanRoom": "Clean room", "settings.cleanRoom": "Clean room",
+4
View File
@@ -3,6 +3,10 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00021": {
en: ["Git commits of agents name the model: “Co-Authored-By: <model> (<provider>) via CodeCraft” (Settings → General)"],
de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: <Modell> (<Anbieter>) via CodeCraft“ (Einstellungen → Allgemein)"],
},
"202611-00020": { "202611-00020": {
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"], 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"], de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],