Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,11 @@ flight at a time), controlled by `run.max_concurrent_repos`.
in the PR body so a human sees it immediately.
7. **Deliver** — the remote is re-checked, then pushed; a draft PR is opened with `Closes #<n>`,
verification result, model used, and cost; the issue is commented with the PR link;
`agent-working` and `agent-planned` are swapped for `agent-done` or `agent-failed`.
`agent-working` and `agent-planned` are swapped for `agent-done` or `agent-failed`. Every commit on
the branch — whether made by the harness or by Claude itself during step 5 — carries the
`git.author_name`/`git.author_email` identity, not your own; the PR itself is still opened under
the `gh` token's account, since that is a GitHub-level attribution the identity config does not
touch.
8. **Cleanup** — the worktree is removed (kept on disk if `workspace.keep_failed` and the run
failed, for post-mortem). The claim is always released, on every exit path.

Expand Down Expand Up @@ -346,6 +350,10 @@ This repository's own `config.json` is also **compiled into the binary** at buil
"enabled": false,
"webhook_url": ""
},
"git": {
"author_name": "coding-agent-loop[bot]",
"author_email": "coding-agent-loop@users.noreply.github.com"
},
"models_path": "models.json"
}
```
Expand Down Expand Up @@ -387,6 +395,8 @@ This repository's own `config.json` is also **compiled into the binary** at buil
| `store.path` | SQLite database path |
| `discord.enabled` | turn on Discord status notifications (see [Discord notifications](#discord-notifications)) |
| `discord.webhook_url` | the channel's incoming webhook URL; **required** if `discord.enabled` is true |
| `git.author_name` | commit author name for work the loop produces; distinct from your own so agent commits are easy to spot; empty falls back to `coding-agent-loop[bot]` |
| `git.author_email` | commit author email to go with `git.author_name`; empty falls back to `coding-agent-loop@users.noreply.github.com` |
| `models_path` | where to look for `models.json`; see [Embedded defaults](#embedded-defaults) for how the default value is resolved |

### models.json
Expand Down
12 changes: 7 additions & 5 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,13 @@ func run(f flags) error {
ghBinary = abs
}
gitMgr := &gitpkg.Manager{
ReposRoot: cfg.Workspace.ReposRoot,
WorkRoot: cfg.Workspace.Root,
GHBinary: ghBinary,
DryRun: suppressMutations,
Log: func(format string, args ...any) { log.Info(fmt.Sprintf(format, args...)) },
ReposRoot: cfg.Workspace.ReposRoot,
WorkRoot: cfg.Workspace.Root,
AuthorName: cfg.Git.AuthorName,
AuthorEmail: cfg.Git.AuthorEmail,
GHBinary: ghBinary,
DryRun: suppressMutations,
Log: func(format string, args ...any) { log.Info(fmt.Sprintf(format, args...)) },
}
runner := &claude.Runner{Log: func(format string, args ...any) { log.Debug(fmt.Sprintf(format, args...)) }}
gateway := gate.New(st, cfg.Claude, func(format string, args ...any) { log.Info(fmt.Sprintf(format, args...)) })
Expand Down
4 changes: 4 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,9 @@
"enabled": false,
"webhook_url": ""
},
"git": {
"author_name": "coding-agent-loop[bot]",
"author_email": "coding-agent-loop@users.noreply.github.com"
},
"models_path": "models.json"
}
5 changes: 5 additions & 0 deletions internal/claude/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ type Options struct {
PermissionMode string
// WorkDir is the process cwd, i.e. the worktree.
WorkDir string
// Env is appended to the subprocess's inherited environment.
Env []string
// ExtraArgs are appended verbatim.
ExtraArgs []string
// LogPath receives the raw JSONL transcript. Required.
Expand Down Expand Up @@ -224,6 +226,9 @@ func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) {

cmd := exec.CommandContext(ctx, opts.Binary, args...)
cmd.Dir = opts.WorkDir
if len(opts.Env) > 0 {
cmd.Env = append(os.Environ(), opts.Env...)
}
cmd.Stdin = strings.NewReader(opts.Prompt)
// Claude Code spawns children (the bash tool, language servers); kill the
// whole group on cancellation or a timed-out run leaves the worker stuck.
Expand Down
19 changes: 19 additions & 0 deletions internal/claude/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,25 @@ printf '{"type":"result","subtype":"success","is_error":false,"result":"%s"}\n'
}
}

// Env must reach the child process, since this is how the harness's commit
// identity is delivered to Claude when it commits on its own.
func TestEnvReachesTheChildProcess(t *testing.T) {
bin := stubCLI(t, `cat > /dev/null
printf '{"type":"result","subtype":"success","is_error":false,"result":"%s"}\n' "$GIT_AUTHOR_NAME"
`)
res, err := (&Runner{}).Run(context.Background(), Options{
Binary: bin,
LogPath: filepath.Join(t.TempDir(), "run.jsonl"),
Env: []string{"GIT_AUTHOR_NAME=coding-agent-loop[bot]"},
})
if err != nil {
t.Fatal(err)
}
if res.Result != "coding-agent-loop[bot]" {
t.Fatalf("Env did not reach the child process, got result %q", res.Result)
}
}

func TestLogPathRequired(t *testing.T) {
if _, err := (&Runner{}).Run(context.Background(), Options{Binary: "true"}); err == nil {
t.Fatal("LogPath must be required so every run leaves a transcript")
Expand Down
25 changes: 25 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type Config struct {
Server ServerConfig `json:"server"`
Store StoreConfig `json:"store"`
Discord DiscordConfig `json:"discord"`
Git GitConfig `json:"git"`

// ModelsPath points at models.json, resolved by the caller (cmd/agent.go),
// not expanded here: "models.json" (the default) is looked up next to the
Expand Down Expand Up @@ -180,6 +181,14 @@ type DiscordConfig struct {
WebhookURL string `json:"webhook_url"`
}

// GitConfig identifies the author of commits the loop produces, so they are
// visibly distinct from the human repo owner's own commits. Empty means "use
// the built-in default" — see git.Manager's author()/email().
type GitConfig struct {
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
}

// Default returns a Config with every field populated to a sane value.
func Default() Config {
return Config{
Expand Down Expand Up @@ -231,6 +240,10 @@ func Default() Config {
Server: ServerConfig{Addr: "127.0.0.1:8787"},
Store: StoreConfig{Path: "~/.agent-loop/state.db"},
Discord: DiscordConfig{Enabled: false},
Git: GitConfig{
AuthorName: "coding-agent-loop[bot]",
AuthorEmail: "coding-agent-loop@users.noreply.github.com",
},
}
}

Expand Down Expand Up @@ -326,6 +339,12 @@ func (c *Config) Validate() error {
if c.Discord.Enabled && c.Discord.WebhookURL == "" {
return fmt.Errorf("discord.webhook_url must be set when discord.enabled is true")
}
if containsCommitHeaderBreakingChars(c.Git.AuthorName) {
return fmt.Errorf("git.author_name must not contain '<', '>', or a newline")
}
if containsCommitHeaderBreakingChars(c.Git.AuthorEmail) {
return fmt.Errorf("git.author_email must not contain '<', '>', or a newline")
}
if c.GitHub.PRComments.Enabled {
pc := c.GitHub.PRComments
if !strings.HasPrefix(pc.Mention, "@") {
Expand All @@ -344,6 +363,12 @@ func (c *Config) Validate() error {
return nil
}

// containsCommitHeaderBreakingChars reports whether s has a character that
// would corrupt the "Name <email>" line git writes into a commit header.
func containsCommitHeaderBreakingChars(s string) bool {
return strings.ContainsAny(s, "<>\n")
}

// validReactions are the only content values GitHub's reactions API accepts.
var validReactions = map[string]bool{
"+1": true, "-1": true, "laugh": true, "confused": true,
Expand Down
41 changes: 41 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,47 @@ func TestRetryBackoffMaxMustNotBeBelowTheBase(t *testing.T) {
}
}

// Agent-authored commits should carry their own identity by default, distinct
// from whatever the host's global gitconfig would otherwise resolve to.
func TestGitIdentityDefaults(t *testing.T) {
cfg := Default()
if cfg.Git.AuthorName != "coding-agent-loop[bot]" {
t.Fatalf("unexpected default git author name %q", cfg.Git.AuthorName)
}
if cfg.Git.AuthorEmail != "coding-agent-loop@users.noreply.github.com" {
t.Fatalf("unexpected default git author email %q", cfg.Git.AuthorEmail)
}
}

func TestGitIdentityOverlaysFromConfig(t *testing.T) {
cfg, err := Load(writeConfig(t, `{"github":{"owners":["acme"]},"git":{"author_name":"Acme Bot","author_email":"bot@acme.example"}}`), false)
if err != nil {
t.Fatal(err)
}
if cfg.Git.AuthorName != "Acme Bot" {
t.Fatalf("author_name = %q", cfg.Git.AuthorName)
}
if cfg.Git.AuthorEmail != "bot@acme.example" {
t.Fatalf("author_email = %q", cfg.Git.AuthorEmail)
}
}

// A name/email containing '<', '>', or a newline would corrupt the commit
// header line git builds ("Name <email>").
func TestGitIdentityWithAngleBracketIsRejected(t *testing.T) {
_, err := Load(writeConfig(t, `{"github":{"owners":["acme"]},"git":{"author_name":"evil>name"}}`), false)
if err == nil || !strings.Contains(err.Error(), "author_name") {
t.Fatalf("want a git.author_name validation error, got %v", err)
}
}

func TestGitIdentityEmailWithAngleBracketIsRejected(t *testing.T) {
_, err := Load(writeConfig(t, `{"github":{"owners":["acme"]},"git":{"author_email":"<bad@example.com"}}`), false)
if err == nil || !strings.Contains(err.Error(), "author_email") {
t.Fatalf("want a git.author_email validation error, got %v", err)
}
}

func TestPRCommentsDefaults(t *testing.T) {
cfg, err := Load(writeConfig(t, `{"github":{"owners":["acme"]}}`), false)
if err != nil {
Expand Down
36 changes: 34 additions & 2 deletions internal/git/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ func (m *Manager) EnsureRepo(ctx context.Context, repo, cloneURL string) (string
if _, err := m.run(ctx, path, "fetch", "--prune", "--quiet", "origin"); err != nil {
return "", fmt.Errorf("fetch %s: %w", repo, err)
}
if err := m.applyIdentity(ctx, path); err != nil {
return "", err
}
return path, nil
}

Expand Down Expand Up @@ -265,14 +268,43 @@ func (m *Manager) author() string {
if m.AuthorName != "" {
return m.AuthorName
}
return "coding-agent-loop"
return "coding-agent-loop[bot]"
}

func (m *Manager) email() string {
if m.AuthorEmail != "" {
return m.AuthorEmail
}
return "coding-agent-loop@localhost"
return "coding-agent-loop@users.noreply.github.com"
}

// IdentityEnv returns the GIT_AUTHOR_*/GIT_COMMITTER_* environment variables
// for the harness's configured identity, so a subprocess that commits on its
// own (e.g. Claude, via `git commit` with no -c flags) picks up the same
// identity as CommitAll, regardless of what its own git config resolves to.
func (m *Manager) IdentityEnv() []string {
author, email := m.author(), m.email()
return []string{
"GIT_AUTHOR_NAME=" + author,
"GIT_AUTHOR_EMAIL=" + email,
"GIT_COMMITTER_NAME=" + author,
"GIT_COMMITTER_EMAIL=" + email,
}
}

// applyIdentity sets user.name/user.email in repoPath's own git config, so
// every worktree that shares this repo's $GIT_COMMON_DIR/config — including
// ones a subprocess like Claude commits in directly, with no -c flags —
// resolves to the harness's identity rather than the host's global gitconfig.
// Idempotent; safe to call on every EnsureRepo pass.
func (m *Manager) applyIdentity(ctx context.Context, repoPath string) error {
if _, err := m.run(ctx, repoPath, "config", "user.name", m.author()); err != nil {
return fmt.Errorf("set commit identity: %w", err)
}
if _, err := m.run(ctx, repoPath, "config", "user.email", m.email()); err != nil {
return fmt.Errorf("set commit identity: %w", err)
}
return nil
}

// DiffStat summarises the branch against origin/base, for the PR body.
Expand Down
64 changes: 64 additions & 0 deletions internal/git/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ func TestWorktreeLifecycle(t *testing.T) {
if err != nil || !committed {
t.Fatalf("CommitAll: committed=%v err=%v", committed, err)
}
if got := git(t, wt, "log", "-1", "--format=%an <%ae>"); strings.TrimSpace(got) != "agent <agent@example.com>" {
t.Fatalf("commit author = %q, want the configured identity", got)
}
// Committed work still counts as work, via the commit-ahead check.
if work, err := m.HasWork(ctx, wt, "main"); err != nil || !work {
t.Fatalf("committed work should still register: work=%v err=%v", work, err)
Expand Down Expand Up @@ -191,6 +194,67 @@ func TestWorktreeLifecycle(t *testing.T) {
}
}

// This is the case the issue actually cares about: Claude, or any other
// subprocess, commits directly with a bare `git commit` (no -c flags, unlike
// CommitAll). EnsureRepo must have already written the harness identity into
// the shared clone's own config, so that commit picks it up instead of
// falling through to the host's global gitconfig — which is what caused
// agent commits to be attributed to the human repo owner.
func TestEnsureRepoAppliesIdentityOverridingGlobalConfig(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
ctx := context.Background()
origin := originRepo(t)
root := t.TempDir()

globalConfig := filepath.Join(root, "global-gitconfig")
if err := os.WriteFile(globalConfig, []byte("[user]\n\tname = Human Author\n\temail = human@example.com\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("GIT_CONFIG_GLOBAL", globalConfig)

m := &Manager{
ReposRoot: filepath.Join(root, "repos"),
WorkRoot: filepath.Join(root, "work"),
AuthorName: "agent",
AuthorEmail: "agent@example.com",
}

repoPath, err := m.EnsureRepo(ctx, "acme/widgets", origin)
if err != nil {
if strings.Contains(err.Error(), "unknown option") || strings.Contains(err.Error(), "unknown environment variable") {
t.Skip("git too old to support GIT_CONFIG_GLOBAL")
}
t.Fatalf("EnsureRepo: %v", err)
}

wt := m.WorktreePath("acme/widgets", 7)
if err := m.AddWorktree(ctx, repoPath, wt, "agent/issue-7", "main"); err != nil {
t.Fatalf("AddWorktree: %v", err)
}
if err := os.WriteFile(filepath.Join(wt, "feature.txt"), []byte("implemented\n"), 0o644); err != nil {
t.Fatal(err)
}

// Simulate Claude: a bare commit, no -c user.name/user.email.
cmd := exec.Command("git", "add", "-A")
cmd.Dir = wt
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git add: %v\n%s", err, out)
}
cmd = exec.Command("git", "commit", "-m", "add feature")
cmd.Dir = wt
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git commit: %v\n%s", err, out)
}

got := git(t, wt, "log", "-1", "--format=%an <%ae>")
if strings.TrimSpace(got) != "agent <agent@example.com>" {
t.Fatalf("bare commit author = %q, want the harness identity, not the host's global gitconfig", got)
}
}

// AssertRemote is the last gate before anything leaves the machine.
func TestAssertRemote(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/orchestrator/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ func (o *Orchestrator) execute(ctx context.Context, log *slog.Logger, cand candi
Fallbacks: fallbacks,
PermissionMode: permissionMode,
WorkDir: worktree,
Env: o.opts.Git.IdentityEnv(),
ExtraArgs: cfg.Claude.ExtraArgs,
LogPath: logPath,
Timeout: cfg.Run.Timeout.D(),
Expand Down
2 changes: 2 additions & 0 deletions internal/orchestrator/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ The harness, not you, owns version control and GitHub. Specifically:
- Do NOT run git push, git rebase, git reset --hard, or any force operation.
- Do NOT create branches, tags, pull requests, or issue comments.
- Do NOT amend or rewrite any commit that already exists.
- Do NOT change git's user.name/user.email or pass --author/--reset-author; the harness has
already set the commit identity for this worktree.
- You MAY commit your work locally. If you do not, the harness commits it for you.

Scope rules:
Expand Down
11 changes: 11 additions & 0 deletions internal/orchestrator/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ func TestPlanTaskPromptIncludesThePreviousPlanOnReplan(t *testing.T) {
}
}

// The harness sets the commit identity for the worktree before Claude runs;
// this stops a helpful model from "fixing" an unfamiliar author.
func TestSystemPromptForbidsChangingGitIdentity(t *testing.T) {
p := systemPrompt("acme/widgets", "agent/issue-9", "/work/widgets/issue-9")
for _, want := range []string{"user.name", "user.email", "--author", "already set the commit identity"} {
if !strings.Contains(p, want) {
t.Errorf("system prompt missing %q:\n%s", want, p)
}
}
}

func TestPlanSystemPromptForbidsEditing(t *testing.T) {
p := planSystemPrompt("acme/widgets", "/work/widgets/issue-9")
for _, want := range []string{"Do NOT edit", "Do NOT", "plan"} {
Expand Down
Loading