Details and log output for this agent run.
{"command": "cd /project && git log --oneline -10 && git status && git branch -a"}
c3d3720 (HEAD -> issue-48-greg) Pass the agent's git credential to the sandbox, clone inside it f083eae (origin/main, origin/HEAD, main) Merge pull request 'Add pr:review event so agents react to PR reviews' (#37) from issue-36-greg into main 2b59027 Merge branch 'main' into issue-36-greg c6a6304 Merge pull request 'docs: add comprehensive README for Zoo' (#44) from issue-43-anna into main 438a663 docs: clarify agents are in example config, not pre-configured 884245c Merge branch 'main' into issue-36-greg 6347330 Merge pull request 'llm: remove DisallowUnknownFields usage' (#46) from issue-45-greg into main dd4a669 llm: stop using DisallowUnknownFields when decoding stream chunks b24f77c docs: omit installation instructions from README per review feedback 0717fa6 docs: add comprehensive README with project overview, setup guide, and agent documentation On branch issue-48-greg nothing to commit, working tree clean * issue-48-greg main remotes/origin/HEAD -> origin/main remotes/origin/main
{"path": ".", "recursive": false}
drwxr-xr-x 5 1000 1000 4096 Aug 25 19:25 . drwxr-xr-x 1 root root 4096 Aug 25 19:25 .. drwxr-xr-x 8 root root 4096 Aug 25 19:25 .git -rw-r--r-- 1 root root 22 Aug 25 19:25 .gitignore -rw-r--r-- 1 root root 6387 Aug 25 19:25 README.md drwxr-xr-x 3 root root 4096 Aug 25 19:25 cmd -rw-r--r-- 1 root root 2628 Aug 25 19:25 go.mod -rw-r--r-- 1 root root 16578 Aug 25 19:25 go.sum drwxr-xr-x 11 root root 4096 Aug 25 19:25 internal -rw-r--r-- 1 root root 2339 Aug 25 19:25 zoo.hcl.example
{"command": "cd /project && find . -type f -name \"*.go\" | head -50 && echo \"---\" && git show --stat HEAD"}
./cmd/zoo/main.go
./internal/config/config.go
./internal/config/config_test.go
./internal/web/templates.go
./internal/web/web_test.go
./internal/web/web.go
./internal/livelog/hub.go
./internal/livelog/hub_test.go
./internal/tools/forgejo.go
./internal/tools/grep_search.go
./internal/tools/context.go
./internal/tools/remove_file.go
./internal/tools/registry.go
./internal/tools/write_file.go
./internal/tools/move_file.go
./internal/tools/read_file.go
./internal/tools/git.go
./internal/tools/edit-file.go
./internal/tools/list_files.go
./internal/tools/bash.go
./internal/tools/shell.go
./internal/tools/tools_test.go
./internal/llm/streamer_test.go
./internal/llm/client.go
./internal/llm/streamer.go
./internal/llm/types.go
./internal/store/store.go
./internal/store/store_test.go
./internal/forgejo/poll.go
./internal/forgejo/watcher.go
./internal/forgejo/webhook_test.go
./internal/forgejo/webhook.go
./internal/forgejo/client.go
./internal/forgejo/poll_test.go
./internal/forgejo/client_test.go
./internal/forgejo/event.go
./internal/agentrun/forgejo.go
./internal/agentrun/roster_test.go
./internal/agentrun/context.go
./internal/agentrun/loop.go
./internal/agentrun/sandboxgit.go
./internal/agentrun/docker.go
./internal/agentrun/seed_test.go
./internal/agentrun/docker_integration_test.go
./internal/agentrun/run.go
./internal/agentrun/sandboxgit_test.go
./internal/agentrun/roster.go
./internal/scheduler/scheduler.go
./internal/scheduler/scheduler_test.go
---
commit c3d37202716048d01f9c50c0c664fb980ea62062 (HEAD -> issue-48-greg)
Author: Greg Coolio <greg@example.com>
Date: Tue Aug 25 19:17:38 2026 +0000
Pass the agent's git credential to the sandbox, clone inside it
Git now simply works inside the agent's container:
- The sandbox's git authenticates via an http.<host>.extraHeader entry
in the container's system gitconfig, scoped to the forge host. It
carries the agent's own Forgejo token when configured (so git
activity is attributed to the agent's account), falling back to the
shared zoo identity. Clone, fetch, pull, push, and ls-remote all
authenticate transparently β no per-invocation plumbing.
- The initial clone (and the PR-head fetch for pr:review runs) now
happens inside the container, so the working tree is owned by the
container's user and zoo never runs git on the host.
- The git tool now executes inside the container (shell-quoted args),
and push.autoSetupRemote makes a bare 'git push' β and the 'git
pull' that follows it β work on the fresh working branch.
- The credential lives only in the container's ephemeral filesystem;
the origin remote keeps the plain clone URL, so the token never
lands in the bind-mounted working tree or the host's work dir.
Verified against a local smart-HTTP git server: clone/pull/push/
ls-remote all authenticate via the gitconfig header.
README.md | 6 +
internal/agentrun/clone.go | 81 --------------
internal/agentrun/context.go | 25 ++---
internal/agentrun/docker_integration_test.go | 122 +++++++++++++++++++++
internal/agentrun/run.go | 70 ++++++------
internal/agentrun/sandboxgit.go | 158 +++++++++++++++++++++++++++
internal/agentrun/sandboxgit_test.go | 78 +++++++++++++
internal/agentrun/system.md | 5 +-
internal/tools/context.go | 14 +--
internal/tools/git.go | 8 +-
10 files changed, 422 insertions(+), 145 deletions(-)
{"path": "internal/tools/git.go", "start_line": 1, "end_line": 200}
{"path":"internal/tools/git.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"slices\"\r\n\t\"strings\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\n// gitAllowed intentionally excludes remote-mutating subcommands other\r\n// than push: agents are expected to commit and push their own branch,\r\n// not touch remotes/config/etc.\r\nvar gitAllowed = []string{\r\n\t\"add\",\r\n\t\"branch\",\r\n\t\"checkout\",\r\n\t\"commit\",\r\n\t\"diff\",\r\n\t\"fetch\",\r\n\t\"log\",\r\n\t\"ls-remote\",\r\n\t\"pull\",\r\n\t\"push\",\r\n\t\"show\",\r\n\t\"status\",\r\n\t\"rm\",\r\n}\r\n\r\ntype gitParams struct {\r\n\tSubcommand string `json:\"subcommand\"`\r\n\tArgs []string `json:\"args\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"git\",\r\n\t\t\"Run a git subcommand with optional arguments against the project's working tree\")\r\n\r\n\ttool.AddEnumProperty(\"subcommand\", \"The git subcommand to run\", gitAllowed, true)\r\n\ttool.AddStringArrayProperty(\"args\", \"Additional arguments to pass to the git subcommand, e.g. ['HEAD~1', 'HEAD']\", false)\r\n\r\n\tRegister(tool, git)\r\n}\r\n\r\n// git runs subcommand via Context.Git, which executes it inside the\r\n// run's container, where the run's Forgejo credential is configured\r\n// for git (see that method's doc comment) β remote operations like\r\n// push simply work.\r\nfunc git(ctx Context, params gitParams) (string, error) {\r\n\tif !slices.Contains(gitAllowed, params.Subcommand) {\r\n\t\treturn \"\", fmt.Errorf(\"git subcommand '%s' is not allowed. Only %s are permitted\", params.Subcommand, strings.Join(gitAllowed, \", \"))\r\n\t}\r\n\r\n\toutput, err := ctx.Git(context.Background(), params.Subcommand, params.Args...)\r\n\tif err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\tif output == \"\" {\r\n\t\tswitch params.Subcommand {\r\n\t\tcase \"diff\":\r\n\t\t\treturn \"No changes detected.\", nil\r\n\r\n\t\tcase \"status\":\r\n\t\t\treturn \"No status output.\", nil\r\n\t\t}\r\n\t}\r\n\r\n\treturn strings.TrimSpace(output), nil\r\n}\r","start_line":1,"end_line":72,"total_lines":72,"truncated":false}
{"path": "internal/tools/context.go", "start_line": 1, "end_line": 200}
{"path":"internal/tools/context.go","content":"// Package tools is the registry of functions an agent's tool-calling\r\n// loop can invoke, ported from ../a/tools but retargeted at a Docker\r\n// container (via Exec) and the Forgejo issue/PR that triggered the run\r\n// (via Forgejo()) instead of a local sandboxed filesystem.\r\npackage tools\r\n\r\nimport \"context\"\r\n\r\n// Context is bound to a single agent run (job), unlike ../a's single\r\n// global Context, since multiple agents run concurrently in zoo.\r\ntype Context interface {\r\n\t// Exec runs command inside the run's container via `sh -c` and\r\n\t// returns combined stdout+stderr.\r\n\tExec(ctx context.Context, command string) (string, error)\r\n\r\n\t// Git runs a git subcommand against the run's working tree inside\r\n\t// the run's container. Remote operations (fetch, pull, push, ...)\r\n\t// authenticate via the Forgejo credential the run's setup wrote to\r\n\t// the container's system gitconfig, so they work without any\r\n\t// per-invocation plumbing.\r\n\tGit(ctx context.Context, subcommand string, args ...string) (string, error)\r\n\r\n\t// Forgejo returns the actions bound to the issue/PR that triggered\r\n\t// this run, so tools don't need to be told which repo/issue to act\r\n\t// on.\r\n\tForgejo() ForgejoActions\r\n}\r\n\r\n// ForgejoActions are the actions available to an agent against the\r\n// triggering issue/PR. Implemented by internal/agentrun, backed by\r\n// internal/forgejo.Client plus the run's owner/repo/issue.\r\ntype ForgejoActions interface {\r\n\tComment(body string) error\r\n\tOpenPullRequest(head, base, title, body string) error\r\n\tRequestReview(reviewers []string) error\r\n\tAddLabel(name string) error\r\n\tRemoveLabel(name string) error\r\n\tCloseIssue() error\r\n\tReopenIssue() error\r\n\r\n\t// AssignIssue hands the triggering issue/PR off to another agent by\r\n\t// their name (which doubles as their Forgejo username); the\r\n\t// resulting issue:assigned event triggers that agent's run.\r\n\tAssignIssue(agentName string) error\r\n\r\n\t// SubmitReview submits a review on the triggering pull request with\r\n\t// the given verdict (forgejo.ReviewStateApproved,\r\n\t// ReviewStateChangesRequest, or ReviewStateComment) and body.\r\n\tSubmitReview(state, body string) error\r\n}\r","start_line":1,"end_line":50,"total_lines":50,"truncated":false}
{"path": "internal/agentrun/context.go", "start_line": 1, "end_line": 250}
{"path":"internal/agentrun/context.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/tools\"\r\n)\r\n\r\n// runContext implements tools.Context for a single agent run, executing\r\n// both shell commands and git commands via docker exec against the\r\n// run's container, and binding Forgejo actions to the triggering\r\n// issue/PR.\r\ntype runContext struct {\r\n\tdocker *dockerRuntime\r\n\tcontainerID string\r\n\tforgejo tools.ForgejoActions\r\n}\r\n\r\nfunc (c *runContext) Exec(ctx context.Context, command string) (string, error) {\r\n\toutput, exitCode, err := c.docker.exec(ctx, c.containerID, command)\r\n\r\n\tif err != nil {\r\n\t\treturn output, err\r\n\t}\r\n\r\n\tif exitCode != 0 {\r\n\t\treturn output, fmt.Errorf(\"command exited with status %d\", exitCode)\r\n\t}\r\n\r\n\treturn output, nil\r\n}\r\n\r\n// Git implements tools.Context.Git by running git inside the run's\r\n// container, in its /project working directory. Remote operations\r\n// (fetch, pull, push, ...) authenticate via the http.\u003curl\u003e.extraHeader\r\n// credential configureSandboxGit wrote to the container's system\r\n// gitconfig, so no per-invocation credential plumbing is needed.\r\nfunc (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {\r\n\tcmdArgs := append([]string{subcommand}, args...)\r\n\r\n\treturn c.Exec(ctx, shellGitCmd(cmdArgs...))\r\n}\r\n\r\nfunc (c *runContext) Forgejo() tools.ForgejoActions {\r\n\treturn c.forgejo\r\n}\r","start_line":1,"end_line":47,"total_lines":47,"truncated":false}
{"path": "internal/agentrun/sandboxgit.go", "start_line": 1, "end_line": 200}
{"path":"internal/agentrun/sandboxgit.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"encoding/base64\"\r\n\t\"fmt\"\r\n\t\"net/url\"\r\n\t\"strings\"\r\n)\r\n\r\n// This file makes git \"just work\" inside the agent's container: the\r\n// initial clone happens in the sandbox, and the sandbox's git\r\n// credential is written to the container's system gitconfig so every\r\n// subsequent remote operation (pull, fetch, push, ...) authenticates\r\n// without any per-invocation plumbing. Zoo itself never runs git\r\n// outside the sandbox.\r\n\r\n// shellQuote wraps s in single quotes for safe interpolation into a\r\n// `sh -c` command string, escaping any embedded single quotes.\r\nfunc shellQuote(s string) string {\r\n\treturn \"'\" + strings.ReplaceAll(s, \"'\", `'\\''`) + \"'\"\r\n}\r\n\r\n// shellGitCmd renders `git \u003cargs...\u003e` as one sh -c command line with\r\n// every argument quoted, for docker exec.\r\nfunc shellGitCmd(args ...string) string {\r\n\tparts := make([]string, 0, len(args)+1)\r\n\tparts = append(parts, \"git\")\r\n\r\n\tfor _, a := range args {\r\n\t\tparts = append(parts, shellQuote(a))\r\n\t}\r\n\r\n\treturn strings.Join(parts, \" \")\r\n}\r\n\r\n// runSandboxGit runs `git \u003cargs...\u003e` inside containerID (in its\r\n// working directory, /project) and returns its combined output. A\r\n// non-zero exit code is an error carrying the output.\r\nfunc runSandboxGit(ctx context.Context, rt *dockerRuntime, containerID string, args ...string) (string, error) {\r\n\tout, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))\r\n\tif err != nil {\r\n\t\treturn out, err\r\n\t}\r\n\r\n\tif exitCode != 0 {\r\n\t\treturn out, fmt.Errorf(\"git %s: exit %d: %s\", args[0], exitCode, out)\r\n\t}\r\n\r\n\treturn out, nil\r\n}\r\n\r\n// gitAuthHeader returns the value of an Authorization header that\r\n// authenticates git's smart-HTTP requests as user with token.\r\nfunc gitAuthHeader(user, token string) string {\r\n\tauth := base64.StdEncoding.EncodeToString([]byte(user + \":\" + token))\r\n\r\n\treturn \"Authorization: Basic \" + auth\r\n}\r\n\r\n// forgeHost returns the scheme+host prefix of cloneURL, e.g.\r\n// \"https://code.stdio.dk\" for \"https://code.stdio.dk/abrander/zoo.git\".\r\n// On a parse failure it falls back to the full URL, which is a valid\r\n// (narrower) prefix match too.\r\nfunc forgeHost(cloneURL string) string {\r\n\tu, err := url.Parse(cloneURL)\r\n\tif err != nil || u.Host == \"\" {\r\n\t\treturn cloneURL\r\n\t}\r\n\r\n\treturn u.Scheme + \"://\" + u.Host\r\n}\r\n\r\n// configureSandboxGit writes the container's system gitconfig so git\r\n// works inside the sandbox without further setup:\r\n//\r\n// - safe.directory '*', so the bind-mounted /project is accepted\r\n// regardless of which UID the container runs git as;\r\n// - user.name / user.email, so commits are attributed to the agent;\r\n// - http.\u003chost\u003e.extraHeader carrying the run's Forgejo credential,\r\n// scoped to the forge host the repository lives on, so\r\n// clone/fetch/pull/push all authenticate transparently β including\r\n// for submodules and other repos on the same forge. The token is\r\n// only valid on that forge anyway, so the host scope grants no\r\n// extra access; git never sends it anywhere else;\r\n// - push.autoSetupRemote, so a bare `git push` on the fresh working\r\n// branch pushes it to origin and sets the upstream β after which\r\n// a bare `git pull` works too.\r\n//\r\n// The credential lives in the container's own filesystem (ephemeral,\r\n// torn down with the container), never in the bind-mounted working\r\n// tree: the origin remote keeps the plain cloneURL, so the token can't\r\n// leak into the repo's .git/config, into a work dir zoo keeps on\r\n// failure, or anywhere the host can read it back.\r\nfunc configureSandboxGit(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, user, token, name, email string) error {\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"config\", \"--system\", \"--add\", \"safe.directory\", \"*\"); err != nil {\r\n\t\treturn fmt.Errorf(\"configure safe.directory: %w\", err)\r\n\t}\r\n\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"config\", \"--system\", \"user.name\", name); err != nil {\r\n\t\treturn fmt.Errorf(\"configure user.name: %w\", err)\r\n\t}\r\n\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"config\", \"--system\", \"user.email\", email); err != nil {\r\n\t\treturn fmt.Errorf(\"configure user.email: %w\", err)\r\n\t}\r\n\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"config\", \"--system\", \"http.\"+forgeHost(cloneURL)+\".extraHeader\", gitAuthHeader(user, token)); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git credential: %w\", err)\r\n\t}\r\n\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"config\", \"--system\", \"push.autoSetupRemote\", \"true\"); err != nil {\r\n\t\treturn fmt.Errorf(\"configure push.autoSetupRemote: %w\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// cloneAndBranch clones cloneURL into /project inside the container\r\n// and checks out a fresh branch off defaultBranch. The clone\r\n// authenticates via the http.\u003chost\u003e.extraHeader configured by\r\n// configureSandboxGit, and the origin remote is left with the plain\r\n// cloneURL: no credential ends up in the working tree.\r\nfunc cloneAndBranch(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, defaultBranch, branch string) error {\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, \"/project\"); err != nil {\r\n\t\treturn fmt.Errorf(\"clone: %w\", err)\r\n\t}\r\n\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"checkout\", \"-b\", branch); err != nil {\r\n\t\treturn fmt.Errorf(\"checkout branch %q: %w\", branch, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// clonePRHead clones cloneURL into /project inside the container and\r\n// checks out the pull request's head as a local branch named headRef,\r\n// so commits the agent makes push straight to the PR. The head is\r\n// fetched via the PR's refs/pull/\u003cn\u003e/head ref rather than by branch\r\n// name, which also works for PRs opened from a fork, where the head\r\n// branch doesn't exist in the base repo.\r\nfunc clonePRHead(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, defaultBranch, headRef string, index int64) error {\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, \"/project\"); err != nil {\r\n\t\treturn fmt.Errorf(\"clone: %w\", err)\r\n\t}\r\n\r\n\tfetchRef := fmt.Sprintf(\"refs/pull/%d/head\", index)\r\n\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"fetch\", \"--no-tags\", \"origin\", fetchRef); err != nil {\r\n\t\treturn fmt.Errorf(\"fetch PR %d head: %w\", index, err)\r\n\t}\r\n\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"checkout\", \"-B\", headRef, \"FETCH_HEAD\"); err != nil {\r\n\t\treturn fmt.Errorf(\"checkout PR head %q: %w\", headRef, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":1,"end_line":158,"total_lines":158,"truncated":false}
{"path": "internal/agentrun/run.go", "start_line": 1, "end_line": 250}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\r\n// runs in: a Docker container with the agent's git working tree cloned\r\n// inside it (authenticated with the agent's own Forgejo token), the\r\n// triggering event written to /event, and the tool-calling loop\r\n// (internal/llm + internal/tools) driven against it.\r\npackage agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t_ \"embed\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"sync\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n//go:embed system.md\r\nvar defaultSystemPrompt string\r\n\r\n// DefaultTimeout bounds a single agent run's wall-clock time if the\r\n// caller doesn't override it.\r\nconst DefaultTimeout = 120 * time.Minute\r\n\r\ntype Runner struct {\r\n\tdocker *dockerRuntime\r\n\tforgejo *forgejo.Client\r\n\tstore *store.Store\r\n\thub *livelog.Hub\r\n\tcfg *config.Config\r\n\tlogger *slog.Logger\r\n\ttimeout time.Duration\r\n\tkeepOnFailure bool\r\n\r\n\tagentClientsMu sync.Mutex\r\n\tagentClients map[string]*forgejo.Client\r\n}\r\n\r\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\r\n\tdocker, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tif timeout \u003c= 0 {\r\n\t\ttimeout = DefaultTimeout\r\n\t}\r\n\r\n\treturn \u0026Runner{\r\n\t\tdocker: docker,\r\n\t\tforgejo: fg,\r\n\t\tstore: st,\r\n\t\thub: hub,\r\n\t\tcfg: cfg,\r\n\t\tlogger: logger,\r\n\t\ttimeout: timeout,\r\n\t\tkeepOnFailure: keepOnFailure,\r\n\t\tagentClients: make(map[string]*forgejo.Client),\r\n\t}, nil\r\n}\r\n\r\n// forgejoAs returns a Forgejo client that authenticates as the given\r\n// agent (using the agent's own token from config). This lets each agent\r\n// act as themselves on Forgejo without needing a global token with sudo\r\n// privileges. Clients are built once per agent and cached, since\r\n// constructing one costs an extra API round trip.\r\n//\r\n// If the agent has no token configured, falls back to the shared zoo\r\n// identity so existing deployments without per-agent tokens still work.\r\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\r\n\tr.agentClientsMu.Lock()\r\n\tdefer r.agentClientsMu.Unlock()\r\n\r\n\tif c, ok := r.agentClients[agentName]; ok {\r\n\t\treturn c\r\n\t}\r\n\r\n\tvar c *forgejo.Client\r\n\tif token != \"\" {\r\n\t\tc = r.forgejo.As(token)\r\n\t} else {\r\n\t\t// Fallback: use shared identity. Optionally log a warning\r\n\t\t// if we ever want to enforce per-agent tokens.\r\n\t\tc = r.forgejo\r\n\t}\r\n\r\n\tr.agentClients[agentName] = c\r\n\r\n\treturn c\r\n}\r\n\r\n// Run implements scheduler.Runner.\r\nfunc (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {\r\n\tctx, cancel := context.WithTimeout(ctx, r.timeout)\r\n\tdefer cancel()\r\n\r\n\tlogger := r.logger.With(\"job\", jobID, \"agent\", agent.Name)\r\n\r\n\trepoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"look up repository: %w\", err)\r\n\t}\r\n\r\n\tworkDir, err := os.MkdirTemp(\"\", \"zoo-run-*\")\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create work dir: %w\", err)\r\n\t}\r\n\r\n\tsucceeded := false\r\n\r\n\tdefer func() {\r\n\t\tif succeeded || !r.keepOnFailure {\r\n\t\t\tos.RemoveAll(workDir)\r\n\t\t} else {\r\n\t\t\tlogger.Warn(\"keeping work dir after failure\", \"dir\", workDir)\r\n\t\t}\r\n\t}()\r\n\r\n\t// The container bind-mounts projectDir as /project and does the\r\n\t// initial clone into it, so the (empty) directory must exist on the\r\n\t// host before the container is created β otherwise Docker would\r\n\t// create it itself, root-owned.\r\n\tprojectDir := filepath.Join(workDir, \"project\")\r\n\r\n\tif err := os.MkdirAll(projectDir, 0o755); err != nil {\r\n\t\treturn fmt.Errorf(\"create project dir: %w\", err)\r\n\t}\r\n\r\n\t// A pr:review run works on the PR's own head branch, so the agent's\r\n\t// commits push straight to the PR. Every other event kind branches\r\n\t// off the default branch as usual.\r\n\tvar review *forgejo.ReviewDetail\r\n\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\r\n\r\n\tif ev.Kind == forgejo.EventPRReview {\r\n\t\t// Always fetch the current head ref, not just when the event\r\n\t\t// lacks one (the polling path doesn't carry it): the webhook's\r\n\t\t// copy could be stale if the PR's head branch was renamed since\r\n\t\t// the review, and the push target depends on it.\r\n\t\theadRef := ev.HeadRef\r\n\r\n\t\tif prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index); err != nil {\r\n\t\t\tlogger.Warn(\"fetch pull request head failed; falling back to the event's head ref\", \"error\", err)\r\n\t\t} else if prInfo.HeadRef != \"\" {\r\n\t\t\theadRef = prInfo.HeadRef\r\n\t\t}\r\n\r\n\t\tif headRef == \"\" {\r\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\r\n\t\t}\r\n\r\n\t\tbranch = headRef\r\n\r\n\t\t// Fetch the full review (verdict, body, inline comments) so the\r\n\t\t// agent sees all the feedback, not just the triggering event. A\r\n\t\t// failure degrades to no review detail rather than failing the\r\n\t\t// run: the agent can still do its job, just without the inline\r\n\t\t// comments.\r\n\t\treview, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID)\r\n\t\tif err != nil {\r\n\t\t\tlogger.Warn(\"fetch review detail failed; agent will not see inline review comments\", \"error\", err)\r\n\t\t\treview = nil\r\n\t\t}\r\n\t}\r\n\r\n\troster := buildRoster(r.forgejo, r.cfg.Agents, logger)\r\n\tgitName, gitEmail := gitIdentity(agent.Name, roster)\r\n\r\n\t// The credential the sandbox's git uses for remote operations: the\r\n\t// agent's own Forgejo token when configured, so its git activity is\r\n\t// attributed to its own account, falling back to the shared zoo\r\n\t// identity for deployments without per-agent tokens (mirroring\r\n\t// forgejoAs).\r\n\tgitUser, gitToken := \"zoo\", r.forgejo.Token()\r\n\r\n\tif agent.Token != \"\" {\r\n\t\tgitUser, gitToken = agent.Name, agent.Token\r\n\t}\r\n\r\n\teventPath := filepath.Join(workDir, \"event.json\")\r\n\tif err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {\r\n\t\treturn fmt.Errorf(\"write event file: %w\", err)\r\n\t}\r\n\r\n\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\r\n\t\tprojectDir + \":/project\",\r\n\t\teventPath + \":/event:ro\",\r\n\t}, fmt.Sprintf(\"zoo-issue-%d-%s\", ev.Index, agent.Name))\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"start container: %w\", err)\r\n\t}\r\n\r\n\tdefer func() {\r\n\t\tcleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\t\tdefer cleanupCancel()\r\n\t\tif err := r.docker.remove(cleanupCtx, containerID); err != nil {\r\n\t\t\tlogger.Warn(\"failed to remove container\", \"container\", containerID, \"error\", err)\r\n\t\t}\r\n\t}()\r\n\r\n\t// Git must simply work inside the sandbox: safe.directory, commit\r\n\t// identity, and the remote credential all go into the container's\r\n\t// system gitconfig (see configureSandboxGit).\r\n\tif err := configureSandboxGit(ctx, r.docker, containerID, repoInfo.CloneURL, gitUser, gitToken, gitName, gitEmail); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git in container: %w\", err)\r\n\t}\r\n\r\n\t// The initial clone happens inside the sandbox, so the working tree\r\n\t// is owned by the container's user and git never runs on the host.\r\n\tif ev.Kind == forgejo.EventPRReview {\r\n\t\tif err := clonePRHead(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch, ev.Index); err != nil {\r\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\r\n\t\t}\r\n\t} else {\r\n\t\tif err := cloneAndBranch(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch); err != nil {\r\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\r\n\t\t}\r\n\t}\r\n\r\n\tlogAppend := func(stream, line string) {\r\n\t\tif err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {\r\n\t\t\tlogger.Warn(\"failed to append log\", \"error\", err)\r\n\t\t}\r\n\t}\r\n\r\n\trunCtx := \u0026runContext{\r\n\t\tdocker: r.docker,\r\n\t\tcontainerID: containerID,\r\n\t\tforgejo: \u0026runForgejoActions{\r\n\t\t\tclient: r.forgejoAs(agent.Name, agent.Token),\r\n\t\t\towner: ev.Owner,\r\n\t\t\trepo: ev.Repo,\r\n\t\t\tindex: ev.Index,\r\n\t\t\tlogger: logger,\r\n\t\t},\r\n\t}\r\n\r\n\tllmClient := llm.NewClient(llmCfg)\r\n\r\n\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\r\n\r","start_line":1,"end_line":250,"total_lines":437,"truncated":true}
{"path": "internal/agentrun/system.md", "start_line": 1, "end_line": 200}
{"path":"internal/agentrun/system.md","content":"You are an autonomous coding agent working inside `zoo`, a system that\r\ntriggers you from Forgejo (a Gitea-family forge) issue/PR events.\r\n\r\n**Your environment**\r\n\r\n- Your working tree is at `/project`, checked out on the working branch\r\n named in your briefing below. For most events that's a fresh branch\r\n off the repository's default branch; for a PR review event it's the\r\n pull request's own head branch, so commits you push update the PR\r\n directly. Only `/project` persists; nothing outside it survives\r\n between tool calls.\r\n- The event that triggered you (issue or pull request JSON) is available\r\n at `/event` inside the container, and is also included below.\r\n- You have a real git remote configured with push access. When you're\r\n done, `git add`/`git commit`/`git push` your branch β that's how your\r\n work gets saved. Nothing is persisted automatically.\r\n\r\n**Tools**\r\n\r\n- `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,\r\n `move_file`, `remove_file` operate on the project container.\r\n- `comment`, `open_pull_request`, `request_review`, `submit_review`,\r\n `add_label`, `remove_label`, `close_issue`, `reopen_issue`,\r\n `assign_issue` act directly on the Forgejo issue/PR that triggered\r\n this run β use `comment` to report back to the person who filed it,\r\n `open_pull_request` once you've pushed a branch with your changes,\r\n and `submit_review` to leave a review verdict.\r\n\r\nBelow, in \"Your identity\" and \"Other agents\", you'll find your own role\r\n(from your Forgejo profile) and a roster of the other agents zoo runs,\r\neach with their own role. If a task in front of you clearly belongs to\r\nsomeone else's role, use `assign_issue` to hand it off rather than\r\ndoing it yourself.\r\n\r\n**Git**\r\n\r\nYou may use normal git commands to manage your working tree. Remote\r\n(pull, fetch, push, etc.) git operations are authenticated for you and\r\nsimply work β through the `git` tool or plain `git` in `bash`.\r\n\r\n**Guidelines**\r\n\r\n- Make surgical changes: modify only what's necessary for the task in\r\n the triggering event.\r\n- Before finishing, verify your changes (re-read the file, run tests or\r\n a build if the project has them).\r\n- Always leave a `comment` summarizing what you did (or why you couldn't\r\n finish), and use `open_pull_request` when you have a change ready for\r\n review. Don't leave the issue without a response.\r","start_line":1,"end_line":49,"total_lines":49,"truncated":false}
{"end_line": 437, "path": "internal/agentrun/run.go", "start_line": 250}
{"path":"internal/agentrun/run.go","content":"\r\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\r\n\r\n\t// Fetch the full comment thread so the agent sees everything that's\r\n\t// been said on the issue/PR, not just the triggering event (which\r\n\t// only carries the latest comment, if any). A failure degrades to\r\n\t// no comments rather than failing the run: the agent can still do\r\n\t// its job, just without prior context.\r\n\tcomments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)\r\n\tif err != nil {\r\n\t\tlogger.Warn(\"fetch issue comments failed; agent will not see prior comments\", \"error\", err)\r\n\t\tcomments = nil\r\n\t}\r\n\r\n\tmessages := []llm.Message{\r\n\t\t{Role: \"system\", Content: systemPrompt},\r\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},\r\n\t}\r\n\r\n\thooks := r.streamHooks(jobID, logAppend)\r\n\r\n\t_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent loop: %w\", err)\r\n\t}\r\n\r\n\tsucceeded = true\r\n\r\n\treturn nil\r\n}\r\n\r\n// streamHooks builds the Hooks a single Run passes to runLoop: every\r\n// delta is published live to the hub for connected dashboard viewers,\r\n// and once a reasoning/content block or tool call is complete, it's\r\n// persisted to the store as one row and the hub's replay buffer for\r\n// jobID is checkpointed β so a viewer connecting from this point on\r\n// sees it via the persisted history instead of a live replay, and is\r\n// never shown it twice.\r\nfunc (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {\r\n\tvar reasoningBuf, contentBuf strings.Builder\r\n\r\n\treasoningOpen, contentOpen := false, false\r\n\r\n\treturn Hooks{\r\n\t\tOnReasoningDelta: func(delta string) {\r\n\t\t\tif !reasoningOpen {\r\n\t\t\t\treasoningOpen = true\r\n\t\t\t\treasoningBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\r\n\t\t\t}\r\n\r\n\t\t\treasoningBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\r\n\t\t},\r\n\t\tOnContentDelta: func(delta string) {\r\n\t\t\tif !contentOpen {\r\n\t\t\t\tcontentOpen = true\r\n\t\t\t\tcontentBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\r\n\t\t\t}\r\n\r\n\t\t\tcontentBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\r\n\t\t},\r\n\t\tOnTurnEnd: func() {\r\n\t\t\tif reasoningOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\r\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\treasoningOpen = false\r\n\t\t\t}\r\n\r\n\t\t\tif contentOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\r\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\tcontentOpen = false\r\n\t\t\t}\r\n\t\t},\r\n\t\tOnTool: func(name, arguments, result string, toolErr bool) {\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{\r\n\t\t\t\tType: livelog.Tool,\r\n\t\t\t\tName: name,\r\n\t\t\t\tArguments: arguments,\r\n\t\t\t\tResult: result,\r\n\t\t\t\tError: toolErr,\r\n\t\t\t})\r\n\r\n\t\t\tline, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})\r\n\t\t\tif err != nil {\r\n\t\t\t\tr.logger.Warn(\"failed to marshal tool log entry\", \"job\", jobID, \"error\", err)\r\n\t\t\t} else {\r\n\t\t\t\tlogAppend(\"tool\", string(line))\r\n\t\t\t}\r\n\r\n\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {\r\n\traw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), \"\", \" \")\r\n\r\n\tvar instructionsSection string\r\n\tif instructions != \"\" {\r\n\t\tinstructionsSection = fmt.Sprintf(\"Instructions for this event, from zoo.hcl:\\n%s\\n\\n\", instructions)\r\n\t}\r\n\r\n\t// A pr:review run works on the PR's own head branch, not a fresh\r\n\t// branch off the default branch.\r\n\tbranchLine := fmt.Sprintf(\"Your working branch is %q, checked out from the default branch %q.\\n\\n\", branch, defaultBranch)\r\n\tif ev.Kind == forgejo.EventPRReview {\r\n\t\tbranchLine = fmt.Sprintf(\"Your working branch is %q, the pull request's head branch β commits you push here update the pull request directly.\\n\\n\", branch)\r\n\t}\r\n\r\n\tvar reviewSection string\r\n\tif review != nil {\r\n\t\treviewSection = renderReviewSection(review)\r\n\t}\r\n\r\n\tvar commentsSection string\r\n\tif len(comments) \u003e 0 {\r\n\t\tvar b strings.Builder\r\n\t\tfmt.Fprintf(\u0026b, \"Comments (%d):\\n\\n\", len(comments))\r\n\r\n\t\tfor i, c := range comments {\r\n\t\t\tfmt.Fprintf(\u0026b, \"%d. %s (%s):\\n%s\\n\\n\", i+1, c.Author, c.Created.Format(time.RFC3339), c.Body)\r\n\t\t}\r\n\r\n\t\tcommentsSection = b.String()\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\r\n\t\t\"You were triggered by a %q event on %s/%s.\\n\\n\"+\r\n\t\t\t\"%s%s\"+\r\n\t\t\t\"%sTitle: %s\\n\\nBody:\\n%s\\n\\n%sFull event payload:\\n```json\\n%s\\n```\",\r\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw)\r\n}\r\n\r\n// renderReviewSection renders the submitted review as a briefing\r\n// section: the verdict, the review body, and each inline comment with\r\n// its location and id (the id lets the agent refer to a specific\r\n// comment in its reply).\r\nfunc renderReviewSection(review *forgejo.ReviewDetail) string {\r\n\tvar b strings.Builder\r\n\r\n\tfmt.Fprintf(\u0026b, \"Review submitted by %s:\\n\", review.Reviewer)\r\n\tfmt.Fprintf(\u0026b, \" Verdict: %s\\n\", humanReviewState(review.State))\r\n\r\n\tif review.Body != \"\" {\r\n\t\tb.WriteString(\" Review body:\\n\")\r\n\t\tfor _, line := range strings.Split(review.Body, \"\\n\") {\r\n\t\t\tfmt.Fprintf(\u0026b, \" %s\\n\", line)\r\n\t\t}\r\n\t}\r\n\r\n\tif len(review.Comments) \u003e 0 {\r\n\t\tfmt.Fprintf(\u0026b, \" Inline comments (%d):\\n\", len(review.Comments))\r\n\r\n\t\tfor i, c := range review.Comments {\r\n\t\t\twhere := c.Path\r\n\t\t\tif c.Line \u003e 0 {\r\n\t\t\t\twhere = fmt.Sprintf(\"%s:%d\", c.Path, c.Line)\r\n\t\t\t}\r\n\r\n\t\t\tfmt.Fprintf(\u0026b, \" %d. [%s] %s (comment id %d):\\n\", i+1, where, c.Author, c.ID)\r\n\t\t\tfor _, line := range strings.Split(c.Body, \"\\n\") {\r\n\t\t\t\tfmt.Fprintf(\u0026b, \" %s\\n\", line)\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\treturn b.String() + \"\\n\"\r\n}\r\n\r\n// humanReviewState maps a Forgejo review state to a readable verdict.\r\nfunc humanReviewState(state string) string {\r\n\tswitch state {\r\n\tcase \"APPROVED\":\r\n\t\treturn \"approved\"\r\n\tcase \"REQUEST_CHANGES\":\r\n\t\treturn \"changes requested\"\r\n\tcase \"COMMENT\":\r\n\t\treturn \"comment\"\r\n\tdefault:\r\n\t\treturn state\r\n\t}\r\n}\r","start_line":250,"end_line":437,"total_lines":437,"truncated":false}
{"query": "Git\\(", "case_sensitive": true}
/project/internal/tools/context.go:21: Git(ctx context.Context, subcommand string, args ...string) (string, error)
/project/internal/tools/git.go:56: output, err := ctx.Git(context.Background(), params.Subcommand, params.Args...)
/project/internal/tools/tools_test.go:50:func (f *fakeContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {
/project/internal/agentrun/context.go:39:func (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {
/project/internal/agentrun/sandboxgit.go:40:func runSandboxGit(ctx context.Context, rt *dockerRuntime, containerID string, args ...string) (string, error) {
/project/internal/agentrun/sandboxgit.go:95:func configureSandboxGit(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, user, token, name, email string) error {
/project/internal/agentrun/sandboxgit.go:96: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--system", "--add", "safe.directory", "*"); err != nil {
/project/internal/agentrun/sandboxgit.go:100: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--system", "user.name", name); err != nil {
/project/internal/agentrun/sandboxgit.go:104: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--system", "user.email", email); err != nil {
/project/internal/agentrun/sandboxgit.go:108: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--system", "http."+forgeHost(cloneURL)+".extraHeader", gitAuthHeader(user, token)); err != nil {
/project/internal/agentrun/sandboxgit.go:112: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--system", "push.autoSetupRemote", "true"); err != nil {
/project/internal/agentrun/sandboxgit.go:125: if _, err := runSandboxGit(ctx, rt, containerID, "clone", "--branch", defaultBranch, "--single-branch", cloneURL, "/project"); err != nil {
/project/internal/agentrun/sandboxgit.go:129: if _, err := runSandboxGit(ctx, rt, containerID, "checkout", "-b", branch); err != nil {
/project/internal/agentrun/sandboxgit.go:143: if _, err := runSandboxGit(ctx, rt, containerID, "clone", "--branch", defaultBranch, "--single-branch", cloneURL, "/project"); err != nil {
/project/internal/agentrun/sandboxgit.go:149: if _, err := runSandboxGit(ctx, rt, containerID, "fetch", "--no-tags", "origin", fetchRef); err != nil {
/project/internal/agentrun/sandboxgit.go:153: if _, err := runSandboxGit(ctx, rt, containerID, "checkout", "-B", headRef, "FETCH_HEAD"); err != nil {
/project/internal/agentrun/docker_integration_test.go:115:func TestDockerRuntimeSandboxGit(t *testing.T) {
/project/internal/agentrun/docker_integration_test.go:173: if err := configureSandboxGit(ctx, rt, containerID, cloneURL, user, token, "Greg Coolio", "greg@noreply.localhost"); err != nil {
/project/internal/agentrun/docker_integration_test.go:181: out, err := runSandboxGit(ctx, rt, containerID, "config", "--system", "--get", "http."+forgeHost(cloneURL)+".extraHeader")
/project/internal/agentrun/docker_integration_test.go:195: out, err = runSandboxGit(ctx, rt, containerID, "branch", "--show-current")
/project/internal/agentrun/docker_integration_test.go:208: out, err = runSandboxGit(ctx, rt, containerID, "log", "-1", "--format=%an %ae")
/project/internal/agentrun/run.go:213: if err := configureSandboxGit(ctx, r.docker, containerID, repoInfo.CloneURL, gitUser, gitToken, gitName, gitEmail); err != nil {
{"end_line": 120, "path": "internal/tools/tools_test.go", "start_line": 1}
{"path":"internal/tools/tools_test.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n)\r\n\r\ntype fakeForgejoActions struct {\r\n\tcomments []string\r\n\tassigned []string\r\n\treviews []string\r\n}\r\n\r\nfunc (f *fakeForgejoActions) Comment(body string) error {\r\n\tf.comments = append(f.comments, body)\r\n\treturn nil\r\n}\r\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\r\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error { return nil }\r\nfunc (f *fakeForgejoActions) AddLabel(name string) error { return nil }\r\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error { return nil }\r\nfunc (f *fakeForgejoActions) CloseIssue() error { return nil }\r\nfunc (f *fakeForgejoActions) ReopenIssue() error { return nil }\r\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\r\n\tf.assigned = append(f.assigned, agentName)\r\n\treturn nil\r\n}\r\nfunc (f *fakeForgejoActions) SubmitReview(state, body string) error {\r\n\tf.reviews = append(f.reviews, state)\r\n\treturn nil\r\n}\r\n\r\ntype fakeContext struct {\r\n\tlastCmd string\r\n\toutput string\r\n\terr error\r\n\tfg *fakeForgejoActions\r\n\r\n\tlastGitSubcommand string\r\n\tlastGitArgs []string\r\n}\r\n\r\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {\r\n\tf.lastCmd = command\r\n\treturn f.output, f.err\r\n}\r\n\r\nfunc (f *fakeContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {\r\n\tf.lastGitSubcommand = subcommand\r\n\tf.lastGitArgs = args\r\n\treturn f.output, f.err\r\n}\r\n\r\nfunc (f *fakeContext) Forgejo() ForgejoActions {\r\n\treturn f.fg\r\n}\r\n\r\nfunc TestShellQuote(t *testing.T) {\r\n\tcases := map[string]string{\r\n\t\t\"simple\": \"'simple'\",\r\n\t\t\"it's a dir\": `'it'\\''s a dir'`,\r\n\t}\r\n\tfor in, want := range cases {\r\n\t\tif got := shellQuote(in); got != want {\r\n\t\t\tt.Errorf(\"shellQuote(%q) = %q, want %q\", in, got, want)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc TestReadFileParsesMetaAndContent(t *testing.T) {\r\n\tfc := \u0026fakeContext{output: \"3\\nline one\\nline two\\nline three\\n\"}\r\n\r\n\tout, err := readFile(fc, readFileParams{Path: \"src/main.go\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif !strings.Contains(fc.lastCmd, \"/project/src/main.go\") {\r\n\t\tt.Fatalf(\"expected command to reference /project/src/main.go, got %q\", fc.lastCmd)\r\n\t}\r\n\tif !strings.Contains(out, \"line one\") || !strings.Contains(out, `\"total_lines\":3`) {\r\n\t\tt.Fatalf(\"unexpected result: %s\", out)\r\n\t}\r\n\tif strings.Contains(out, `\"truncated\":true`) {\r\n\t\tt.Fatalf(\"full read should not be truncated: %s\", out)\r\n\t}\r\n}\r\n\r\nfunc TestGitRejectsDisallowedSubcommand(t *testing.T) {\r\n\tfc := \u0026fakeContext{}\r\n\r\n\t_, err := git(fc, gitParams{Subcommand: \"push-force\"})\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for disallowed subcommand\")\r\n\t}\r\n}\r\n\r\nfunc TestGitPassesSubcommandAndArgsThrough(t *testing.T) {\r\n\tfc := \u0026fakeContext{output: \"ok\"}\r\n\r\n\t_, err := git(fc, gitParams{Subcommand: \"commit\", Args: []string{\"-m\", \"fix: handle it's edge case\"}})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif fc.lastGitSubcommand != \"commit\" {\r\n\t\tt.Fatalf(\"expected subcommand %q, got %q\", \"commit\", fc.lastGitSubcommand)\r\n\t}\r\n\tif len(fc.lastGitArgs) != 2 || fc.lastGitArgs[1] != \"fix: handle it's edge case\" {\r\n\t\tt.Fatalf(\"expected args passed through unescaped, got %v\", fc.lastGitArgs)\r\n\t}\r\n}\r\n\r\nfunc TestCommentToolCallsForgejo(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tif _, err := comment(fc, commentParams{Body: \"hello\"}); err != nil {\r","start_line":1,"end_line":120,"total_lines":216,"truncated":true}
{"end_line": 80, "path": "internal/agentrun/sandboxgit_test.go", "start_line": 1}
{"path":"internal/agentrun/sandboxgit_test.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"encoding/base64\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n)\r\n\r\nfunc TestSandboxShellQuote(t *testing.T) {\r\n\tcases := map[string]string{\r\n\t\t\"simple\": \"'simple'\",\r\n\t\t\"it's a branch\": `'it'\\''s a branch'`,\r\n\t\t\"with space\": \"'with space'\",\r\n\t\t\"$(rm -rf /)\": \"'$(rm -rf /)'\",\r\n\t\t\"a\\\"b\\\\c\": \"'a\\\"b\\\\c'\",\r\n\t\t\"\": \"''\",\r\n\t\t\"HEAD~1\": \"'HEAD~1'\",\r\n\t\t\"https://h/a/b.git\": \"'https://h/a/b.git'\",\r\n\t}\r\n\r\n\tfor in, want := range cases {\r\n\t\tif got := shellQuote(in); got != want {\r\n\t\t\tt.Errorf(\"shellQuote(%q) = %q, want %q\", in, got, want)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc TestShellGitCmdQuotesEveryArg(t *testing.T) {\r\n\tgot := shellGitCmd(\"clone\", \"--branch\", \"main\", \"--single-branch\", \"https://h/a/b.git\", \"/project\")\r\n\r\n\twant := \"git 'clone' '--branch' 'main' '--single-branch' 'https://h/a/b.git' '/project'\"\r\n\r\n\tif got != want {\r\n\t\tt.Fatalf(\"shellGitCmd = %q, want %q\", got, want)\r\n\t}\r\n}\r\n\r\nfunc TestShellGitCmdSubcommandWithArgs(t *testing.T) {\r\n\tgot := shellGitCmd(\"commit\", \"-m\", \"fix: handle it's edge case\")\r\n\r\n\tif !strings.HasPrefix(got, \"git 'commit' '-m' \") {\r\n\t\tt.Fatalf(\"shellGitCmd = %q, want prefix git 'commit' '-m' \", got)\r\n\t}\r\n\r\n\t// The message must be one quoted word with the embedded quote\r\n\t// escaped, so sh passes it through verbatim.\r\n\tif want := `'fix: handle it'\\''s edge case'`; !strings.HasSuffix(got, want) {\r\n\t\tt.Fatalf(\"shellGitCmd = %q, want suffix %q\", got, want)\r\n\t}\r\n}\r\n\r\nfunc TestForgeHost(t *testing.T) {\r\n\tcases := map[string]string{\r\n\t\t\"https://code.stdio.dk/abrander/zoo.git\": \"https://code.stdio.dk\",\r\n\t\t\"http://forgejo.local:3000/acme/widgets\": \"http://forgejo.local:3000\",\r\n\t\t\"https://h/a/b.git?x=1\": \"https://h\",\r\n\t\t// Not a URL with a host: fall back to the full string, which is\r\n\t\t// still a valid (narrower) prefix match.\r\n\t\t\"/bare\": \"/bare\",\r\n\t\t\"not a url\": \"not a url\",\r\n\t}\r\n\r\n\tfor in, want := range cases {\r\n\t\tif got := forgeHost(in); got != want {\r\n\t\t\tt.Errorf(\"forgeHost(%q) = %q, want %q\", in, got, want)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc TestGitAuthHeader(t *testing.T) {\r\n\tgot := gitAuthHeader(\"greg\", \"s3cr3t\")\r\n\r\n\twant := \"Authorization: Basic \" + base64.StdEncoding.EncodeToString([]byte(\"greg:s3cr3t\"))\r\n\r\n\tif got != want {\r\n\t\tt.Fatalf(\"gitAuthHeader = %q, want %q\", got, want)\r\n\t}\r\n}\r","start_line":1,"end_line":78,"total_lines":78,"truncated":false}
{"path": "README.md", "start_line": 1, "end_line": 200}
{"path":"README.md","content":"# πΎ Zoo\r\n\r\n**Autonomous AI agents that work your Forgejo issues \u0026 pull requests.**\r\n\r\nZoo watches a [Forgejo](https://codeberg.org/forgejo/forgejo) instance for issue and pull request events, then dispatches them to configured AI agents running in isolated Docker containers. Each agent has its own role, LLM model, and personality β like a menagerie working together to keep your projects moving.\r\n\r\n---\r\n\r\n## β¨ Features\r\n\r\n- **Multi-agent orchestration** β Assign different tasks to specialized agents (reviewers, managers, developers).\r\n- **Event-driven routing** β Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more.\r\n- **LLM flexibility** β Plug in any OpenAI-compatible API; each agent gets its own model choice.\r\n- **Isolated execution** β Agents run in Docker containers with full filesystem access but no persistence between runs.\r\n- **Live dashboard** β Real-time web UI showing active agents, logs, and job history.\r\n- **Webhook \u0026 polling support** β React to events instantly via webhooks, or fall back to polling.\r\n\r\n---\r\n\r\n## π Quick Start\r\n\r\n### Prerequisites\r\n\r\n| Requirement | Version |\r\n|-------------|---------|\r\n| Go | 1.26+ |\r\n| Docker | Latest |\r\n| Forgejo | Any (self-hosted or codeberg.dk) |\r\n| LLM endpoint | OpenAI-compatible API |\r\n\r\n### Configuration\r\n\r\nCopy the example config and customize it:\r\n\r\n```bash\r\ncp zoo.hcl.example zoo.hcl\r\n```\r\n\r\nEdit `zoo.hcl` with your Forgejo credentials, LLM tokens, and agent definitions. See the [configuration reference](#-configuration-reference) below.\r\n\r\n### Running\r\n\r\n```bash\r\ngo build -o zoo ./cmd/zoo\r\n./zoo\r\n```\r\n\r\nThe daemon starts on port `:8080` by default. Open your browser to see the dashboard.\r\n\r\n---\r\n\r\n## π₯ Meet the Agents\r\n\r\nThe example configuration includes four agents, each with a distinct role:\r\n\r\n| Agent | Role | Suggested LLM | Handles |\r\n|----------|-----------------------|---------------------|----------------------------------|\r\n| **leon** | Engineering Manager | Qwen 3.8 | New issues, comments |\r\n| **greg** | Senior Developer | Qwen 3.8 | Pull request reviews |\r\n| **anna** | UI/UX Designer | Qwen 3.6 | Design-related issues \u0026 PRs |\r\n| **mika** | Junior Developer | Qwen 3.6 | Assigned issues |\r\n\r\nYou can add, remove, or reassign agents freely in your `zoo.hcl`.\r\n\r\n---\r\n\r\n## βοΈ Configuration Reference\r\n\r\nAll settings live in a single HCL file (`zoo.hcl`). Here's what each section controls:\r\n\r\n### LLM Definitions\r\n\r\nDefine one or more LLM endpoints. Agents reference these by name.\r\n\r\n```hcl\r\nllm \"Qwen 3.6\" {\r\n openai = \"https://your-llm-endpoint\"\r\n token = \"YOUR_API_TOKEN\"\r\n model = \"model-name\"\r\n}\r\n```\r\n\r\n### Forgejo Connection\r\n\r\n```hcl\r\nforgejo {\r\n url = \"https://code.stdio.dk\"\r\n token = \"ZOO_SERVICE_TOKEN\"\r\n webhook_secret = \"SHARED_SECRET\" # optional if using polling\r\n}\r\n```\r\n\r\n### Environment\r\n\r\n```hcl\r\nenvironment {\r\n docker_image = \"golang:latest\" # base image for agent containers\r\n max_live_agents = 5 # concurrent agent limit\r\n}\r\n```\r\n\r\n### Agent Definition\r\n\r\n```hcl\r\nagent \"anna\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"ANNA_FORGEJO_TOKEN\"\r\n}\r\n```\r\n\r\nThe optional `token` is the agent's own Forgejo token. When set, the\r\nagent acts as itself on Forgejo (comments, PRs, ...) and its sandbox's\r\ngit authenticates with it too β the initial clone and all remote git\r\noperations (pull, push, ...) run inside the container with that\r\ncredential. Without it, the shared `forgejo.token` is used.\r\n\r\n### Event Routing\r\n\r\nMap event types to agents with optional custom instructions:\r\n\r\n```hcl\r\nevent \"issue:new\" {\r\n agent = \"leon\"\r\n instructions = \"Triage this issue.\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n // No `agent` β dynamically matches the assignee's username\r\n instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n agent = \"greg\"\r\n instructions = \"Review this pull request.\"\r\n}\r\n```\r\n\r\n---\r\n\r\n## π§ Command-Line Flags\r\n\r\n| Flag | Default | Description |\r\n|-------------------|-----------------|----------------------------------------------|\r\n| `-config` | `zoo.hcl` | Path to the configuration file |\r\n| `-db` | `zoo.db` | SQLite database path |\r\n| `-listen` | `:8080` | HTTP listen address |\r\n| `-run-timeout` | (see source) | Max wall-clock time per agent run |\r\n| `-keep-on-failure`| `false` | Preserve container after failure for debugging |\r\n\r\n---\r\n\r\n## π Project Structure\r\n\r\n```\r\nβββ cmd/zoo/main.go # Entry point\r\nβββ internal/\r\nβ βββ agentrun/ # Docker-based agent execution\r\nβ βββ config/ # HCL config loader \u0026 validator\r\nβ βββ forgejo/ # Webhook receiver \u0026 event poller\r\nβ βββ livelog/ # Real-time log streaming hub\r\nβ βββ llm/ # LLM client \u0026 streaming\r\nβ βββ scheduler/ # Event-to-agent dispatcher\r\nβ βββ store/ # SQLite state management\r\nβ βββ tools/ # Agent tool registry\r\nβ βββ web/ # Dashboard HTTP server\r\nβββ zoo.hcl.example # Configuration template\r\n```\r\n\r\n---\r\n\r\n## π Development\r\n\r\n### Running Tests\r\n\r\n```bash\r\ngo test ./...\r\n```\r\n\r\n### Adding a New Agent\r\n\r\n1. Define the agent in `zoo.hcl`:\r\n\r\n ```hcl\r\n agent \"newagent\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"NEW_AGENT_TOKEN\"\r\n }\r\n ```\r\n\r\n2. Add an event route:\r\n\r\n ```hcl\r\n event \"issue:new\" {\r\n agent = \"newagent\"\r\n }\r\n ```\r\n\r\n3. Update your Forgejo profile with the agent's role description.\r\n\r\n### Adding a New Tool\r","start_line":1,"end_line":200,"total_lines":223,"truncated":true}
{"end_line": 223, "path": "README.md", "start_line": 200}
{"path":"README.md","content":"### Adding a New Tool\r\n\r\nTools are registered in `internal/tools/registry.go`. Each tool is a Go function that operates on the agent's `/project` directory. See existing tools for patterns.\r\n\r\n---\r\n\r\n## π€ Contributing\r\n\r\nContributions are welcome! Please:\r\n\r\n1. Fork the repository\r\n2. Create a feature branch\r\n3. Make your changes\r\n4. Open a pull request\r\n\r\n---\r\n\r\n## π License\r\n\r\nThis project is available as open source under the terms of the [MIT License](LICENSE).\r\n\r\n---\r\n\r\n*Made with β€οΈ by the [zoo](https://code.stdio.dk/abrander/zoo) team.*\r","start_line":200,"end_line":223,"total_lines":223,"truncated":false}
{"end_line": 216, "path": "internal/tools/tools_test.go", "start_line": 120}
{"path":"internal/tools/tools_test.go","content":"\tif _, err := comment(fc, commentParams{Body: \"hello\"}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif len(fg.comments) != 1 || fg.comments[0] != \"hello\" {\r\n\t\tt.Fatalf(\"unexpected comments: %v\", fg.comments)\r\n\t}\r\n}\r\n\r\nfunc TestAssignIssueToolCallsForgejo(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif out != \"Assigned to greg\" {\r\n\t\tt.Fatalf(\"unexpected result: %q\", out)\r\n\t}\r\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\r\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\r\n\t}\r\n}\r\n\r\nfunc TestSubmitReviewToolCallsForgejo(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tout, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \"please fix\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif out != \"Review submitted\" {\r\n\t\tt.Fatalf(\"unexpected result: %q\", out)\r\n\t}\r\n\tif len(fg.reviews) != 1 || fg.reviews[0] != \"changes_requested\" {\r\n\t\tt.Fatalf(\"unexpected reviews: %v\", fg.reviews)\r\n\t}\r\n}\r\n\r\n// An approval needs no body, but any other verdict does.\r\nfunc TestSubmitReviewRequiresBodyUnlessApproved(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tif _, err := submitReview(fc, submitReviewParams{State: \"approved\", Body: \"\"}); err != nil {\r\n\t\tt.Fatalf(\"approval with empty body should be allowed, got %v\", err)\r\n\t}\r\n\r\n\tif _, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \" \"}); err == nil {\r\n\t\tt.Fatal(\"expected an error for a changes_requested review with an empty body\")\r\n\t}\r\n}\r\n\r\nfunc TestGrepSearchUsesExtendedRegex(t *testing.T) {\r\n\tfc := \u0026fakeContext{output: \"/project/foo.go:1:foo|bar\\n\"}\r\n\r\n\tout, err := grepSearch(fc, grepSearchParams{Pattern: `foo|bar`})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !strings.Contains(fc.lastCmd, \"grep -Rn -I -E\") {\r\n\t\tt.Fatalf(\"expected grep to be invoked with -E (extended regex), got %q\", fc.lastCmd)\r\n\t}\r\n\tif !strings.Contains(out, \"foo|bar\") {\r\n\t\tt.Fatalf(\"unexpected result: %s\", out)\r\n\t}\r\n}\r\n\r\nfunc TestGrepSearchNoMatches(t *testing.T) {\r\n\tfc := \u0026fakeContext{output: \"\", err: fmt.Errorf(\"exit status 1\")}\r\n\r\n\tout, err := grepSearch(fc, grepSearchParams{Pattern: \"nomatch\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif out != \"No matches found.\" {\r\n\t\tt.Fatalf(\"unexpected result: %q\", out)\r\n\t}\r\n}\r\n\r\nfunc TestRegistryRoundTrip(t *testing.T) {\r\n\tif len(All()) == 0 {\r\n\t\tt.Fatal(\"expected registered tools\")\r\n\t}\r\n\r\n\tfound := false\r\n\tfor _, tl := range All() {\r\n\t\tif tl.Function.Name == \"bash\" {\r\n\t\t\tfound = true\r\n\t\t}\r\n\t}\r\n\tif !found {\r\n\t\tt.Fatal(\"expected bash tool to be registered\")\r\n\t}\r\n}\r","start_line":120,"end_line":216,"total_lines":216,"truncated":false}
{"path": "internal/tools/shell.go", "start_line": 1, "end_line": 60}
{"path":"internal/tools/shell.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"encoding/base64\"\r\n\t\"fmt\"\r\n\t\"strings\"\r\n)\r\n\r\n// shellQuote wraps s in single quotes for safe interpolation into a `sh\r\n// -c` command string, escaping any embedded single quotes.\r\nfunc shellQuote(s string) string {\r\n\treturn \"'\" + strings.ReplaceAll(s, \"'\", `'\\''`) + \"'\"\r\n}\r\n\r\nfunc resolvePath(path string) string {\r\n\tif path == \"\" {\r\n\t\treturn \"/project\"\r\n\t}\r\n\r\n\tif path == \".\" || path == \"./\" {\r\n\t\treturn \"/project\"\r\n\t}\r\n\r\n\tif strings.HasPrefix(path, \"/project/\") {\r\n\t\treturn path\r\n\t}\r\n\r\n\tif !strings.HasPrefix(path, \"/\") {\r\n\t\treturn \"/project/\" + strings.TrimPrefix(path, \"/\")\r\n\t}\r\n\r\n\treturn path\r\n}\r\n\r\nfunc shellReadFile(ctx Context, path string) ([]byte, error) {\r\n\tp := resolvePath(path)\r\n\r\n\t// We use base64 encoding to avoid issues with binary data and\r\n\t// special characters in the file content. The output of the command\r\n\t// will be base64-encoded, which we then decode in Go.\r\n\toutput, err := ctx.Exec(context.Background(), \"base64 \"+shellQuote(p))\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tdecoded, err := base64.StdEncoding.DecodeString(output)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"decode base64 output of %q: %w\", path, err)\r\n\t}\r\n\r\n\treturn decoded, nil\r\n}\r\n\r\nfunc shellWriteFile(ctx Context, path string, content []byte) error {\r\n\tp := resolvePath(path)\r\n\tencoded := base64.StdEncoding.EncodeToString(content)\r\n\r\n\tcmd := fmt.Sprintf(\r\n\t\t`mkdir -p \"$(dirname %s)\" \u0026\u0026 echo %s | base64 -d \u003e %s`,\r","start_line":1,"end_line":60,"total_lines":69,"truncated":true}
{"path": "internal/agentrun/docker_integration_test.go", "start_line": 1, "end_line": 230}
{"path":"internal/agentrun/docker_integration_test.go","content":"//go:build docker\r\n\r\n// Run with: go test -tags docker ./internal/agentrun/...\r\n// Requires a reachable Docker daemon (DOCKER_HOST or the default socket).\r\npackage agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"os\"\r\n\t\"os/exec\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n\t\"time\"\r\n)\r\n\r\nfunc TestDockerRuntimeExecRoundTrip(t *testing.T) {\r\n\trt, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"docker client: %v\", err)\r\n\t}\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\r\n\tdefer cancel()\r\n\r\n\tcontainerID, err := rt.createContainer(ctx, \"debian:unstable\", nil, \"zoo-test-run\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"create container: %v\", err)\r\n\t}\r\n\tdefer rt.remove(context.Background(), containerID)\r\n\r\n\toutput, exitCode, err := rt.exec(ctx, containerID, \"echo hello-from-zoo\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"exec: %v\", err)\r\n\t}\r\n\tif exitCode != 0 {\r\n\t\tt.Fatalf(\"expected exit code 0, got %d\", exitCode)\r\n\t}\r\n\tif !strings.Contains(output, \"hello-from-zoo\") {\r\n\t\tt.Fatalf(\"unexpected output: %q\", output)\r\n\t}\r\n\r\n\t_, exitCode, err = rt.exec(ctx, containerID, \"exit 3\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"exec: %v\", err)\r\n\t}\r\n\tif exitCode != 3 {\r\n\t\tt.Fatalf(\"expected exit code 3, got %d\", exitCode)\r\n\t}\r\n}\r\n\r\n// TestDockerRuntimeGitSafeDirectory reproduces the \"detected dubious\r\n// ownership\" error git raises against a bind-mounted repo owned by a\r\n// different UID than the container runs as, and confirms the `git\r\n// config --system --add safe.directory '*'` fix Run() applies (see\r\n// run.go) actually clears it, against the same golang:latest image\r\n// zoo.hcl now defaults to.\r\nfunc TestDockerRuntimeGitSafeDirectory(t *testing.T) {\r\n\tprojectDir := t.TempDir()\r\n\r\n\tfor _, args := range [][]string{\r\n\t\t{\"init\", \"-q\", projectDir},\r\n\t\t{\"-C\", projectDir, \"commit\", \"-q\", \"--allow-empty\", \"-m\", \"init\"},\r\n\t} {\r\n\t\tif out, err := exec.Command(\"git\", args...).CombinedOutput(); err != nil {\r\n\t\t\tt.Fatalf(\"git %v: %v: %s\", args, err, out)\r\n\t\t}\r\n\t}\r\n\r\n\trt, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"docker client: %v\", err)\r\n\t}\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\r\n\tdefer cancel()\r\n\r\n\tcontainerID, err := rt.createContainer(ctx, \"golang:latest\", []string{projectDir + \":/project\"}, \"zoo-test-git\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"create container: %v\", err)\r\n\t}\r\n\tdefer rt.remove(context.Background(), containerID)\r\n\r\n\toutput, _, err := rt.exec(ctx, containerID, \"git status\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"exec: %v\", err)\r\n\t}\r\n\tif !strings.Contains(output, \"dubious ownership\") {\r\n\t\tt.Fatalf(\"expected the bind mount to reproduce dubious ownership before the fix, got: %s\", output)\r\n\t}\r\n\r\n\toutput, exitCode, err := rt.exec(ctx, containerID, \"git config --system --add safe.directory '*'\")\r\n\tif err != nil || exitCode != 0 {\r\n\t\tt.Fatalf(\"configure safe.directory: err=%v exit=%d: %s\", err, exitCode, output)\r\n\t}\r\n\r\n\toutput, exitCode, err = rt.exec(ctx, containerID, \"git status\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"exec: %v\", err)\r\n\t}\r\n\tif exitCode != 0 || strings.Contains(output, \"dubious ownership\") {\r\n\t\tt.Fatalf(\"expected git status to succeed after the fix, got exit=%d: %s\", exitCode, output)\r\n\t}\r\n}\r\n\r\n// TestDockerRuntimeSandboxGit exercises the in-sandbox git setup\r\n// Run() performs (see sandboxgit.go): the system gitconfig round-trip\r\n// (including the http.\u003curl\u003e.extraHeader key whose subsection is a URL\r\n// full of dots and colons), the initial clone + branch done inside the\r\n// container, the commit identity taken from the system gitconfig, and\r\n// that the credential never lands in the bind-mounted working tree.\r\n// The clone uses a local path remote (no http involved), so the test\r\n// needs no reachable Forgejo; the header mechanism itself is core git\r\n// behavior.\r\nfunc TestDockerRuntimeSandboxGit(t *testing.T) {\r\n\ttmp := t.TempDir()\r\n\r\n\t// A bare \"remote\" on the host, plus the empty directory the\r\n\t// container will clone into (Run() creates it before the container\r\n\t// exists, for the same reason).\r\n\tseedDir := filepath.Join(tmp, \"seed\")\r\n\tbareDir := filepath.Join(tmp, \"remote.git\")\r\n\tprojectDir := filepath.Join(tmp, \"project\")\r\n\r\n\trun := func(dir string, args ...string) {\r\n\t\tcmd := exec.Command(\"git\", args...)\r\n\t\tcmd.Dir = dir\r\n\r\n\t\tif out, err := cmd.CombinedOutput(); err != nil {\r\n\t\t\tt.Fatalf(\"git %v: %v: %s\", args, err, out)\r\n\t\t}\r\n\t}\r\n\r\n\trun(\"\", \"init\", \"-q\", \"-b\", \"main\", seedDir)\r\n\trun(seedDir, \"config\", \"user.name\", \"zoo-test\")\r\n\trun(seedDir, \"config\", \"user.email\", \"zoo@test\")\r\n\r\n\tif err := os.WriteFile(filepath.Join(seedDir, \"file.txt\"), []byte(\"hello\\n\"), 0o644); err != nil {\r\n\t\tt.Fatalf(\"write seed file: %v\", err)\r\n\t}\r\n\r\n\trun(seedDir, \"add\", \".\")\r\n\trun(seedDir, \"commit\", \"-q\", \"-m\", \"init\")\r\n\trun(\"\", \"clone\", \"-q\", \"--bare\", seedDir, bareDir)\r\n\r\n\tif err := os.MkdirAll(projectDir, 0o755); err != nil {\r\n\t\tt.Fatalf(\"create project dir: %v\", err)\r\n\t}\r\n\r\n\trt, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"docker client: %v\", err)\r\n\t}\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)\r\n\tdefer cancel()\r\n\r\n\tcontainerID, err := rt.createContainer(ctx, \"golang:latest\", []string{\r\n\t\tbareDir + \":/bare\",\r\n\t\tprojectDir + \":/project\",\r\n\t}, \"zoo-test-sandbox-git\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"create container: %v\", err)\r\n\t}\r\n\tdefer rt.remove(context.Background(), containerID)\r\n\r\n\tconst (\r\n\t\tcloneURL = \"https://forgejo.example/acme/widgets.git\"\r\n\t\tuser = \"greg\"\r\n\t\ttoken = \"super-secret-token\"\r\n\t)\r\n\r\n\tif err := configureSandboxGit(ctx, rt, containerID, cloneURL, user, token, \"Greg Coolio\", \"greg@noreply.localhost\"); err != nil {\r\n\t\tt.Fatalf(\"configureSandboxGit: %v\", err)\r\n\t}\r\n\r\n\t// The credential must round-trip through the system gitconfig,\r\n\t// which is what makes plain `git pull`/`git push` authenticate.\r\n\twantHeader := gitAuthHeader(user, token)\r\n\r\n\tout, err := runSandboxGit(ctx, rt, containerID, \"config\", \"--system\", \"--get\", \"http.\"+forgeHost(cloneURL)+\".extraHeader\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"read back credential: %v\", err)\r\n\t}\r\n\tif got := strings.TrimSpace(out); got != wantHeader {\r\n\t\tt.Fatalf(\"credential round-trip = %q, want %q\", got, wantHeader)\r\n\t}\r\n\r\n\tif err := cloneAndBranch(ctx, rt, containerID, \"/bare\", \"main\", \"issue-1-greg\"); err != nil {\r\n\t\tt.Fatalf(\"cloneAndBranch: %v\", err)\r\n\t}\r\n\r\n\t// The clone happened inside the container: the fresh branch is\r\n\t// checked out in /project.\r\n\tout, err = runSandboxGit(ctx, rt, containerID, \"branch\", \"--show-current\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"branch --show-current: %v\", err)\r\n\t}\r\n\tif got := strings.TrimSpace(out); got != \"issue-1-greg\" {\r\n\t\tt.Fatalf(\"branch = %q, want issue-1-greg\", got)\r\n\t}\r\n\r\n\t// Commits must pick up the identity from the system gitconfig.\r\n\tif _, exitCode, err := rt.exec(ctx, containerID, \"echo more \u003e\u003e file.txt \u0026\u0026 git add file.txt \u0026\u0026 git commit -qm 'from sandbox'\"); err != nil || exitCode != 0 {\r\n\t\tt.Fatalf(\"commit in sandbox: err=%v exit=%d\", err, exitCode)\r\n\t}\r\n\r\n\tout, err = runSandboxGit(ctx, rt, containerID, \"log\", \"-1\", \"--format=%an %ae\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"log: %v\", err)\r\n\t}\r\n\tif got := strings.TrimSpace(out); got != \"Greg Coolio greg@noreply.localhost\" {\r\n\t\tt.Fatalf(\"commit author = %q, want %q\", got, \"Greg Coolio greg@noreply.localhost\")\r\n\t}\r\n\r\n\t// The token must not leak into the bind-mounted working tree.\r\n\tout, _, err = rt.exec(ctx, containerID, \"grep -r super-secret-token /project || true\")\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"grep: %v\", err)\r\n\t}\r\n\tif strings.Contains(out, \"super-secret-token\") {\r\n\t\tt.Fatalf(\"token leaked into the working tree: %s\", out)\r\n\t}\r\n}\r","start_line":1,"end_line":224,"total_lines":224,"truncated":false}
{"query": "git tool|`git`|gitAllowed", "case_sensitive": false}
/project/internal/tools/git.go:12:// gitAllowed intentionally excludes remote-mutating subcommands other
/project/internal/tools/git.go:15:var gitAllowed = []string{
/project/internal/tools/git.go:41: tool.AddEnumProperty("subcommand", "The git subcommand to run", gitAllowed, true)
/project/internal/tools/git.go:52: if !slices.Contains(gitAllowed, params.Subcommand) {
/project/internal/tools/git.go:53: return "", fmt.Errorf("git subcommand '%s' is not allowed. Only %s are permitted", params.Subcommand, strings.Join(gitAllowed, ", "))
/project/internal/agentrun/system.md:20:- `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,
/project/internal/agentrun/system.md:39:simply work β through the `git` tool or plain `git` in `bash`.
{"case_sensitive": false, "query": "git", "glob": "internal/agentrun/loop.go"}
No matches found.
{"case_sensitive": false, "query": "git", "glob": "README.md"}
/project/README.md:113:git authenticates with it too β the initial clone and all remote git
{"case_sensitive": false, "query": "\"git\"|'git'|\\bgit\\b", "glob": "internal/web/*.go"}
No matches found.
{"command": "cd /project && grep -rn \"git\" --include=\"*.go\" internal/tools/ | grep -v \"_test.go\" | grep -iv \"gitconfig\" ; echo \"=== test files ===\"; grep -rn \"git\" --include=\"*_test.go\" internal/tools/"}
internal/tools/forgejo.go:7: "github.com/abrander/zoo/internal/llm"
internal/tools/grep_search.go:9: "github.com/abrander/zoo/internal/llm"
internal/tools/context.go:16: // Git runs a git subcommand against the run's working tree inside
internal/tools/remove_file.go:7: "github.com/abrander/zoo/internal/llm"
internal/tools/registry.go:7: "github.com/abrander/zoo/internal/llm"
internal/tools/write_file.go:6: "github.com/abrander/zoo/internal/llm"
internal/tools/move_file.go:7: "github.com/abrander/zoo/internal/llm"
internal/tools/read_file.go:10: "github.com/abrander/zoo/internal/llm"
internal/tools/git.go:9: "github.com/abrander/zoo/internal/llm"
internal/tools/git.go:12:// gitAllowed intentionally excludes remote-mutating subcommands other
internal/tools/git.go:15:var gitAllowed = []string{
internal/tools/git.go:31:type gitParams struct {
internal/tools/git.go:38: "git",
internal/tools/git.go:39: "Run a git subcommand with optional arguments against the project's working tree")
internal/tools/git.go:41: tool.AddEnumProperty("subcommand", "The git subcommand to run", gitAllowed, true)
internal/tools/git.go:42: tool.AddStringArrayProperty("args", "Additional arguments to pass to the git subcommand, e.g. ['HEAD~1', 'HEAD']", false)
internal/tools/git.go:44: Register(tool, git)
internal/tools/git.go:47:// git runs subcommand via Context.Git, which executes it inside the
internal/tools/git.go:49:// for git (see that method's doc comment) β remote operations like
internal/tools/git.go:51:func git(ctx Context, params gitParams) (string, error) {
internal/tools/git.go:52: if !slices.Contains(gitAllowed, params.Subcommand) {
internal/tools/git.go:53: return "", fmt.Errorf("git subcommand '%s' is not allowed. Only %s are permitted", params.Subcommand, strings.Join(gitAllowed, ", "))
internal/tools/edit-file.go:8: "github.com/abrander/zoo/internal/llm"
internal/tools/list_files.go:7: "github.com/abrander/zoo/internal/llm"
internal/tools/bash.go:8: "github.com/abrander/zoo/internal/llm"
=== test files ===
internal/tools/tools_test.go:94: _, err := git(fc, gitParams{Subcommand: "push-force"})
internal/tools/tools_test.go:103: _, err := git(fc, gitParams{Subcommand: "commit", Args: []string{"-m", "fix: handle it's edge case"}})
{"command": "cd /project && grep -rn \"git\" --include=\"*.go\" internal/agentrun/seed_test.go internal/agentrun/roster_test.go internal/llm/ 2>/dev/null | grep -iv \"gitconfig\\|github.com\" ; echo \"=== done ===\""}
internal/agentrun/roster_test.go:49: name, email := gitIdentity("leon", roster)
internal/agentrun/roster_test.go:58: name, email := gitIdentity("leon", roster)
internal/agentrun/roster_test.go:68: name, email := gitIdentity("mika", nil)
=== done ===
{"path": "internal/tools/git.go"}
File removed successfully
{"path": "internal/tools/context.go", "find": "\t// Git runs a git subcommand against the run's working tree inside\n\t// the run's container. Remote operations (fetch, pull, push, ...)\n\t// authenticate via the Forgejo credential the run's setup wrote to\n\t// the container's system gitconfig, so they work without any\n\t// per-invocation plumbing.\n\tGit(ctx context.Context, subcommand string, args ...string) (string, error)\n\n\t// Forgejo returns the actions bound to the issue/PR that triggered", "replace": "\t// Forgejo returns the actions bound to the issue/PR that triggered"}
1 occurrences replaced
{"path": "internal/agentrun/context.go", "find": "// runContext implements tools.Context for a single agent run, executing\n// both shell commands and git commands via docker exec against the\n// run's container, and binding Forgejo actions to the triggering\n// issue/PR.", "replace": "// runContext implements tools.Context for a single agent run, executing\n// shell commands via docker exec against the run's container, and\n// binding Forgejo actions to the triggering issue/PR."}
1 occurrences replaced
{"path": "internal/agentrun/context.go", "find": "// Git implements tools.Context.Git by running git inside the run's\n// container, in its /project working directory. Remote operations\n// (fetch, pull, push, ...) authenticate via the http.<url>.extraHeader\n// credential configureSandboxGit wrote to the container's system\n// gitconfig, so no per-invocation credential plumbing is needed.\nfunc (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {\n\tcmdArgs := append([]string{subcommand}, args...)\n\n\treturn c.Exec(ctx, shellGitCmd(cmdArgs...))\n}\n\nfunc (c *runContext) Forgejo() tools.ForgejoActions {", "replace": "func (c *runContext) Forgejo() tools.ForgejoActions {"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "type fakeContext struct {\n\tlastCmd string\n\toutput string\n\terr error\n\tfg *fakeForgejoActions\n\n\tlastGitSubcommand string\n\tlastGitArgs []string\n}\n\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {\n\tf.lastCmd = command\n\treturn f.output, f.err\n}\n\nfunc (f *fakeContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {\n\tf.lastGitSubcommand = subcommand\n\tf.lastGitArgs = args\n\treturn f.output, f.err\n}\n\nfunc (f *fakeContext) Forgejo() ForgejoActions {", "replace": "type fakeContext struct {\n\tlastCmd string\n\toutput string\n\terr error\n\tfg *fakeForgejoActions\n}\n\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {\n\tf.lastCmd = command\n\treturn f.output, f.err\n}\n\nfunc (f *fakeContext) Forgejo() ForgejoActions {"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "func TestGitRejectsDisallowedSubcommand(t *testing.T) {\n\tfc := &fakeContext{}\n\n\t_, err := git(fc, gitParams{Subcommand: \"push-force\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected error for disallowed subcommand\")\n\t}\n}\n\nfunc TestGitPassesSubcommandAndArgsThrough(t *testing.T) {\n\tfc := &fakeContext{output: \"ok\"}\n\n\t_, err := git(fc, gitParams{Subcommand: \"commit\", Args: []string{\"-m\", \"fix: handle it's edge case\"}})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif fc.lastGitSubcommand != \"commit\" {\n\t\tt.Fatalf(\"expected subcommand %q, got %q\", \"commit\", fc.lastGitSubcommand)\n\t}\n\tif len(fc.lastGitArgs) != 2 || fc.lastGitArgs[1] != \"fix: handle it's edge case\" {\n\t\tt.Fatalf(\"expected args passed through unescaped, got %v\", fc.lastGitArgs)\n\t}\n}\n\nfunc TestCommentToolCallsForgejo(t *testing.T) {", "replace": "func TestCommentToolCallsForgejo(t *testing.T) {"}
1 occurrences replaced
{"path": "internal/agentrun/system.md", "find": "- `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,\n `move_file`, `remove_file` operate on the project container.", "replace": "- `bash`, `read_file`, `write_file`, `list_files`, `grep_search`,\n `move_file`, `remove_file` operate on the project container."}
1 occurrences replaced
{"path": "internal/agentrun/system.md", "find": "**Git**\n\nYou may use normal git commands to manage your working tree. Remote\n(pull, fetch, push, etc.) git operations are authenticated for you and\nsimply work β through the `git` tool or plain `git` in `bash`.", "replace": "**Git**\n\nYou may use normal git commands in `bash` to manage your working tree.\nRemote (pull, fetch, push, etc.) git operations are authenticated for\nyou and simply work."}
1 occurrences replaced
{"command": "cd /project && gofmt -l . && go build ./... && go vet ./... && go vet -tags docker ./... && go test ./... 2>&1 | tail -20"}
go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec ? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.003s ok github.com/abrander/zoo/internal/config 0.004s ok github.com/abrander/zoo/internal/forgejo 0.465s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 2.464s ok github.com/abrander/zoo/internal/store 1.676s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.833s
{"command": "cd /project && git add -A && git status --short && git diff --cached --stat && git diff --cached"}
M internal/agentrun/context.go
M internal/agentrun/system.md
M internal/tools/context.go
D internal/tools/git.go
M internal/tools/tools_test.go
internal/agentrun/context.go | 16 ++--------
internal/agentrun/system.md | 8 ++---
internal/tools/context.go | 7 -----
internal/tools/git.go | 72 --------------------------------------------
internal/tools/tools_test.go | 34 ---------------------
5 files changed, 6 insertions(+), 131 deletions(-)
diff --git a/internal/agentrun/context.go b/internal/agentrun/context.go
index abf82bb..28cfcd6 100644
--- a/internal/agentrun/context.go
+++ b/internal/agentrun/context.go
@@ -8,9 +8,8 @@ import (
)
// runContext implements tools.Context for a single agent run, executing
-// both shell commands and git commands via docker exec against the
-// run's container, and binding Forgejo actions to the triggering
-// issue/PR.
+// shell commands via docker exec against the run's container, and
+// binding Forgejo actions to the triggering issue/PR.
type runContext struct {
docker *dockerRuntime
containerID string
@@ -31,17 +30,6 @@ func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
return output, nil
}
-// Git implements tools.Context.Git by running git inside the run's
-// container, in its /project working directory. Remote operations
-// (fetch, pull, push, ...) authenticate via the http.<url>.extraHeader
-// credential configureSandboxGit wrote to the container's system
-// gitconfig, so no per-invocation credential plumbing is needed.
-func (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {
- cmdArgs := append([]string{subcommand}, args...)
-
- return c.Exec(ctx, shellGitCmd(cmdArgs...))
-}
-
func (c *runContext) Forgejo() tools.ForgejoActions {
return c.forgejo
}
diff --git a/internal/agentrun/system.md b/internal/agentrun/system.md
index 1fe763a..7b8c8cb 100644
--- a/internal/agentrun/system.md
+++ b/internal/agentrun/system.md
@@ -17,7 +17,7 @@ triggers you from Forgejo (a Gitea-family forge) issue/PR events.
**Tools**
-- `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,
+- `bash`, `read_file`, `write_file`, `list_files`, `grep_search`,
`move_file`, `remove_file` operate on the project container.
- `comment`, `open_pull_request`, `request_review`, `submit_review`,
`add_label`, `remove_label`, `close_issue`, `reopen_issue`,
@@ -34,9 +34,9 @@ doing it yourself.
**Git**
-You may use normal git commands to manage your working tree. Remote
-(pull, fetch, push, etc.) git operations are authenticated for you and
-simply work β through the `git` tool or plain `git` in `bash`.
+You may use normal git commands in `bash` to manage your working tree.
+Remote (pull, fetch, push, etc.) git operations are authenticated for
+you and simply work.
**Guidelines**
diff --git a/internal/tools/context.go b/internal/tools/context.go
index 30b5dd3..85a0828 100644
--- a/internal/tools/context.go
+++ b/internal/tools/context.go
@@ -13,13 +13,6 @@ type Context interface {
// returns combined stdout+stderr.
Exec(ctx context.Context, command string) (string, error)
- // Git runs a git subcommand against the run's working tree inside
- // the run's container. Remote operations (fetch, pull, push, ...)
- // authenticate via the Forgejo credential the run's setup wrote to
- // the container's system gitconfig, so they work without any
- // per-invocation plumbing.
- Git(ctx context.Context, subcommand string, args ...string) (string, error)
-
// Forgejo returns the actions bound to the issue/PR that triggered
// this run, so tools don't need to be told which repo/issue to act
// on.
diff --git a/internal/tools/git.go b/internal/tools/git.go
deleted file mode 100644
index 5c38591..0000000
--- a/internal/tools/git.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package tools
-
-import (
- "context"
- "fmt"
- "slices"
- "strings"
-
- "github.com/abrander/zoo/internal/llm"
-)
-
-// gitAllowed intentionally excludes remote-mutating subcommands other
-// than push: agents are expected to commit and push their own branch,
-// not touch remotes/config/etc.
-var gitAllowed = []string{
- "add",
- "branch",
- "checkout",
- "commit",
- "diff",
- "fetch",
- "log",
- "ls-remote",
- "pull",
- "push",
- "show",
- "status",
- "rm",
-}
-
-type gitParams struct {
- Subcommand string `json:"subcommand"`
- Args []string `json:"args"`
-}
-
-func init() {
- tool := llm.NewTool(
- "git",
- "Run a git subcommand with optional arguments against the project's working tree")
-
- tool.AddEnumProperty("subcommand", "The git subcommand to run", gitAllowed, true)
- tool.AddStringArrayProperty("args", "Additional arguments to pass to the git subcommand, e.g. ['HEAD~1', 'HEAD']", false)
-
- Register(tool, git)
-}
-
-// git runs subcommand via Context.Git, which executes it inside the
-// run's container, where the run's Forgejo credential is configured
-// for git (see that method's doc comment) β remote operations like
-// push simply work.
-func git(ctx Context, params gitParams) (string, error) {
- if !slices.Contains(gitAllowed, params.Subcommand) {
- return "", fmt.Errorf("git subcommand '%s' is not allowed. Only %s are permitted", params.Subcommand, strings.Join(gitAllowed, ", "))
- }
-
- output, err := ctx.Git(context.Background(), params.Subcommand, params.Args...)
- if err != nil {
- return "", err
- }
-
- if output == "" {
- switch params.Subcommand {
- case "diff":
- return "No changes detected.", nil
-
- case "status":
- return "No status output.", nil
- }
- }
-
- return strings.TrimSpace(output), nil
-}
diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go
index 7ea9b83..4fdc4a9 100644
--- a/internal/tools/tools_test.go
+++ b/internal/tools/tools_test.go
@@ -37,9 +37,6 @@ type fakeContext struct {
output string
err error
fg *fakeForgejoActions
-
- lastGitSubcommand string
- lastGitArgs []string
}
func (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {
@@ -47,12 +44,6 @@ func (f *fakeContext) Exec(ctx context.Context, command string) (string, error)
return f.output, f.err
}
-func (f *fakeContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {
- f.lastGitSubcommand = subcommand
- f.lastGitArgs = args
- return f.output, f.err
-}
-
func (f *fakeContext) Forgejo() ForgejoActions {
return f.fg
}
@@ -88,31 +79,6 @@ func TestReadFileParsesMetaAndContent(t *testing.T) {
}
}
-func TestGitRejectsDisallowedSubcommand(t *testing.T) {
- fc := &fakeContext{}
-
- _, err := git(fc, gitParams{Subcommand: "push-force"})
- if err == nil {
- t.Fatal("expected error for disallowed subcommand")
- }
-}
-
-func TestGitPassesSubcommandAndArgsThrough(t *testing.T) {
- fc := &fakeContext{output: "ok"}
-
- _, err := git(fc, gitParams{Subcommand: "commit", Args: []string{"-m", "fix: handle it's edge case"}})
- if err != nil {
- t.Fatal(err)
- }
-
- if fc.lastGitSubcommand != "commit" {
- t.Fatalf("expected subcommand %q, got %q", "commit", fc.lastGitSubcommand)
- }
- if len(fc.lastGitArgs) != 2 || fc.lastGitArgs[1] != "fix: handle it's edge case" {
- t.Fatalf("expected args passed through unescaped, got %v", fc.lastGitArgs)
- }
-}
-
func TestCommentToolCallsForgejo(t *testing.T) {
fg := &fakeForgejoActions{}
fc := &fakeContext{fg: fg}
{"command": "cd /project && git commit -m \"Remove the git tool and Context.Git per review\n\nNow that git simply works inside the sandbox (credential in the\ncontainer's system gitconfig), the restricted git tool and the\nContext.Git plumbing are redundant: the agent's bash tool already\ngives it full git access, and remote operations authenticate\ntransparently.\n\n- Delete internal/tools/git.go (the git tool)\n- Drop Git() from the tools.Context interface and from runContext\n- Update the system prompt: git is used via bash, no git tool\n- Drop the now-dead git tests from the tools package\n\nZoo's own in-sandbox git setup (configureSandboxGit, cloneAndBranch,\nclonePRHead) is untouched and keeps using runSandboxGit/shellGitCmd.\" && git push origin issue-48-greg"}
[issue-48-greg 7c60789] Remove the git tool and Context.Git per review 5 files changed, 6 insertions(+), 131 deletions(-) delete mode 100644 internal/tools/git.go Enumerating objects: 17, done. Counting objects: 5% (1/17) Counting objects: 11% (2/17) Counting objects: 17% (3/17) Counting objects: 23% (4/17) Counting objects: 29% (5/17) Counting objects: 35% (6/17) Counting objects: 41% (7/17) Counting objects: 47% (8/17) Counting objects: 52% (9/17) Counting objects: 58% (10/17) Counting objects: 64% (11/17) Counting objects: 70% (12/17) Counting objects: 76% (13/17) Counting objects: 82% (14/17) Counting objects: 88% (15/17) Counting objects: 94% (16/17) Counting objects: 100% (17/17) Counting objects: 100% (17/17), done. Delta compression using up to 32 threads Compressing objects: 11% (1/9) Compressing objects: 22% (2/9) Compressing objects: 33% (3/9) Compressing objects: 44% (4/9) Compressing objects: 55% (5/9) Compressing objects: 66% (6/9) Compressing objects: 77% (7/9) Compressing objects: 88% (8/9) Compressing objects: 100% (9/9) Compressing objects: 100% (9/9), done. Writing objects: 11% (1/9) Writing objects: 22% (2/9) Writing objects: 33% (3/9) Writing objects: 44% (4/9) Writing objects: 55% (5/9) Writing objects: 66% (6/9) Writing objects: 77% (7/9) Writing objects: 88% (8/9) Writing objects: 100% (9/9) Writing objects: 100% (9/9), 1.16 KiB | 1.16 MiB/s, done. Total 9 (delta 8), reused 0 (delta 0), pack-reused 0 (from 0) To https://code.stdio.dk/abrander/zoo.git c3d3720..7c60789 issue-48-greg -> issue-48-greg
{"body": "Done β the `git` tool and `Context.Git` are removed (commit `7c60789`).\n\nYou're right that they're redundant now that git simply works inside the sandbox: the agent's `bash` tool already gives it full git access, and remote operations authenticate transparently via the system gitconfig, so the restricted wrapper added no value.\n\nWhat changed:\n\n- **Deleted `internal/tools/git.go`** β the `git` tool (and its `gitAllowed` subcommand allowlist).\n- **`tools.Context`** β dropped the `Git()` method; the interface is now just `Exec` + `Forgejo()`.\n- **`runContext`** β dropped the `Git()` implementation and updated its doc comment.\n- **System prompt** β the tool list no longer mentions `git`, and the Git section now says remote ops \"are authenticated for you and simply work\" via `bash`.\n- **Tests** β removed the two git-tool tests and the `Git` stub from `fakeContext`.\n\nWhat I deliberately kept: `shellQuote`/`shellGitCmd`/`runSandboxGit` in `sandboxgit.go` β those still back zoo's own in-sandbox setup (`configureSandboxGit`, `cloneAndBranch`, `clonePRHead`), which is separate from the agent-facing tool.\n\nVerified: `go build`, `go vet` (incl. `-tags docker`), `gofmt`, and the full unit test suite all pass.\n\nOne note: the PR description still has a line about the `git` tool running in the sandbox β I don't have a way to edit the PR body from here, so you may want to trim that sentence before merging."}
Comment posted
{"command": "cd /project && git status && git log --oneline -3 && git diff origin/main --stat | tail -15"}
On branch issue-48-greg nothing to commit, working tree clean 7c60789 (HEAD -> issue-48-greg) Remove the git tool and Context.Git per review c3d3720 Pass the agent's git credential to the sandbox, clone inside it f083eae (origin/main, origin/HEAD, main) Merge pull request 'Add pr:review event so agents react to PR reviews' (#37) from issue-36-greg into main README.md | 6 + internal/agentrun/clone.go | 81 -------------- internal/agentrun/context.go | 23 +--- internal/agentrun/docker_integration_test.go | 122 +++++++++++++++++++++ internal/agentrun/run.go | 70 ++++++------ internal/agentrun/sandboxgit.go | 158 +++++++++++++++++++++++++++ internal/agentrun/sandboxgit_test.go | 78 +++++++++++++ internal/agentrun/system.md | 7 +- internal/tools/context.go | 11 +- internal/tools/git.go | 72 ------------ internal/tools/tools_test.go | 34 ------ 11 files changed, 407 insertions(+), 255 deletions(-)