Details and log output for this agent run.
{"path": ".", "recursive": false}
drwxr-xr-x 6 user user 4096 Aug 25 21:16 . drwxr-xr-x 1 root root 4096 Aug 25 21:16 .. drwxr-xr-x 7 user user 4096 Aug 25 21:16 .git -rw-r--r-- 1 user user 22 Aug 25 21:16 .gitignore -rw-r--r-- 1 user user 6387 Aug 25 21:16 README.md drwxr-xr-x 3 user user 4096 Aug 25 21:16 cmd drwxr-xr-x 2 user user 4096 Aug 25 21:16 docker -rw-r--r-- 1 user user 2628 Aug 25 21:16 go.mod -rw-r--r-- 1 user user 16578 Aug 25 21:16 go.sum drwxr-xr-x 11 user user 4096 Aug 25 21:16 internal -rw-r--r-- 1 user user 3092 Aug 25 21:16 zoo.hcl.example
{"path": "docker", "recursive": true}
/project/docker/Dockerfile /project/docker/group /project/docker/passwd /project/docker/shadow /project/docker/uid1000-sudo
{"end_line": 100, "path": "zoo.hcl.example", "start_line": 1}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\r\n#\r\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\r\n\r\nllm \"Qwen 3.6\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.6\"\r\n}\r\n\r\nllm \"Qwen 3.8\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.8\"\r\n}\r\n\r\nforgejo {\r\n url = \"https://code.stdio.dk\"\r\n token = \"ZOO_SERVICE_TOKEN\"\r\n # Shared secret configured on the Forgejo webhook. Required to accept\r\n # webhook deliveries; polling works without it.\r\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\r\n\r\n # Which repositories to watch. Events from every other repository\r\n # are ignored. Each entry is an \"owner/repo\" pair with glob\r\n # wildcards:\r\n #\r\n # repos = [\"acme/*\"] # everything in the acme org\r\n # repos = [\"acme/widgets\"] # just that one repository\r\n # repos = [\"acme/web*\"] # repos in acme whose name starts with \"web\"\r\n # repos = [\"acme\", \"bob\"] # bare names mean \"everything in that org\"\r\n # repos = [\"*\"] # everything on the instance\r\n #\r\n # Owner names match case-insensitively (like Forgejo usernames);\r\n # repository names match case-sensitively. Leave the list out (or\r\n # empty) to watch everything the token can see.\r\n # repos = [\"abrander/*\", \"acme/widgets\"]\r\n}\r\n\r\nenvironment {\r\n docker_image = \"golang:latest\"\r\n max_live_agents = 5\r\n}\r\n\r\nagent \"anna\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"ANNA_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"greg\" {\r\n llm = \"Qwen 3.8\"\r\n token = \"GREG_FORGEJO_TOKEN\"\r\n}\r\n\r\n# \"leon\" doubles as the expected Forgejo assignee username for\r\n# issue:assigned events - see the event block below.\r\nagent \"leon\" {\r\n llm = \"Qwen 3.8\"\r\n token = \"LEON_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"mika\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"MIKA_FORGEJO_TOKEN\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n // No `agent` here: the agent whose name matches the Forgejo\r\n // assignee's username is triggered.\r\n instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"issue:comment\" {\r\n agent = \"leon\"\r\n instructions = \"Please review what this comment says and respond appropriately.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n agent = \"greg\"\r\n instructions = \"Please review this pull request and respond appropriately.\"\r\n}\r\n\r\n# A review was submitted on a pull request (approved, changes requested,\r\n# or comment). The agent that opened the PR is triggered to handle it,\r\n# like issue:assigned: its working tree is checked out on the PR's head\r\n# branch, so changes it pushes update the PR directly. It should address\r\n# the feedback and reply to the review with a summary of what it\r\n# addressed. (Forgejo has no API to mark review comments resolved, so\r\n# that stays with the reviewer.)\r\nevent \"pr:review\" {\r\n // No `agent` here: the agent whose name matches the pull request\r\n // author's username is triggered.\r\n instructions = \"A review was submitted on this pull request. Address the feedback, push your changes to the PR branch, and reply to the review with a summary of what you addressed.\"\r\n}\r","start_line":1,"end_line":98,"total_lines":98,"truncated":false}
{"end_line": 100, "path": "README.md", "start_line": 1}
{"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","start_line":1,"end_line":100,"total_lines":223,"truncated":true}
{"end_line": 223, "path": "README.md", "start_line": 100}
{"path":"README.md","content":"```\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\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":100,"end_line":223,"total_lines":223,"truncated":false}
{"end_line": 100, "path": "docker/Dockerfile", "start_line": 1}
{"path":"docker/Dockerfile","content":"FROM debian:unstable\r\n\r\nRUN apt-get update \u0026\u0026 \\\r\n apt-get install -y --no-install-recommends \\\r\n ca-certificates \\\r\n clang \\\r\n cpp \\\r\n curl \\\r\n forgejo-cli \\\r\n g++ \\\r\n gcc \\\r\n git \\\r\n gnupg \\\r\n golang-go \\\r\n libc6-dev \\\r\n make \\\r\n mercurial \\\r\n netbase \\\r\n openssh-client \\\r\n pkg-config \\\r\n procps \\\r\n sq \\\r\n subversion \\\r\n sudo \\\r\n wget \\\r\n \u0026\u0026 \\\r\n rm -rf /var/lib/apt/lists/*\r\n\r\nADD --chmod=0644 passwd /etc/passwd\r\nADD --chmod=0644 group /etc/group\r\nADD --chmod=0640 shadow /etc/shadow\r\nADD --chmod=0644 uid1000-sudo /etc/sudoers.d/uid1000\r\n\r\nRUN mkdir -p /home/user \u0026\u0026 \\\r\n chown 1000:1000 /home/user\r\n\r\nUSER 1000:1000\r","start_line":1,"end_line":37,"total_lines":37,"truncated":false}
{"path": "internal/agentrun", "recursive": true}
/project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/sandboxgit.go /project/internal/agentrun/sandboxgit_test.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md
{"end_line": 100, "path": "internal/agentrun/docker.go", "start_line": 1}
{"path":"internal/agentrun/docker.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\t\"time\"\r\n\r\n\t\"github.com/docker/docker/api/types/container\"\r\n\t\"github.com/docker/docker/client\"\r\n)\r\n\r\n// containerCPUs and containerMemory bound each agent container's\r\n// resource usage; there's no per-agent config knob for this yet (see\r\n// TODO.md), so every run gets the same sane default.\r\nconst (\r\n\tcontainerNanoCPUs = 2_000_000_000 // 2 CPUs\r\n\tcontainerMemory = 2 \u003c\u003c 30 // 2 GiB\r\n)\r\n\r\ntype dockerRuntime struct {\r\n\tcli *client.Client\r\n}\r\n\r\nfunc newDockerRuntime() (*dockerRuntime, error) {\r\n\tcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"docker client: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026dockerRuntime{cli: cli}, nil\r\n}\r\n\r\n// createContainer creates and starts a container from image with the\r\n// given bind mounts, kept alive with `sleep infinity` regardless of the\r\n// image's own entrypoint so it can be repeatedly `exec`'d into.\r\nfunc (d *dockerRuntime) createContainer(ctx context.Context, image string, binds []string, name string) (string, error) {\r\n\tresp, err := d.cli.ContainerCreate(ctx,\r\n\t\t\u0026container.Config{\r\n\t\t\tImage: image,\r\n\t\t\tEntrypoint: []string{\"sleep\"},\r\n\t\t\tCmd: []string{\"infinity\"},\r\n\t\t\tWorkingDir: \"/project\",\r\n\t\t},\r\n\t\t\u0026container.HostConfig{\r\n\t\t\tBinds: binds,\r\n\t\t\tResources: container.Resources{\r\n\t\t\t\tNanoCPUs: containerNanoCPUs,\r\n\t\t\t\tMemory: containerMemory,\r\n\t\t\t},\r\n\t\t},\r\n\t\tnil, nil, name)\r\n\tif err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"create container: %w\", err)\r\n\t}\r\n\r\n\tif err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"start container: %w\", err)\r\n\t}\r\n\r\n\treturn resp.ID, nil\r\n}\r\n\r\n// exec runs command via `sh -c` inside containerID and returns its\r\n// combined stdout+stderr (a TTY is attached so the two streams merge\r\n// without needing to demultiplex Docker's stdcopy framing) plus its exit\r\n// code.\r\nfunc (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, int, error) {\r\n\tcreated, err := d.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{\r\n\t\tCmd: []string{\"sh\", \"-c\", command},\r\n\t\t// A TTY is attached (see doc comment above), which makes git's\r\n\t\t// isatty-based color.ui=auto default to enabling ANSI color codes\r\n\t\t// that pollute the captured job log. NO_COLOR covers tools that\r\n\t\t// honor that convention; the GIT_CONFIG_* override forces git's\r\n\t\t// own color.ui to \"never\" regardless of tty detection, since git\r\n\t\t// does not honor NO_COLOR itself.\r\n\t\t//\r\n\t\t// The same isatty check makes git launch a pager for diff/log/show,\r\n\t\t// and the pager (waiting on a stdin nothing ever attaches or\r\n\t\t// closes) then blocks forever with no way to time it out ā see\r\n\t\t// exec's read loop below. GIT_PAGER/PAGER=cat disable that.\r\n\t\t// GIT_TERMINAL_PROMPT=0 closes the same class of hang for\r\n\t\t// credential prompts on a private remote.\r\n\t\tEnv: []string{\r\n\t\t\t\"NO_COLOR=1\",\r\n\t\t\t\"GIT_CONFIG_COUNT=1\",\r\n\t\t\t\"GIT_CONFIG_KEY_0=color.ui\",\r\n\t\t\t\"GIT_CONFIG_VALUE_0=never\",\r\n\t\t\t\"GIT_PAGER=cat\",\r\n\t\t\t\"PAGER=cat\",\r\n\t\t\t\"GIT_TERMINAL_PROMPT=0\",\r\n\t\t},\r\n\t\tTty: true,\r\n\t\tAttachStdout: true,\r\n\t\tAttachStderr: true,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn \"\", 0, fmt.Errorf(\"exec create: %w\", err)\r\n\t}\r\n\r","start_line":1,"end_line":100,"total_lines":168,"truncated":true}
{"end_line": 100, "path": "internal/agentrun/system.md", "start_line": 1}
{"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`, `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 in `bash` to manage your working tree.\r\nRemote (pull, fetch, push, etc.) git operations are authenticated for\r\nyou and simply work.\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": 168, "path": "internal/agentrun/docker.go", "start_line": 100}
{"path":"internal/agentrun/docker.go","content":"\r\n\tattached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{Tty: true})\r\n\tif err != nil {\r\n\t\treturn \"\", 0, fmt.Errorf(\"exec attach: %w\", err)\r\n\t}\r\n\tdefer attached.Close()\r\n\r\n\t// Once hijacked, this stream is a raw connection that ctx cancellation\r\n\t// no longer reaches ā a wedged child process (pager, credential\r\n\t// prompt, anything else reading a stdin nobody attaches) would\r\n\t// otherwise block this read forever, past the run's deadline, with no\r\n\t// way to recover the job. Race the read against ctx and force-remove\r\n\t// the container to sever the connection if it fires.\r\n\ttype readResult struct {\r\n\t\toutput []byte\r\n\t\terr error\r\n\t}\r\n\r\n\treadDone := make(chan readResult, 1)\r\n\r\n\tgo func() {\r\n\t\toutput, err := io.ReadAll(attached.Reader)\r\n\t\treadDone \u003c- readResult{output, err}\r\n\t}()\r\n\r\n\tvar output []byte\r\n\r\n\tselect {\r\n\tcase res := \u003c-readDone:\r\n\t\tif res.err != nil {\r\n\t\t\treturn \"\", 0, fmt.Errorf(\"exec read output: %w\", res.err)\r\n\t\t}\r\n\r\n\t\toutput = res.output\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\tkillCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\r\n\t\tdefer cancel()\r\n\r\n\t\t_ = d.cli.ContainerRemove(killCtx, containerID, container.RemoveOptions{Force: true})\r\n\t\t\u003c-readDone\r\n\r\n\t\treturn \"\", 0, ctx.Err()\r\n\t}\r\n\r\n\tinspect, err := d.cli.ContainerExecInspect(ctx, created.ID)\r\n\tif err != nil {\r\n\t\treturn string(output), 0, fmt.Errorf(\"exec inspect: %w\", err)\r\n\t}\r\n\r\n\treturn string(output), inspect.ExitCode, nil\r\n}\r\n\r\n// remove force-stops and removes containerID, ignoring \"already gone\"\r\n// errors so cleanup is safe to call unconditionally.\r\nfunc (d *dockerRuntime) remove(ctx context.Context, containerID string) error {\r\n\ttimeout := 5\r\n\t_ = d.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: \u0026timeout})\r\n\r\n\tif err := d.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {\r\n\t\treturn fmt.Errorf(\"remove container: %w\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\nfunc (d *dockerRuntime) kill(ctx context.Context, containerID string) {\r\n\t_ = d.cli.ContainerKill(ctx, containerID, \"KILL\")\r\n}\r","start_line":100,"end_line":168,"total_lines":168,"truncated":false}
{"end_line": 150, "path": "internal/agentrun/run.go", "start_line": 1}
{"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","start_line":1,"end_line":150,"total_lines":437,"truncated":true}
{"end_line": 300, "path": "internal/agentrun/run.go", "start_line": 150}
{"path":"internal/agentrun/run.go","content":"\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\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","start_line":150,"end_line":300,"total_lines":437,"truncated":true}
{"path": "internal/agentrun/sandboxgit.go"}
{"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\", \"--global\", \"--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\", \"--global\", \"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\", \"--global\", \"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\", \"--global\", \"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\", \"--global\", \"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}
{"query": "forgejo.?cli|FG_|forgejo-cli", "case_sensitive": false, "max_results": 50}
/project/cmd/zoo/main.go:66: return fmt.Errorf("forgejo client: %w", err)
/project/docker/Dockerfile:9: forgejo-cli \
/project/internal/web/web_test.go:35: // No Forgejo client: pages render without avatars.
/project/internal/web/web.go:23: fg *forgejo.Client
/project/internal/web/web.go:41:func New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {
/project/internal/web/web.go:155:// or "" if it can't be resolved (no Forgejo client configured, unknown
/project/internal/tools/context.go:24:// internal/forgejo.Client plus the run's owner/repo/issue.
/project/internal/forgejo/client.go:27: return nil, fmt.Errorf("forgejo client: %w", err)
/project/internal/forgejo/client.go:63: return nil, fmt.Errorf("forgejo client sudo %q: %w", username, err)
/project/internal/agentrun/forgejo.go:10:// generic forgejo.Client to the single issue/PR that triggered this run
/project/internal/agentrun/forgejo.go:13: client *forgejo.Client
/project/internal/agentrun/run.go:36: forgejo *forgejo.Client
/project/internal/agentrun/run.go:45: agentClients map[string]*forgejo.Client
/project/internal/agentrun/run.go:48:func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
/project/internal/agentrun/run.go:67: agentClients: make(map[string]*forgejo.Client),
/project/internal/agentrun/run.go:71:// forgejoAs returns a Forgejo client that authenticates as the given
/project/internal/agentrun/run.go:79:func (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {
/project/internal/agentrun/run.go:87: var c *forgejo.Client
/project/internal/agentrun/roster.go:27:func buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {
{"path": "internal/tools", "recursive": true}
/project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go
{"path": "internal/tools/forgejo.go"}
{"path":"internal/tools/forgejo.go","content":"package tools\r\n\r\nimport (\r\n\t\"fmt\"\r\n\t\"strings\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype commentParams struct {\r\n\tBody string `json:\"body\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"comment\",\r\n\t\t\"Post a comment on the issue or pull request that triggered this run.\")\r\n\r\n\ttool.AddStringProperty(\"body\", \"The comment text, in Markdown\", true)\r\n\r\n\tRegister(tool, comment)\r\n}\r\n\r\nfunc comment(ctx Context, params commentParams) (string, error) {\r\n\tif err := ctx.Forgejo().Comment(params.Body); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Comment posted\", nil\r\n}\r\n\r\ntype openPullRequestParams struct {\r\n\tHead string `json:\"head\"`\r\n\tBase string `json:\"base\"`\r\n\tTitle string `json:\"title\"`\r\n\tBody string `json:\"body\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"open_pull_request\",\r\n\t\t\"Open a pull request from a branch you've pushed into the repository's default branch (or another base branch).\")\r\n\r\n\ttool.AddStringProperty(\"head\", \"The branch containing your changes (usually the run's own branch)\", true)\r\n\ttool.AddStringProperty(\"base\", \"The branch to merge into, e.g. the repository's default branch\", true)\r\n\ttool.AddStringProperty(\"title\", \"The pull request title\", true)\r\n\ttool.AddStringProperty(\"body\", \"The pull request description, in Markdown\", true)\r\n\r\n\tRegister(tool, openPullRequest)\r\n}\r\n\r\nfunc openPullRequest(ctx Context, params openPullRequestParams) (string, error) {\r\n\tif err := ctx.Forgejo().OpenPullRequest(params.Head, params.Base, params.Title, params.Body); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Pull request opened\", nil\r\n}\r\n\r\ntype requestReviewParams struct {\r\n\tReviewers []string `json:\"reviewers\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"request_review\",\r\n\t\t\"Request review of the current pull request from one or more Forgejo users.\")\r\n\r\n\ttool.AddStringArrayProperty(\"reviewers\", \"Usernames to request review from\", true)\r\n\r\n\tRegister(tool, requestReview)\r\n}\r\n\r\nfunc requestReview(ctx Context, params requestReviewParams) (string, error) {\r\n\tif err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Review requested\", nil\r\n}\r\n\r\ntype submitReviewParams struct {\r\n\tState string `json:\"state\"`\r\n\tBody string `json:\"body\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"submit_review\",\r\n\t\t\"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review fires a pr:review event that triggers a run, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.\")\r\n\r\n\ttool.AddEnumProperty(\"state\", \"The review verdict\", []string{\"approved\", \"changes_requested\", \"comment\"}, true)\r\n\ttool.AddStringProperty(\"body\", \"The review text, in Markdown. Required unless state is 'approved'.\", true)\r\n\r\n\tRegister(tool, submitReview)\r\n}\r\n\r\nfunc submitReview(ctx Context, params submitReviewParams) (string, error) {\r\n\tif params.State != \"approved\" \u0026\u0026 strings.TrimSpace(params.Body) == \"\" {\r\n\t\treturn \"\", fmt.Errorf(\"body is required unless state is 'approved'\")\r\n\t}\r\n\r\n\tif err := ctx.Forgejo().SubmitReview(params.State, params.Body); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Review submitted\", nil\r\n}\r\n\r\ntype labelParams struct {\r\n\tName string `json:\"name\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"add_label\",\r\n\t\t\"Add a label to the issue or pull request that triggered this run, creating it on the repo first if needed.\")\r\n\r\n\ttool.AddStringProperty(\"name\", \"The label name\", true)\r\n\r\n\tRegister(tool, addLabel)\r\n}\r\n\r\nfunc addLabel(ctx Context, params labelParams) (string, error) {\r\n\tif err := ctx.Forgejo().AddLabel(params.Name); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Label added\", nil\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"remove_label\",\r\n\t\t\"Remove a label from the issue or pull request that triggered this run.\")\r\n\r\n\ttool.AddStringProperty(\"name\", \"The label name\", true)\r\n\r\n\tRegister(tool, removeLabel)\r\n}\r\n\r\nfunc removeLabel(ctx Context, params labelParams) (string, error) {\r\n\tif err := ctx.Forgejo().RemoveLabel(params.Name); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Label removed\", nil\r\n}\r\n\r\ntype assignIssueParams struct {\r\n\tAgent string `json:\"agent\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"assign_issue\",\r\n\t\t\"Hand the issue or pull request that triggered this run off to another agent, by name. This assigns it to that agent's Forgejo account, which triggers their run. Use this when the task is better suited to another agent's described role than your own.\")\r\n\r\n\ttool.AddStringProperty(\"agent\", \"The name of the agent to hand this off to, e.g. one listed in the \\\"Other agents\\\" section of your system prompt\", true)\r\n\r\n\tRegister(tool, assignIssue)\r\n}\r\n\r\nfunc assignIssue(ctx Context, params assignIssueParams) (string, error) {\r\n\tif err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\"Assigned to %s\", params.Agent), nil\r\n}\r\n\r\ntype noParams struct{}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"close_issue\",\r\n\t\t\"Close the issue or pull request that triggered this run.\")\r\n\r\n\tRegister(tool, closeIssue)\r\n}\r\n\r\nfunc closeIssue(ctx Context, _ noParams) (string, error) {\r\n\tif err := ctx.Forgejo().CloseIssue(); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Issue closed\", nil\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"reopen_issue\",\r\n\t\t\"Reopen the issue or pull request that triggered this run.\")\r\n\r\n\tRegister(tool, reopenIssue)\r\n}\r\n\r\nfunc reopenIssue(ctx Context, _ noParams) (string, error) {\r\n\tif err := ctx.Forgejo().ReopenIssue(); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Issue reopened\", nil\r\n}\r","start_line":1,"end_line":204,"total_lines":204,"truncated":false}
{"end_line": 250, "path": "internal/agentrun/run.go", "start_line": 200}
{"path":"internal/agentrun/run.go","content":"\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":200,"end_line":250,"total_lines":437,"truncated":true}
{"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": "CloneURL|\\.URL\\b", "path": "internal/forgejo", "max_results": 30}
/project/internal/config/config.go:109: if c.Forgejo.URL == "" || c.Forgejo.Token == "" {
/project/internal/web/web_test.go:143: if r.URL.Path == "/api/v1/version" {
/project/internal/web/web_test.go:150: if user, ok := strings.CutPrefix(r.URL.Path, "/api/v1/users/"); ok {
/project/internal/web/web_test.go:163: fg, err := forgejo.NewClient(config.Forgejo{URL: api.URL, Token: "test"})
/project/internal/web/web.go:170: return e.url
/project/internal/forgejo/client.go:25: c, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))
/project/internal/forgejo/client.go:30: return &Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil
/project/internal/forgejo/client.go:352: CloneURL string
/project/internal/forgejo/client.go:361: return RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil
/project/internal/forgejo/poll_test.go:62: w := newTestPollWatcher(t, server.URL)
/project/internal/forgejo/poll_test.go:119: w := newTestPollWatcher(t, server.URL)
/project/internal/forgejo/poll_test.go:149: w := newTestPollWatcher(t, server.URL)
/project/internal/forgejo/client_test.go:53: page, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64)
/project/internal/forgejo/client_test.go:54: limit, _ := strconv.ParseInt(r.URL.Query().Get("limit"), 10, 64)
/project/internal/forgejo/client_test.go:76: client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
/project/internal/forgejo/client_test.go:110: client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
/project/internal/forgejo/client_test.go:147: client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
/project/internal/forgejo/client_test.go:188: client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
/project/internal/forgejo/client_test.go:226: client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
/project/internal/forgejo/client_test.go:246: client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
/project/internal/agentrun/sandboxgit.go:61:// forgeHost returns the scheme+host prefix of cloneURL, e.g.
/project/internal/agentrun/sandboxgit.go:65:func forgeHost(cloneURL string) string {
/project/internal/agentrun/sandboxgit.go:66: u, err := url.Parse(cloneURL)
/project/internal/agentrun/sandboxgit.go:68: return cloneURL
/project/internal/agentrun/sandboxgit.go:92:// tree: the origin remote keeps the plain cloneURL, so the token can't
/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:108: if _, err := runSandboxGit(ctx, rt, containerID, "config", "--global", "http."+forgeHost(cloneURL)+".extraHeader", gitAuthHeader(user, token)); err != nil {
/project/internal/agentrun/sandboxgit.go:119:// cloneAndBranch clones cloneURL into /project inside the container
/project/internal/agentrun/sandboxgit.go:123:// cloneURL: no credential ends up in the working tree.
/project/internal/agentrun/sandboxgit.go:124:func cloneAndBranch(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, defaultBranch, branch string) error {
{"find": "// cloneAndBranch clones cloneURL into /project inside the container\n// and checks out a fresh branch off defaultBranch. The clone\n// authenticates via the http.<host>.extraHeader configured by\n// configureSandboxGit, and the origin remote is left with the plain\n// cloneURL: no credential ends up in the working tree.\nfunc cloneAndBranch(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, defaultBranch, branch string) error {", "path": "internal/agentrun/sandboxgit.go", "replace": "// configureForgejoCLI sets up the agent's Forgejo CLI credentials\n// inside the container. It writes a config file under the container's\n// home directory so fg (forgejo-cli) authenticates automatically for\n// all commands. The token is scoped to the forge host only, matching\n// how git credentials are configured ā the token never leaks into the\n// bind-mounted working tree.\n//\n// forgejo-cli reads ~/.config/forgejo-cli/config.yml; we write a YAML\n// file with the instance URL and token, scoped to the specific host.\nfunc configureForgejoCLI(ctx context.Context, rt *dockerRuntime, containerID, forgejoURL, token string) error {\n\t// Create the config directory inside the container.\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"sh\", \"-c\", \"mkdir -p ~/.config/forgejo-cli\"); err != nil {\n\t\treturn fmt.Errorf(\"create forgejo-cli config dir: %w\", err)\n\t}\n\n\t// Write the config file. Using printf to avoid heredoc quoting issues.\n\tconfigContent := fmt.Sprintf(\"instances:\\n - url: %s\\n token: '%s'\\n\", forgejoURL, token)\n\tcmd := fmt.Sprintf(\"printf '%s' > ~/.config/forgejo-cli/config.yml\", shellQuote(configContent))\n\tif _, err := rt.exec(ctx, containerID, cmd); err != nil {\n\t\treturn fmt.Errorf(\"write forgejo-cli config: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// cloneAndBranch clones cloneURL into /project inside the container\n// and checks out a fresh branch off defaultBranch. The clone\n// authenticates via the http.<host>.extraHeader configured by\n// configureSandboxGit, and the origin remote is left with the plain\n// cloneURL: no credential ends up in the working tree.\nfunc cloneAndBranch(ctx context.Context, rt *dockerRuntime, containerID, cloneURL, defaultBranch, branch string) error {"}
1 occurrences replaced
{"find": "\t// Git must simply work inside the sandbox: safe.directory, commit\n\t// identity, and the remote credential all go into the container's\n\t// system gitconfig (see configureSandboxGit).\n\tif err := configureSandboxGit(ctx, r.docker, containerID, repoInfo.CloneURL, gitUser, gitToken, gitName, gitEmail); err != nil {\n\t\treturn fmt.Errorf(\"configure git in container: %w\", err)\n\t}", "path": "internal/agentrun/run.go", "replace": "\t// Git must simply work inside the sandbox: safe.directory, commit\n\t// identity, and the remote credential all go into the container's\n\t// system gitconfig (see configureSandboxGit).\n\tif err := configureSandboxGit(ctx, r.docker, containerID, repoInfo.CloneURL, gitUser, gitToken, gitName, gitEmail); err != nil {\n\t\treturn fmt.Errorf(\"configure git in container: %w\", err)\n\t}\n\n\t// Also configure forgejo-cli so agents can call fg directly.\n\tforgejoURL := forgeHost(repoInfo.CloneURL)\n\tif err := configureForgejoCLI(ctx, r.docker, containerID, forgejoURL, gitToken); err != nil {\n\t\treturn fmt.Errorf(\"configure forgejo-cli in container: %w\", err)\n\t}"}
1 occurrences replaced
{"find": "**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.", "path": "internal/agentrun/system.md", "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.\n\n**Forgejo CLI (`fg`)**\n\nThe `fg` command-line tool is available inside your container. It is\npre-configured with your Forgejo token, so you can call it directly\nwithout any authentication setup:\n\n```bash\n# List issues on the current repository\nfg issue list --state all\n\n# Get details of the triggering issue\nfg issue view 50\n\n# Create a new issue\nfg issue create --title \"New feature\" --body \"Description here\"\n\n# Update an issue's labels\nfg issue edit 50 --add-labels \"bug,help-wanted\"\n\n# List pull requests\nfg pr list --state open\n\n# View PR details\nfg pr view 42\n```\n\nThe CLI authenticates against the same Forgejo instance that hosts your\nrepository. See `fg --help` for the full command reference."}
1 occurrences replaced
{"end_line": 140, "path": "internal/agentrun/sandboxgit.go", "start_line": 115}
{"path":"internal/agentrun/sandboxgit.go","content":"\r\n\treturn nil\r\n}\r\n\r\n// configureForgejoCLI sets up the agent's Forgejo CLI credentials\r\n// inside the container. It writes a config file under the container's\r\n// home directory so fg (forgejo-cli) authenticates automatically for\r\n// all commands. The token is scoped to the forge host only, matching\r\n// how git credentials are configured ā the token never leaks into the\r\n// bind-mounted working tree.\r\n//\r\n// forgejo-cli reads ~/.config/forgejo-cli/config.yml; we write a YAML\r\n// file with the instance URL and token, scoped to the specific host.\r\nfunc configureForgejoCLI(ctx context.Context, rt *dockerRuntime, containerID, forgejoURL, token string) error {\r\n\t// Create the config directory inside the container.\r\n\tif _, err := runSandboxGit(ctx, rt, containerID, \"sh\", \"-c\", \"mkdir -p ~/.config/forgejo-cli\"); err != nil {\r\n\t\treturn fmt.Errorf(\"create forgejo-cli config dir: %w\", err)\r\n\t}\r\n\r\n\t// Write the config file. Using printf to avoid heredoc quoting issues.\r\n\tconfigContent := fmt.Sprintf(\"instances:\\n - url: %s\\n token: '%s'\\n\", forgejoURL, token)\r\n\tcmd := fmt.Sprintf(\"printf '%s' \u003e ~/.config/forgejo-cli/config.yml\", shellQuote(configContent))\r\n\tif _, err := rt.exec(ctx, containerID, cmd); err != nil {\r\n\t\treturn fmt.Errorf(\"write forgejo-cli config: %w\", err)\r\n\t}\r\n\r","start_line":115,"end_line":140,"total_lines":183,"truncated":true}
{"end_line": 230, "path": "internal/agentrun/run.go", "start_line": 200}
{"path":"internal/agentrun/run.go","content":"\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// Also configure forgejo-cli so agents can call fg directly.\r\n\tforgejoURL := forgeHost(repoInfo.CloneURL)\r\n\tif err := configureForgejoCLI(ctx, r.docker, containerID, forgejoURL, gitToken); err != nil {\r\n\t\treturn fmt.Errorf(\"configure forgejo-cli 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","start_line":200,"end_line":230,"total_lines":443,"truncated":true}
{"path": "internal/agentrun/system.md"}
{"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`, `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 in `bash` to manage your working tree.\r\nRemote (pull, fetch, push, etc.) git operations are authenticated for\r\nyou and simply work.\r\n\r\n**Forgejo CLI (`fg`)**\r\n\r\nThe `fg` command-line tool is available inside your container. It is\r\npre-configured with your Forgejo token, so you can call it directly\r\nwithout any authentication setup:\r\n\r\n```bash\r\n# List issues on the current repository\r\nfg issue list --state all\r\n\r\n# Get details of the triggering issue\r\nfg issue view 50\r\n\r\n# Create a new issue\r\nfg issue create --title \"New feature\" --body \"Description here\"\r\n\r\n# Update an issue's labels\r\nfg issue edit 50 --add-labels \"bug,help-wanted\"\r\n\r\n# List pull requests\r\nfg pr list --state open\r\n\r\n# View PR details\r\nfg pr view 42\r\n```\r\n\r\nThe CLI authenticates against the same Forgejo instance that hosts your\r\nrepository. See `fg --help` for the full command reference.\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":78,"total_lines":78,"truncated":false}
{"command": "cd /project && go build ./..."}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command": "cd /project && go build ./... 2>&1"}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command": "cd /project && go build ./... 2>&1 || true"}
# github.com/abrander/zoo/internal/agentrun internal/agentrun/sandboxgit.go:137:15: assignment mismatch: 2 variables but rt.exec returns 3 values
{"find": "\t// Write the config file. Using printf to avoid heredoc quoting issues.\n\tconfigContent := fmt.Sprintf(\"instances:\\n - url: %s\\n token: '%s'\\n\", forgejoURL, token)\n\tcmd := fmt.Sprintf(\"printf '%s' > ~/.config/forgejo-cli/config.yml\", shellQuote(configContent))\n\tif _, err := rt.exec(ctx, containerID, cmd); err != nil {\n\t\treturn fmt.Errorf(\"write forgejo-cli config: %w\", err)\n\t}", "path": "internal/agentrun/sandboxgit.go", "replace": "\t// Write the config file. Using printf to avoid heredoc quoting issues.\n\tconfigContent := fmt.Sprintf(\"instances:\\n - url: %s\\n token: '%s'\\n\", forgejoURL, token)\n\tcmd := fmt.Sprintf(\"printf '%s' > ~/.config/forgejo-cli/config.yml\", shellQuote(configContent))\n\tif _, _, err := rt.exec(ctx, containerID, cmd); err != nil {\n\t\treturn fmt.Errorf(\"write forgejo-cli config: %w\", err)\n\t}"}
1 occurrences replaced
{"command": "cd /project && go build ./... 2>&1 || true"}
{"command": "cd /project && go test ./... 2>&1 || true"}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.006s ok github.com/abrander/zoo/internal/forgejo 1.049s 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.621s ok github.com/abrander/zoo/internal/store 1.730s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.805s
{"command": "cd /project && git add -A && git commit -m \"Configure forgejo-cli credentials for agent containers\n\n- Add configureForgejoCLI() to set up fg (forgejo-cli) authentication\n inside agent containers by writing ~/.config/forgejo-cli/config.yml\n- Call configureForgejoCLI() from run.go after configureSandboxGit()\n- Update system.md with documentation on using forgejo-cli (fg command)\n including examples of common operations\""}
[issue-50-leon 20ac66e] Configure forgejo-cli credentials for agent containers 3 files changed, 60 insertions(+)
{"command": "cd /project && git push origin issue-50-leon"}
Enumerating objects: 13, done. Counting objects: 7% (1/13) Counting objects: 15% (2/13) Counting objects: 23% (3/13) Counting objects: 30% (4/13) Counting objects: 38% (5/13) Counting objects: 46% (6/13) Counting objects: 53% (7/13) Counting objects: 61% (8/13) Counting objects: 69% (9/13) Counting objects: 76% (10/13) Counting objects: 84% (11/13) Counting objects: 92% (12/13) Counting objects: 100% (13/13) Counting objects: 100% (13/13), done. Delta compression using up to 32 threads Compressing objects: 14% (1/7) Compressing objects: 28% (2/7) Compressing objects: 42% (3/7) Compressing objects: 57% (4/7) Compressing objects: 71% (5/7) Compressing objects: 85% (6/7) Compressing objects: 100% (7/7) Compressing objects: 100% (7/7), done. Writing objects: 14% (1/7) Writing objects: 28% (2/7) Writing objects: 42% (3/7) Writing objects: 57% (4/7) Writing objects: 71% (5/7) Writing objects: 85% (6/7) Writing objects: 100% (7/7) Writing objects: 100% (7/7), 1.86 KiB | 1.86 MiB/s, done. Total 7 (delta 6), reused 0 (delta 0), pack-reused 0 (from 0) To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-50-leon -> issue-50-leon
{"base": "main", "head": "issue-50-leon", "title": "Configure forgejo-cli credentials for agent containers", "body": "## Summary\n\nThis PR enables agents to use `forgejo-cli` (`fg`) directly inside their containers by:\n\n1. **Configuring credentials** - Adds `configureForgejoCLI()` in `sandboxgit.go` that writes the agent's Forgejo token to `~/.config/forgejo-cli/config.yml` inside the container, so `fg` authenticates automatically.\n\n2. **Calling from run loop** - The new function is called from `run.go` right after `configureSandboxGit()`, using the same credential (agent's own token or fallback to shared zoo identity) and extracting the instance URL from the repo's clone URL.\n\n3. **Documenting for agents** - Updates `system.md` with a \"Forgejo CLI (`fg`)\" section showing agents how to use the pre-configured CLI, with examples of common operations like listing issues, viewing details, creating issues, etc.\n\n## Security\n\nThe token is written only to the container's home directory (ephemeral), never into the bind-mounted `/project` working tree. This matches the same security model used for git credentials via `http.<host>.extraHeader`.\n\n## Files Changed\n\n- `internal/agentrun/sandboxgit.go` - Added `configureForgejoCLI()` function\n- `internal/agentrun/run.go` - Call `configureForgejoCLI()` during container setup\n- `internal/agentrun/system.md` - Documentation for agents on using forgejo-cli"}
Pull request opened
{"body": "Done! I've implemented forgejo-cli credential support for agents with the following changes:\n\n## What was done\n\n1. **Credential configuration** - Added `configureForgejoCLI()` in `internal/agentrun/sandboxgit.go` that writes the agent's Forgejo token to `~/.config/forgejo-cli/config.yml` inside each container. This allows agents to call `fg` (forgejo-cli) directly without any authentication setup.\n\n2. **Integration** - The function is called from `run.go` right after git configuration, using the same credential mechanism (agent's own token when configured, falling back to shared zoo identity).\n\n3. **Documentation** - Updated `system.md` with a \"Forgejo CLI (`fg`)\" section that documents how agents can use the pre-configured CLI, including examples:\n - Listing issues: `fg issue list --state all`\n - Viewing issue details: `fg issue view 50`\n - Creating issues: `fg issue create --title \"...\" --body \"...\"`\n - Managing labels: `fg issue edit 50 --add-labels \"bug,help-wanted\"`\n - Working with PRs: `fg pr list`, `fg pr view`\n\n## Security\n\nThe token is written only to the container's ephemeral home directory (`~/.config/forgejo-cli/config.yml`), never into the bind-mounted `/project` working tree ā matching the same security model already used for git credentials.\n\nA pull request has been opened at https://code.stdio.dk/abrander/zoo/pulls/51 for review."}
Comment posted