Details and log output for this agent run.
{"path": ".", "recursive": false}
drwxr-xr-x 6 user user 4096 Aug 25 21:24 . drwxr-xr-x 1 root root 4096 Aug 25 21:24 .. drwxr-xr-x 7 user user 4096 Aug 25 21:24 .git -rw-r--r-- 1 user user 22 Aug 25 21:24 .gitignore -rw-r--r-- 1 user user 6387 Aug 25 21:24 README.md drwxr-xr-x 3 user user 4096 Aug 25 21:24 cmd drwxr-xr-x 2 user user 4096 Aug 25 21:24 docker -rw-r--r-- 1 user user 2628 Aug 25 21:24 go.mod -rw-r--r-- 1 user user 16578 Aug 25 21:24 go.sum drwxr-xr-x 11 user user 4096 Aug 25 21:24 internal -rw-r--r-- 1 user user 3092 Aug 25 21:24 zoo.hcl.example
{"command": "cd /project && git log --oneline -5 && git status"}
10c3dce (HEAD -> issue-52-greg, origin/main, main) Better Docker/git inter. 315f993 Add docker image build. 5717258 Merge pull request 'forgejo: repository allowlist with wildcard patterns' (#40) from issue-39-greg into main 823ae62 fix(forgejo): resolve test helper collision after merging main bccf930 Merge branch 'main' into issue-39-greg On branch issue-52-greg nothing to commit, working tree clean
{"query": "pty|PTY", "context_after": 3, "context_before": 3}
/project/.git/hooks/push-to-checkout.sample-59-
/project/.git/hooks/push-to-checkout.sample-60-# This is a rough translation of:
/project/.git/hooks/push-to-checkout.sample-61-#
/project/.git/hooks/push-to-checkout.sample:62:# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
/project/.git/hooks/push-to-checkout.sample-63-if git cat-file -e HEAD 2>/dev/null
/project/.git/hooks/push-to-checkout.sample-64-then
/project/.git/hooks/push-to-checkout.sample-65- head=HEAD
--
/project/.git/hooks/pre-rebase.sample-9-# The hook is called with the following parameters:
/project/.git/hooks/pre-rebase.sample-10-#
/project/.git/hooks/pre-rebase.sample-11-# $1 -- the upstream the series was forked from.
/project/.git/hooks/pre-rebase.sample:12:# $2 -- the branch being rebased (or empty when rebasing the current branch).
/project/.git/hooks/pre-rebase.sample-13-#
/project/.git/hooks/pre-rebase.sample-14-# This sample shows how to prevent topic branches that are already
/project/.git/hooks/pre-rebase.sample-15-# merged to 'next' branch from getting rebased, because allowing it
--
/project/.git/hooks/pre-rebase.sample-164-
/project/.git/hooks/pre-rebase.sample-165- git rev-list master..topic
/project/.git/hooks/pre-rebase.sample-166-
/project/.git/hooks/pre-rebase.sample:167: if this is empty, it is fully merged to "master".
/project/.git/hooks/pre-rebase.sample-168-
/project/.git/hooks/pre-rebase.sample-169-DOC_END
--
/project/.git/hooks/pre-commit.sample-11-then
/project/.git/hooks/pre-commit.sample-12- against=HEAD
/project/.git/hooks/pre-commit.sample-13-else
/project/.git/hooks/pre-commit.sample:14: # Initial commit: diff against an empty tree object
/project/.git/hooks/pre-commit.sample-15- against=$(git hash-object -t tree /dev/null)
/project/.git/hooks/pre-commit.sample-16-fi
/project/.git/hooks/pre-commit.sample-17-
--
/project/internal/config/config.go-57-
/project/internal/config/config.go-58- // Repos is the allowlist of repository patterns to watch, e.g.
/project/internal/config/config.go-59- // ["acme/*", "acme/widgets"]. Patterns are "owner/repo" pairs with
/project/internal/config/config.go:60: // glob wildcards; "*" watches everything on the instance. An empty
/project/internal/config/config.go-61- // list keeps the historical behavior of watching every repository
/project/internal/config/config.go-62- // the token can see.
/project/internal/config/config.go-63- Repos []string `hcl:"repos,optional"`
--
/project/internal/config/config.go-167-}
/project/internal/config/config.go-168-
/project/internal/config/config.go-169-// validRepoPattern checks the shape of a forgejo `repos` entry. It must
/project/internal/config/config.go:170:// be a non-empty "owner/repo" pair (or a bare owner name, or "*") β at
/project/internal/config/config.go-171-// most one slash, none at either end. Glob syntax within the segments
/project/internal/config/config.go-172-// is validated later by forgejo.NewRepoFilter.
/project/internal/config/config.go-173-func validRepoPattern(p string) error {
/project/internal/config/config.go-174- if p == "" {
/project/internal/config/config.go:175: return fmt.Errorf("repos: pattern must not be empty")
/project/internal/config/config.go-176- }
/project/internal/config/config.go-177-
/project/internal/config/config.go-178- if strings.Count(p, "/") > 1 {
--
/project/internal/config/config_test.go-207- }
/project/internal/config/config_test.go-208-}
/project/internal/config/config_test.go-209-
/project/internal/config/config_test.go:210:func TestValidateReposEmptyList(t *testing.T) {
/project/internal/config/config_test.go-211- cfg, err := load(t, fmt.Sprintf(reposConfigTemplate, `[]`))
/project/internal/config/config_test.go-212- if err != nil {
/project/internal/config/config_test.go-213- t.Fatalf("unexpected error: %v", err)
/project/internal/config/config_test.go-214- }
/project/internal/config/config_test.go-215-
/project/internal/config/config_test.go-216- if len(cfg.Forgejo.Repos) != 0 {
/project/internal/config/config_test.go:217: t.Fatalf("expected empty repos, got %q", cfg.Forgejo.Repos)
/project/internal/config/config_test.go-218- }
/project/internal/config/config_test.go-219-}
/project/internal/config/config_test.go-220-
--
/project/internal/web/web_test.go-100- rr = httptest.NewRecorder()
/project/internal/web/web_test.go-101- s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs/job-2/events", nil))
/project/internal/web/web_test.go-102- if rr.Code != http.StatusOK || rr.Body.Len() != 0 {
/project/internal/web/web_test.go:103: t.Fatalf("expected empty response for a non-live job, got %d %q", rr.Code, rr.Body.String())
/project/internal/web/web_test.go-104- }
/project/internal/web/web_test.go-105-}
/project/internal/web/web_test.go-106-
--
/project/internal/livelog/hub.go-22-
/project/internal/livelog/hub.go-23-type Event struct {
/project/internal/livelog/hub.go-24- Type Type `json:"type"`
/project/internal/livelog/hub.go:25: Text string `json:"text,omitempty"`
/project/internal/livelog/hub.go:26: Name string `json:"name,omitempty"`
/project/internal/livelog/hub.go:27: Arguments string `json:"arguments,omitempty"`
/project/internal/livelog/hub.go:28: Result string `json:"result,omitempty"`
/project/internal/livelog/hub.go:29: Error bool `json:"error,omitempty"`
/project/internal/livelog/hub.go:30: Status string `json:"status,omitempty"`
/project/internal/livelog/hub.go-31-}
/project/internal/livelog/hub.go-32-
/project/internal/livelog/hub.go-33-// subChanBuffer bounds how many events a slow subscriber can lag behind
--
/project/internal/livelog/hub_test.go-57- defer cancel()
/project/internal/livelog/hub_test.go-58-
/project/internal/livelog/hub_test.go-59- if len(buffered) != 0 {
/project/internal/livelog/hub_test.go:60: t.Fatalf("expected empty buffer after checkpoint, got %d events", len(buffered))
/project/internal/livelog/hub_test.go-61- }
/project/internal/livelog/hub_test.go-62-}
/project/internal/livelog/hub_test.go-63-
--
/project/internal/livelog/hub_test.go-84- }
/project/internal/livelog/hub_test.go-85-}
/project/internal/livelog/hub_test.go-86-
/project/internal/livelog/hub_test.go:87:func TestSubscribeAfterCloseGetsEmptyBuffer(t *testing.T) {
{"path": "internal", "recursive": true}
/project/internal/agentrun /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 /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/poll_test.go /project/internal/forgejo/repo_filter.go /project/internal/forgejo/repo_filter_test.go /project/internal/forgejo/watcher.go /project/internal/forgejo/watcher_test.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /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 /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go
{"path": "internal/tools/bash.go", "start_line": 1, "end_line": 200}
{"path":"internal/tools/bash.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\nconst maxTimeout = 300\r\n\r\ntype bashParams struct {\r\n\tCommand string `json:\"command\"`\r\n\tTimeout int `json:\"timeout,omitempty\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"bash\",\r\n\t\t\"Execute a shell command in the project container. Use this to run shell commands and scripts.\")\r\n\r\n\ttool.AddStringProperty(\"command\", \"The shell command to execute\", true)\r\n\ttool.AddIntegerProperty(\"timeout\", \"Optional timeout in seconds. Must not exceed 300 seconds.\", false)\r\n\r\n\tRegister(tool, bash)\r\n}\r\n\r\nfunc bash(ctx Context, params bashParams) (string, error) {\r\n\tif params.Timeout \u003c 0 {\r\n\t\treturn \"\", fmt.Errorf(\"timeout must not be negative\")\r\n\t}\r\n\r\n\tif params.Timeout \u003e maxTimeout {\r\n\t\treturn \"\", fmt.Errorf(\"timeout exceeds maximum of %d seconds\", maxTimeout)\r\n\t}\r\n\r\n\tvar execCtx context.Context\r\n\tvar cancel context.CancelFunc\r\n\r\n\tif params.Timeout \u003e 0 {\r\n\t\texecCtx, cancel = context.WithTimeout(context.Background(), time.Duration(params.Timeout)*time.Second)\r\n\t} else {\r\n\t\texecCtx, cancel = context.WithCancel(context.Background())\r\n\t}\r\n\tdefer cancel()\r\n\r\n\toutput, err := ctx.Exec(execCtx, params.Command)\r\n\tif err != nil {\r\n\t\tif execCtx.Err() == context.DeadlineExceeded {\r\n\t\t\treturn \"\", fmt.Errorf(\"command timed out after %d seconds\", params.Timeout)\r\n\t\t}\r\n\r\n\t\treturn \"\", fmt.Errorf(\"command failed: %w\", err)\r\n\t}\r\n\r\n\treturn output, nil\r\n}\r","start_line":1,"end_line":58,"total_lines":58,"truncated":false}
{"path": "internal/tools/shell.go", "start_line": 1, "end_line": 200}
{"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\n\t\tshellQuote(p), shellQuote(encoded), shellQuote(p))\r\n\r\n\t_, err := ctx.Exec(context.Background(), cmd)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"write %q: %w\", path, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":1,"end_line":69,"total_lines":69,"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// 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":43,"total_lines":43,"truncated":false}
{"path": "internal/agentrun/docker.go", "start_line": 1, "end_line": 300}
{"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\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":1,"end_line":168,"total_lines":168,"truncated":false}
{"path": "internal/agentrun/run.go", "start_line": 1, "end_line": 300}
{"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\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":1,"end_line":300,"total_lines":437,"truncated":true}
{"end_line": 437, "path": "internal/agentrun/run.go", "start_line": 300}
{"path":"internal/agentrun/run.go","content":"\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":300,"end_line":437,"total_lines":437,"truncated":false}
{"query": "func \\(c \\*runContext\\) Exec|runContext", "glob": "*.go", "context_after": 15}
/project/internal/agentrun/context.go:10:// runContext implements tools.Context for a single agent run, executing
/project/internal/agentrun/context.go-11-// shell commands via docker exec against the run's container, and
/project/internal/agentrun/context.go-12-// binding Forgejo actions to the triggering issue/PR.
/project/internal/agentrun/context.go:13:type runContext struct {
/project/internal/agentrun/context.go-14- docker *dockerRuntime
/project/internal/agentrun/context.go-15- containerID string
/project/internal/agentrun/context.go-16- forgejo tools.ForgejoActions
/project/internal/agentrun/context.go-17-}
/project/internal/agentrun/context.go-18-
/project/internal/agentrun/context.go:19:func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
/project/internal/agentrun/context.go-20- output, exitCode, err := c.docker.exec(ctx, c.containerID, command)
/project/internal/agentrun/context.go-21-
/project/internal/agentrun/context.go-22- if err != nil {
/project/internal/agentrun/context.go-23- return output, err
/project/internal/agentrun/context.go-24- }
/project/internal/agentrun/context.go-25-
/project/internal/agentrun/context.go-26- if exitCode != 0 {
/project/internal/agentrun/context.go-27- return output, fmt.Errorf("command exited with status %d", exitCode)
/project/internal/agentrun/context.go-28- }
/project/internal/agentrun/context.go-29-
/project/internal/agentrun/context.go-30- return output, nil
/project/internal/agentrun/context.go-31-}
/project/internal/agentrun/context.go-32-
/project/internal/agentrun/context.go:33:func (c *runContext) Forgejo() tools.ForgejoActions {
/project/internal/agentrun/context.go-34- return c.forgejo
/project/internal/agentrun/context.go-35-}
--
/project/internal/agentrun/run.go:235: runCtx := &runContext{
/project/internal/agentrun/run.go-236- docker: r.docker,
/project/internal/agentrun/run.go-237- containerID: containerID,
/project/internal/agentrun/run.go-238- forgejo: &runForgejoActions{
/project/internal/agentrun/run.go-239- client: r.forgejoAs(agent.Name, agent.Token),
/project/internal/agentrun/run.go-240- owner: ev.Owner,
/project/internal/agentrun/run.go-241- repo: ev.Repo,
/project/internal/agentrun/run.go-242- index: ev.Index,
/project/internal/agentrun/run.go-243- logger: logger,
/project/internal/agentrun/run.go-244- },
/project/internal/agentrun/run.go-245- }
/project/internal/agentrun/run.go-246-
/project/internal/agentrun/run.go-247- llmClient := llm.NewClient(llmCfg)
/project/internal/agentrun/run.go-248-
/project/internal/agentrun/run.go-249- systemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)
/project/internal/agentrun/run.go-250-
{"context_after": 4, "context_before": 2, "glob": "*.go", "query": "\\.Exec\\("}
/project/internal/tools/grep_search.go-65- cmd := strings.Join(args, " ") + fmt.Sprintf(" | head -n %d", maxResults)
/project/internal/tools/grep_search.go-66-
/project/internal/tools/grep_search.go:67: output, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/grep_search.go-68- if err != nil {
/project/internal/tools/grep_search.go-69- // grep exits non-zero (with empty output) when there are no
/project/internal/tools/grep_search.go-70- // matches; that's a normal result, not a tool failure.
/project/internal/tools/grep_search.go-71- if strings.TrimSpace(output) == "" {
--
/project/internal/tools/remove_file.go-25- p := resolvePath(params.Path)
/project/internal/tools/remove_file.go-26-
/project/internal/tools/remove_file.go:27: if _, err := ctx.Exec(context.Background(), "rm -f "+shellQuote(p)); err != nil {
/project/internal/tools/remove_file.go-28- return "", fmt.Errorf("remove %q: %w", params.Path, err)
/project/internal/tools/remove_file.go-29- }
/project/internal/tools/remove_file.go-30-
/project/internal/tools/remove_file.go-31- return "File removed successfully", nil
--
/project/internal/tools/move_file.go-30- cmd := fmt.Sprintf(`mkdir -p "$(dirname %s)" && mv %s %s`, shellQuote(to), shellQuote(from), shellQuote(to))
/project/internal/tools/move_file.go-31-
/project/internal/tools/move_file.go:32: if _, err := ctx.Exec(context.Background(), cmd); err != nil {
/project/internal/tools/move_file.go-33- return "", fmt.Errorf("move %q to %q: %w", params.FromPath, params.ToPath, err)
/project/internal/tools/move_file.go-34- }
/project/internal/tools/move_file.go-35-
/project/internal/tools/move_file.go-36- return fmt.Sprintf("Moved %q to %q", params.FromPath, params.ToPath), nil
--
/project/internal/tools/read_file.go-63- shellQuote(p), from, to)
/project/internal/tools/read_file.go-64-
/project/internal/tools/read_file.go:65: output, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/read_file.go-66- if err != nil {
/project/internal/tools/read_file.go-67- return "", fmt.Errorf("read %q: %w", params.Path, err)
/project/internal/tools/read_file.go-68- }
/project/internal/tools/read_file.go-69-
--
/project/internal/tools/list_files.go-34- }
/project/internal/tools/list_files.go-35-
/project/internal/tools/list_files.go:36: output, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/list_files.go-37- if err != nil {
/project/internal/tools/list_files.go-38- return "", fmt.Errorf("list %q: %w", params.Path, err)
/project/internal/tools/list_files.go-39- }
/project/internal/tools/list_files.go-40-
--
/project/internal/tools/bash.go-46- defer cancel()
/project/internal/tools/bash.go-47-
/project/internal/tools/bash.go:48: output, err := ctx.Exec(execCtx, params.Command)
/project/internal/tools/bash.go-49- if err != nil {
/project/internal/tools/bash.go-50- if execCtx.Err() == context.DeadlineExceeded {
/project/internal/tools/bash.go-51- return "", fmt.Errorf("command timed out after %d seconds", params.Timeout)
/project/internal/tools/bash.go-52- }
--
/project/internal/tools/shell.go-40- // special characters in the file content. The output of the command
/project/internal/tools/shell.go-41- // will be base64-encoded, which we then decode in Go.
/project/internal/tools/shell.go:42: output, err := ctx.Exec(context.Background(), "base64 "+shellQuote(p))
/project/internal/tools/shell.go-43- if err != nil {
/project/internal/tools/shell.go-44- return nil, err
/project/internal/tools/shell.go-45- }
/project/internal/tools/shell.go-46-
--
/project/internal/tools/shell.go-61- shellQuote(p), shellQuote(encoded), shellQuote(p))
/project/internal/tools/shell.go-62-
/project/internal/tools/shell.go:63: _, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/shell.go-64- if err != nil {
/project/internal/tools/shell.go-65- return fmt.Errorf("write %q: %w", path, err)
/project/internal/tools/shell.go-66- }
/project/internal/tools/shell.go-67-
--
/project/internal/store/store.go-32- db.SetMaxOpenConns(1)
/project/internal/store/store.go-33-
/project/internal/store/store.go:34: if _, err := db.Exec(schema); err != nil {
/project/internal/store/store.go-35- db.Close()
/project/internal/store/store.go-36-
/project/internal/store/store.go-37- return nil, fmt.Errorf("apply schema: %w", err)
/project/internal/store/store.go-38- }
--
/project/internal/agentrun/context.go-18-
/project/internal/agentrun/context.go-19-func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
/project/internal/agentrun/context.go:20: output, exitCode, err := c.docker.exec(ctx, c.containerID, command)
/project/internal/agentrun/context.go-21-
/project/internal/agentrun/context.go-22- if err != nil {
/project/internal/agentrun/context.go-23- return output, err
/project/internal/agentrun/context.go-24- }
--
/project/internal/agentrun/sandboxgit.go-39-// non-zero exit code is an error carrying the output.
/project/internal/agentrun/sandboxgit.go-40-func runSandboxGit(ctx context.Context, rt *dockerRuntime, containerID string, args ...string) (string, error) {
/project/internal/agentrun/sandboxgit.go:41: out, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))
/project/internal/agentrun/sandboxgit.go-42- if err != nil {
/project/internal/agentrun/sandboxgit.go-43- return out, err
/project/internal/agentrun/sandboxgit.go-44- }
/project/internal/agentrun/sandboxgit.go-45-
--
/project/internal/agentrun/docker_integration_test.go-30- defer rt.remove(context.Background(), containerID)
/project/internal/agentrun/docker_integration_test.go-31-
/project/internal/agentrun/docker_integration_test.go:32: output, exitCode, err := rt.exec(ctx, containerID, "echo hello-from-zoo")
/project/internal/agentrun/docker_integration_test.go-33- if err != nil {
/project/internal/agentrun/docker_integration_test.go-34- t.Fatalf("exec: %v", err)
/project/internal/agentrun/docker_integration_test.go-35- }
/project/internal/agentrun/docker_integration_test.go-36- if exitCode != 0 {
--
/project/internal/agentrun/docker_integration_test.go-41- }
/project/internal/agentrun/docker_integration_test.go-42-
/project/internal/agentrun/docker_integration_test.go:43: _, exitCode, err = rt.exec(ctx, containerID, "exit 3")
/project/internal/agentrun/docker_integration_test.go-44- if err != nil {
{"path": "internal/agentrun/docker_integration_test.go", "start_line": 1, "end_line": 250}
{"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}
{"path": "internal/tools/grep_search.go", "start_line": 1, "end_line": 120}
{"path":"internal/tools/grep_search.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"strconv\"\r\n\t\"strings\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype grepSearchParams struct {\r\n\tPattern string `json:\"query\"`\r\n\tGlob string `json:\"glob\"`\r\n\tCaseSensitive bool `json:\"case_sensitive\"`\r\n\tContextBefore int `json:\"context_before\"`\r\n\tContextAfter int `json:\"context_after\"`\r\n\tMaxResults int `json:\"max_results\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"grep_search\",\r\n\t\t\"Search for an extended regular expression (ERE, e.g. 'foo|bar', 'func\\\\(') in project files (in the project container). Supports context lines, a glob filter, and case sensitivity control.\")\r\n\r\n\ttool.AddStringProperty(\"query\", \"The regex pattern to search for\", true)\r\n\ttool.AddStringProperty(\"glob\", \"Optional glob pattern to filter files, e.g. '*.go'\", false)\r\n\ttool.AddBooleanProperty(\"case_sensitive\", \"Whether the search should be case sensitive (default: false)\", false)\r\n\ttool.AddIntegerProperty(\"context_before\", \"Number of lines of context to show before each match (default: 0)\", false)\r\n\ttool.AddIntegerProperty(\"context_after\", \"Number of lines of context to show after each match (default: 0)\", false)\r\n\ttool.AddIntegerProperty(\"max_results\", \"Maximum number of matching lines to return (default: 100, 0 means no limit)\", false)\r\n\r\n\tRegister(tool, grepSearch)\r\n}\r\n\r\nfunc grepSearch(ctx Context, params grepSearchParams) (string, error) {\r\n\tmaxResults := params.MaxResults\r\n\tif maxResults == 0 {\r\n\t\tmaxResults = 100\r\n\t}\r\n\r\n\t// -E makes grep interpret the pattern as an extended regular\r\n\t// expression (ERE), which is the \"real\" regex flavor the tool\r\n\t// promises: |, +, ?, (), {} all work as metacharacters. Without\r\n\t// it, grep falls back to BRE, where those are treated literally.\r\n\targs := []string{\"grep\", \"-Rn\", \"-I\", \"-E\"}\r\n\tif !params.CaseSensitive {\r\n\t\targs = append(args, \"-i\")\r\n\t}\r\n\r\n\tif params.ContextBefore \u003e 0 {\r\n\t\targs = append(args, \"-B\", strconv.Itoa(params.ContextBefore))\r\n\t}\r\n\r\n\tif params.ContextAfter \u003e 0 {\r\n\t\targs = append(args, \"-A\", strconv.Itoa(params.ContextAfter))\r\n\t}\r\n\r\n\tif params.Glob != \"\" {\r\n\t\targs = append(args, \"--include=\"+shellQuote(params.Glob))\r\n\t}\r\n\r\n\targs = append(args, \"-e\", shellQuote(params.Pattern), \"/project\")\r\n\r\n\tcmd := strings.Join(args, \" \") + fmt.Sprintf(\" | head -n %d\", maxResults)\r\n\r\n\toutput, err := ctx.Exec(context.Background(), cmd)\r\n\tif err != nil {\r\n\t\t// grep exits non-zero (with empty output) when there are no\r\n\t\t// matches; that's a normal result, not a tool failure.\r\n\t\tif strings.TrimSpace(output) == \"\" {\r\n\t\t\treturn \"No matches found.\", nil\r\n\t\t}\r\n\r\n\t\treturn \"\", fmt.Errorf(\"grep_search: %w\", err)\r\n\t}\r\n\r\n\tif strings.TrimSpace(output) == \"\" {\r\n\t\treturn \"No matches found.\", nil\r\n\t}\r\n\r\n\treturn output, nil\r\n}\r","start_line":1,"end_line":83,"total_lines":83,"truncated":false}
{"path": "internal/tools/read_file.go", "start_line": 1, "end_line": 100}
{"path":"internal/tools/read_file.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"strconv\"\r\n\t\"strings\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype readFileParams struct {\r\n\tPath string `json:\"path\"`\r\n\tStartLine int `json:\"start_line\"`\r\n\tEndLine int `json:\"end_line\"`\r\n}\r\n\r\ntype readFileResult struct {\r\n\tPath string `json:\"path\"`\r\n\tContent string `json:\"content\"`\r\n\tStartLine int `json:\"start_line\"`\r\n\tEndLine int `json:\"end_line\"`\r\n\tTotalLines int `json:\"total_lines\"`\r\n\tTruncated bool `json:\"truncated\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"read_file\",\r\n\t\t\"Read lines from a file in the project container. Returns the content of the file along with metadata such as total lines.\")\r\n\r\n\ttool.AddStringProperty(\"path\", \"The path to the file to read\", true)\r\n\ttool.AddIntegerProperty(\"start_line\", \"Optional line number to start reading from (1-indexed)\", true)\r\n\ttool.AddIntegerProperty(\"end_line\", \"Optional line number to stop reading at (inclusive, 1-indexed)\", true)\r\n\r\n\tRegister(tool, readFile)\r\n}\r\n\r\nfunc readFile(ctx Context, params readFileParams) (string, error) {\r\n\tfrom := params.StartLine\r\n\tif from == 0 {\r\n\t\tfrom = 1\r\n\t}\r\n\r\n\tto := params.EndLine\r\n\tif to == 0 {\r\n\t\tto = 1 \u003c\u003c 30\r\n\t}\r\n\r\n\tif from \u003c 1 || to \u003c 1 {\r\n\t\treturn \"\", fmt.Errorf(\"line numbers must be \u003e= 1, got start_line=%d, end_line=%d\", from, to)\r\n\t}\r\n\r\n\tif from \u003e to {\r\n\t\treturn \"\", fmt.Errorf(\"start_line (%d) must be \u003c= end_line (%d)\", from, to)\r\n\t}\r\n\r\n\tp := resolvePath(params.Path)\r\n\r\n\tcmd := fmt.Sprintf(\r\n\t\t`f=%s; [ -f \"$f\" ] || { echo \"read_file: no such file: $f\" \u003e\u00262; exit 1; }; wc -l \u003c \"$f\"; sed -n '%d,%dp' \"$f\"`,\r\n\t\tshellQuote(p), from, to)\r\n\r\n\toutput, err := ctx.Exec(context.Background(), cmd)\r\n\tif err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"read %q: %w\", params.Path, err)\r\n\t}\r\n\r\n\tnl := strings.IndexByte(output, '\\n')\r\n\tif nl \u003c 0 {\r\n\t\treturn \"\", fmt.Errorf(\"read %q: unexpected output %q\", params.Path, output)\r\n\t}\r\n\r\n\ttotalLines, err := strconv.Atoi(strings.TrimSpace(output[:nl]))\r\n\tif err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"read %q: parse line count: %w\", params.Path, err)\r\n\t}\r\n\r\n\tcontent := strings.TrimSuffix(output[nl+1:], \"\\n\")\r\n\r\n\ttruncated := to \u003c totalLines\r\n\teffectiveTo := to\r\n\tif effectiveTo \u003e totalLines {\r\n\t\teffectiveTo = totalLines\r\n\t}\r\n\r\n\tresult := readFileResult{\r\n\t\tPath: params.Path,\r\n\t\tContent: content,\r\n\t\tStartLine: from,\r\n\t\tEndLine: effectiveTo,\r\n\t\tTotalLines: totalLines,\r\n\t\tTruncated: truncated,\r\n\t}\r\n\r\n\tjsonBytes, err := json.Marshal(result)\r\n\tif err != nil {\r\n\t\treturn \"\", err\r\n\t}\r","start_line":1,"end_line":100,"total_lines":103,"truncated":true}
{"path": "internal/tools/list_files.go", "start_line": 1, "end_line": 80}
{"path":"internal/tools/list_files.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype listFilesParams struct {\r\n\tPath string `json:\"path\"`\r\n\tRecursive bool `json:\"recursive\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"list_files\",\r\n\t\t\"List files in a directory in the project container, optionally recursively. Hidden (dot) entries are skipped.\")\r\n\r\n\ttool.AddStringProperty(\"path\", \"The path to list, relative to the project root, e.g. . or src/\", true)\r\n\ttool.AddBooleanProperty(\"recursive\", \"Whether to list files recursively (default: false)\", false)\r\n\r\n\tRegister(tool, listFiles)\r\n}\r\n\r\nfunc listFiles(ctx Context, params listFilesParams) (string, error) {\r\n\tp := resolvePath(params.Path)\r\n\r\n\tvar cmd string\r\n\tif params.Recursive {\r\n\t\tcmd = fmt.Sprintf(`find %s -mindepth 1 -not -path '*/.*' | sort`, shellQuote(p))\r\n\t} else {\r\n\t\tcmd = fmt.Sprintf(`ls -la %s | tail -n +2`, shellQuote(p))\r\n\t}\r\n\r\n\toutput, err := ctx.Exec(context.Background(), cmd)\r\n\tif err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"list %q: %w\", params.Path, err)\r\n\t}\r\n\r\n\treturn output, nil\r\n}\r","start_line":1,"end_line":42,"total_lines":42,"truncated":false}
{"path": "internal/agentrun/sandboxgit.go", "start_line": 1, "end_line": 120}
{"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","start_line":1,"end_line":120,"total_lines":158,"truncated":true}
{"end_line": 158, "path": "internal/agentrun/sandboxgit.go", "start_line": 120}
{"path":"internal/agentrun/sandboxgit.go","content":"// 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":120,"end_line":158,"total_lines":158,"truncated":false}
{"path": "go.mod", "start_line": 1, "end_line": 60}
{"path":"go.mod","content":"module github.com/abrander/zoo\r\n\r\ngo 1.26.4\r\n\r\nrequire (\r\n\tcodeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0\r\n\tgithub.com/docker/docker v28.5.2+incompatible\r\n\tgithub.com/google/uuid v1.6.0\r\n\tgithub.com/hashicorp/hcl/v2 v2.24.0\r\n\tmodernc.org/sqlite v1.56.0\r\n)\r\n\r\nrequire (\r\n\tgithub.com/42wim/httpsig v1.2.3 // indirect\r\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\r\n\tgithub.com/agext/levenshtein v1.2.1 // indirect\r\n\tgithub.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect\r\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\r\n\tgithub.com/containerd/errdefs v1.0.0 // indirect\r\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\r\n\tgithub.com/containerd/log v0.1.0 // indirect\r\n\tgithub.com/davidmz/go-pageant v1.0.2 // indirect\r\n\tgithub.com/distribution/reference v0.6.0 // indirect\r\n\tgithub.com/docker/go-connections v0.8.1 // indirect\r\n\tgithub.com/docker/go-units v0.5.0 // indirect\r\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\r\n\tgithub.com/felixge/httpsnoop v1.1.0 // indirect\r\n\tgithub.com/go-fed/httpsig v1.1.0 // indirect\r\n\tgithub.com/go-logr/logr v1.4.4 // indirect\r\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\r\n\tgithub.com/google/go-cmp v0.7.0 // indirect\r\n\tgithub.com/hashicorp/go-version v1.7.0 // indirect\r\n\tgithub.com/mattn/go-isatty v0.0.24 // indirect\r\n\tgithub.com/mitchellh/go-wordwrap v1.0.1 // indirect\r\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\r\n\tgithub.com/moby/sys/atomicwriter v0.1.0 // indirect\r\n\tgithub.com/moby/term v0.5.2 // indirect\r\n\tgithub.com/morikuni/aec v1.1.0 // indirect\r\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\r\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\r\n\tgithub.com/opencontainers/image-spec v1.1.1 // indirect\r\n\tgithub.com/pkg/errors v0.9.1 // indirect\r\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\r\n\tgithub.com/zclconf/go-cty v1.16.3 // indirect\r\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\r\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect\r\n\tgo.opentelemetry.io/otel v1.45.0 // indirect\r\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect\r\n\tgo.opentelemetry.io/otel/metric v1.45.0 // indirect\r\n\tgo.opentelemetry.io/otel/trace v1.45.0 // indirect\r\n\tgolang.org/x/crypto v0.39.0 // indirect\r\n\tgolang.org/x/mod v0.37.0 // indirect\r\n\tgolang.org/x/sync v0.22.0 // indirect\r\n\tgolang.org/x/sys v0.47.0 // indirect\r\n\tgolang.org/x/text v0.40.0 // indirect\r\n\tgolang.org/x/time v0.15.0 // indirect\r\n\tgolang.org/x/tools v0.47.0 // indirect\r\n\tgotest.tools/v3 v3.5.2 // indirect\r\n\tmodernc.org/libc v1.74.4 // indirect\r\n\tmodernc.org/mathutil v1.7.1 // indirect\r","start_line":1,"end_line":60,"total_lines":62,"truncated":true}
{"path": "internal/agentrun/loop.go", "start_line": 1, "end_line": 150}
{"path":"internal/agentrun/loop.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n\t\"github.com/abrander/zoo/internal/tools\"\r\n)\r\n\r\n// Hooks lets a caller observe a runLoop invocation live, as the model\r\n// streams and tool calls execute, in addition to the final []llm.Message\r\n// it returns. Any of these may be nil.\r\ntype Hooks struct {\r\n\t// OnReasoningDelta and OnContentDelta fire with just the newly\r\n\t// streamed text for the current turn, not the accumulated total.\r\n\tOnReasoningDelta func(delta string)\r\n\tOnContentDelta func(delta string)\r\n\r\n\t// OnTurnEnd fires once per completed streamer round-trip, after the\r\n\t// model's message for that turn is fully received and before any of\r\n\t// its tool calls run.\r\n\tOnTurnEnd func()\r\n\r\n\t// OnTool fires once per tool call, after it has run.\r\n\tOnTool func(name, arguments, result string, toolErr bool)\r\n}\r\n\r\n// runLoop is a headless port of ../a's App.generate(): send messages +\r\n// tool defs, get a completion, run any tool_calls and append their\r\n// results, repeat until a plain finish or ctx is done.\r\nfunc runLoop(ctx context.Context, client *llm.Client, toolsCtx tools.Context, messages []llm.Message, hooks Hooks) ([]llm.Message, error) {\r\n\tfor {\r\n\t\tif err := ctx.Err(); err != nil {\r\n\t\t\treturn messages, err\r\n\t\t}\r\n\r\n\t\tstreamer, err := client.StreamChatCompletion(ctx, \u0026llm.ChatCompletionRequest{\r\n\t\t\tMessages: messages,\r\n\t\t\tStream: true,\r\n\t\t\tTools: tools.All(),\r\n\t\t})\r\n\t\tif err != nil {\r\n\t\t\treturn messages, fmt.Errorf(\"chat completion: %w\", err)\r\n\t\t}\r\n\r\n\t\tvar completion *llm.ChatCompletion\r\n\r\n\t\tvar prevContent, prevReasoning string\r\n\r\n\t\tfor {\r\n\t\t\tc, err := streamer.Get()\r\n\t\t\tif err == io.EOF {\r\n\t\t\t\tbreak\r\n\t\t\t}\r\n\r\n\t\t\tif err != nil {\r\n\t\t\t\treturn messages, fmt.Errorf(\"stream completion: %w\", err)\r\n\t\t\t}\r\n\r\n\t\t\tcompletion = c\r\n\r\n\t\t\tif len(c.Choices) \u003e 0 {\r\n\t\t\t\tmsg := c.Choices[0].Message\r\n\r\n\t\t\t\tif hooks.OnReasoningDelta != nil \u0026\u0026 len(msg.ReasoningContent) \u003e len(prevReasoning) {\r\n\t\t\t\t\thooks.OnReasoningDelta(msg.ReasoningContent[len(prevReasoning):])\r\n\t\t\t\t}\r\n\t\t\t\tprevReasoning = msg.ReasoningContent\r\n\r\n\t\t\t\tif hooks.OnContentDelta != nil \u0026\u0026 len(msg.Content) \u003e len(prevContent) {\r\n\t\t\t\t\thooks.OnContentDelta(msg.Content[len(prevContent):])\r\n\t\t\t\t}\r\n\t\t\t\tprevContent = msg.Content\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif hooks.OnTurnEnd != nil {\r\n\t\t\thooks.OnTurnEnd()\r\n\t\t}\r\n\r\n\t\tif completion == nil || len(completion.Choices) == 0 {\r\n\t\t\treturn messages, fmt.Errorf(\"model returned an empty completion\")\r\n\t\t}\r\n\r\n\t\tchoice := completion.Choices[0]\r\n\t\tmessages = append(messages, choice.Message)\r\n\r\n\t\tif choice.FinishReason != \"tool_calls\" || len(choice.Message.ToolCalls) == 0 {\r\n\t\t\treturn messages, nil\r\n\t\t}\r\n\r\n\t\tfor _, tc := range choice.Message.ToolCalls {\r\n\t\t\tif err := ctx.Err(); err != nil {\r\n\t\t\t\treturn messages, err\r\n\t\t\t}\r\n\r\n\t\t\tresult, err := tools.Call(toolsCtx, tc.FunctionCall)\r\n\r\n\t\t\ttoolErr := err != nil\r\n\t\t\tif toolErr {\r\n\t\t\t\tresult = fmt.Sprintf(\"ERROR CALLING TOOL: %s\", err.Error())\r\n\t\t\t}\r\n\r\n\t\t\tif hooks.OnTool != nil {\r\n\t\t\t\thooks.OnTool(tc.FunctionCall.Name, tc.FunctionCall.Arguments, result, toolErr)\r\n\t\t\t}\r\n\r\n\t\t\tmessages = append(messages, llm.Message{\r\n\t\t\t\tRole: \"tool\",\r\n\t\t\t\tContent: result,\r\n\t\t\t\tToolCallID: tc.Id,\r\n\t\t\t\tName: tc.FunctionCall.Name,\r\n\t\t\t})\r\n\t\t}\r\n\t}\r\n}\r","start_line":1,"end_line":118,"total_lines":118,"truncated":false}
{"path": "internal/tools/tools_test.go", "start_line": 1, "end_line": 120}
{"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\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) 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 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\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","start_line":1,"end_line":120,"total_lines":182,"truncated":true}
{"glob": "*.go", "query": "Exec\\(ctx context\\.Context, command string\\)"}
/project/internal/tools/context.go:14: Exec(ctx context.Context, command string) (string, error)
/project/internal/tools/tools_test.go:42:func (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {
/project/internal/agentrun/context.go:19:func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
{"end_line": 182, "path": "internal/tools/tools_test.go", "start_line": 120}
{"path":"internal/tools/tools_test.go","content":"\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":182,"total_lines":182,"truncated":false}
{"glob": "*.go", "query": "shellReadFile|shellWriteFile"}
/project/internal/tools/write_file.go:28: err := shellWriteFile(ctx, p, []byte(params.Content))
/project/internal/tools/edit-file.go:34: content, err := shellReadFile(ctx, p)
/project/internal/tools/edit-file.go:54: err = shellWriteFile(ctx, p, content)
/project/internal/tools/shell.go:36:func shellReadFile(ctx Context, path string) ([]byte, error) {
/project/internal/tools/shell.go:55:func shellWriteFile(ctx Context, path string, content []byte) error {
{"command": "cd /project && go version && go build ./... 2>&1 | head -20 && echo BUILD_OK", "timeout": 180}
go version go1.26.7 linux/amd64 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 BUILD_OK
{"command": "ls $(go env GOMODCACHE)/github.com/docker/docker@v28.5.2+incompatible/pkg/stdcopy/ 2>/dev/null && grep -n \"func StdCopy\" $(go env GOMODCACHE)/github.com/docker/docker@v28.5.2+incompatible/pkg/stdcopy/stdcopy.go | head -5"}
stdcopy.go stdcopy_test.go
94:func StdCopy(dstout, dsterr io.Writer, src io.Reader) (written int64, _ error) {
{"path": "internal/agentrun/docker.go", "find": "import (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"time\"\n\n\t\"github.com/docker/docker/api/types/container\"\n\t\"github.com/docker/docker/client\"\n)", "replace": "import (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/docker/docker/api/types/container\"\n\t\"github.com/docker/docker/client\"\n\t\"github.com/docker/docker/pkg/stdcopy\"\n)"}
1 occurrences replaced
{"path": "internal/agentrun/docker.go", "find": "// exec runs command via `sh -c` inside containerID and returns its\n// combined stdout+stderr (a TTY is attached so the two streams merge\n// without needing to demultiplex Docker's stdcopy framing) plus its exit\n// code.\nfunc (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, int, error) {\n\tcreated, err := d.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{\n\t\tCmd: []string{\"sh\", \"-c\", command},\n\t\t// A TTY is attached (see doc comment above), which makes git's\n\t\t// isatty-based color.ui=auto default to enabling ANSI color codes\n\t\t// that pollute the captured job log. NO_COLOR covers tools that\n\t\t// honor that convention; the GIT_CONFIG_* override forces git's\n\t\t// own color.ui to \"never\" regardless of tty detection, since git\n\t\t// does not honor NO_COLOR itself.\n\t\t//\n\t\t// The same isatty check makes git launch a pager for diff/log/show,\n\t\t// and the pager (waiting on a stdin nothing ever attaches or\n\t\t// closes) then blocks forever with no way to time it out β see\n\t\t// exec's read loop below. GIT_PAGER/PAGER=cat disable that.\n\t\t// GIT_TERMINAL_PROMPT=0 closes the same class of hang for\n\t\t// credential prompts on a private remote.\n\t\tEnv: []string{\n\t\t\t\"NO_COLOR=1\",\n\t\t\t\"GIT_CONFIG_COUNT=1\",\n\t\t\t\"GIT_CONFIG_KEY_0=color.ui\",\n\t\t\t\"GIT_CONFIG_VALUE_0=never\",\n\t\t\t\"GIT_PAGER=cat\",\n\t\t\t\"PAGER=cat\",\n\t\t\t\"GIT_TERMINAL_PROMPT=0\",\n\t\t},\n\t\tTty: true,\n\t\tAttachStdout: true,\n\t\tAttachStderr: true,\n\t})\n\tif err != nil {\n\t\treturn \"\", 0, fmt.Errorf(\"exec create: %w\", err)\n\t}\n\n\tattached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{Tty: true})\n\tif err != nil {\n\t\treturn \"\", 0, fmt.Errorf(\"exec attach: %w\", err)\n\t}\n\tdefer attached.Close()\n\n\t// Once hijacked, this stream is a raw connection that ctx cancellation\n\t// no longer reaches β a wedged child process (pager, credential\n\t// prompt, anything else reading a stdin nobody attaches) would\n\t// otherwise block this read forever, past the run's deadline, with no\n\t// way to recover the job. Race the read against ctx and force-remove\n\t// the container to sever the connection if it fires.\n\ttype readResult struct {\n\t\toutput []byte\n\t\terr error\n\t}\n\n\treadDone := make(chan readResult, 1)\n\n\tgo func() {\n\t\toutput, err := io.ReadAll(attached.Reader)\n\t\treadDone <- readResult{output, err}\n\t}()\n\n\tvar output []byte\n\n\tselect {\n\tcase res := <-readDone:\n\t\tif res.err != nil {\n\t\t\treturn \"\", 0, fmt.Errorf(\"exec read output: %w\", res.err)\n\t\t}\n\n\t\toutput = res.output\n\n\tcase <-ctx.Done():\n\t\tkillCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\t\tdefer cancel()\n\n\t\t_ = d.cli.ContainerRemove(killCtx, containerID, container.RemoveOptions{Force: true})\n\t\t<-readDone\n\n\t\treturn \"\", 0, ctx.Err()\n\t}\n\n\tinspect, err := d.cli.ContainerExecInspect(ctx, created.ID)\n\tif err != nil {\n\t\treturn string(output), 0, fmt.Errorf(\"exec inspect: %w\", err)\n\t}\n\n\treturn string(output), inspect.ExitCode, nil\n}", "replace": "// exec runs command via `sh -c` inside containerID and returns its\n// stdout and stderr as separate strings, plus its exit code.\n//\n// No TTY is attached: a TTY would make every command believe it is\n// interactive (launching pagers, prompting for credentials, ...) and\n// the line discipline would rewrite stdout's line endings to CRLF.\n// Without a TTY, Docker frames the attached stream with its stdcopy\n// format, so the two streams are demultiplexed back apart with\n// stdcopy.StdCopy.\nfunc (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, string, int, error) {\n\tcreated, err := d.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{\n\t\tCmd: []string{\"sh\", \"-c\", command},\n\t\t// GIT_TERMINAL_PROMPT=0 stops git from ever trying to read a\n\t\t// credential from the terminal. With no TTY attached such a\n\t\t// prompt would fail anyway, but failing fast with a clear error\n\t\t// is preferable to any chance of a hang.\n\t\tEnv: []string{\"GIT_TERMINAL_PROMPT=0\"},\n\t\tAttachStdout: true,\n\t\tAttachStderr: true,\n\t})\n\tif err != nil {\n\t\treturn \"\", \"\", 0, fmt.Errorf(\"exec create: %w\", err)\n\t}\n\n\tattached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})\n\tif err != nil {\n\t\treturn \"\", \"\", 0, fmt.Errorf(\"exec attach: %w\", err)\n\t}\n\tdefer attached.Close()\n\n\t// Once hijacked, this stream is a raw connection that ctx cancellation\n\t// no longer reaches β a wedged child process (anything reading a stdin\n\t// nobody attaches) would otherwise block this read forever, past the\n\t// run's deadline, with no way to recover the job. Race the read against\n\t// ctx and force-remove the container to sever the connection if it\n\t// fires.\n\ttype readResult struct {\n\t\tstdout []byte\n\t\tstderr []byte\n\t\terr error\n\t}\n\n\treadDone := make(chan readResult, 1)\n\n\tgo func() {\n\t\tvar stdout, stderr bytes.Buffer\n\n\t\t// Without a TTY the attached stream is Docker's stdcopy-framed\n\t\t// multiplex of the process's stdout and stderr; StdCopy splits\n\t\t// it back into the two separate streams.\n\t\t_, err := stdcopy.StdCopy(&stdout, &stderr, attached.Reader)\n\n\t\treadDone <- readResult{stdout.Bytes(), stderr.Bytes(), err}\n\t}()\n\n\tvar stdout, stderr []byte\n\n\tselect {\n\tcase res := <-readDone:\n\t\tif res.err != nil {\n\t\t\treturn \"\", \"\", 0, fmt.Errorf(\"exec read output: %w\", res.err)\n\t\t}\n\n\t\tstdout, stderr = res.stdout, res.stderr\n\n\tcase <-ctx.Done():\n\t\tkillCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\t\tdefer cancel()\n\n\t\t_ = d.cli.ContainerRemove(killCtx, containerID, container.RemoveOptions{Force: true})\n\t\t<-readDone\n\n\t\treturn \"\", \"\", 0, ctx.Err()\n\t}\n\n\tinspect, err := d.cli.ContainerExecInspect(ctx, created.ID)\n\tif err != nil {\n\t\treturn string(stdout), string(stderr), 0, fmt.Errorf(\"exec inspect: %w\", err)\n\t}\n\n\treturn string(stdout), string(stderr), inspect.ExitCode, nil\n}"}
1 occurrences replaced
{"path": "internal/agentrun/context.go", "start_line": 1, "end_line": 40}
{"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// shell commands via docker exec against the run's container, and\r\n// binding Forgejo actions to the triggering 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\nfunc (c *runContext) Forgejo() tools.ForgejoActions {\r\n\treturn c.forgejo\r\n}\r","start_line":1,"end_line":35,"total_lines":35,"truncated":false}
{"command": "cd /project && file internal/agentrun/context.go internal/agentrun/docker.go internal/tools/bash.go && git config core.autocrlf; git ls-files --eol internal/agentrun/context.go"}
sh: 1: file: not found i/lf w/lf attr/ internal/agentrun/context.go
{"path": "internal/agentrun/context.go", "find": "func (c *runContext) Exec(ctx context.Context, command string) (string, error) {\n\toutput, exitCode, err := c.docker.exec(ctx, c.containerID, command)\n\n\tif err != nil {\n\t\treturn output, err\n\t}\n\n\tif exitCode != 0 {\n\t\treturn output, fmt.Errorf(\"command exited with status %d\", exitCode)\n\t}\n\n\treturn output, nil\n}", "replace": "// Exec runs command inside the container via `sh -c` and returns its\n// stdout and stderr as separate strings. A non-zero exit code is an\n// error; the output is still returned so callers can surface it.\nfunc (c *runContext) Exec(ctx context.Context, command string) (string, string, error) {\n\tstdout, stderr, exitCode, err := c.docker.exec(ctx, c.containerID, command)\n\n\tif err != nil {\n\t\treturn stdout, stderr, err\n\t}\n\n\tif exitCode != 0 {\n\t\treturn stdout, stderr, fmt.Errorf(\"command exited with status %d\", exitCode)\n\t}\n\n\treturn stdout, stderr, nil\n}"}
1 occurrences replaced
{"path": "internal/tools/context.go", "find": "\t// Exec runs command inside the run's container via `sh -c` and\n\t// returns combined stdout+stderr.\n\tExec(ctx context.Context, command string) (string, error)", "replace": "\t// Exec runs command inside the run's container via `sh -c` and\n\t// returns its stdout and stderr as separate strings. A non-zero\n\t// exit code is an error; the output is still returned so callers\n\t// can surface it.\n\tExec(ctx context.Context, command string) (stdout, stderr string, err error)"}
1 occurrences replaced
{"path": "internal/tools/shell.go", "find": "func shellReadFile(ctx Context, path string) ([]byte, error) {\n\tp := resolvePath(path)\n\n\t// We use base64 encoding to avoid issues with binary data and\n\t// special characters in the file content. The output of the command\n\t// will be base64-encoded, which we then decode in Go.\n\toutput, err := ctx.Exec(context.Background(), \"base64 \"+shellQuote(p))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdecoded, err := base64.StdEncoding.DecodeString(output)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"decode base64 output of %q: %w\", path, err)\n\t}\n\n\treturn decoded, nil\n}", "replace": "// combineOutput merges a command's stdout and stderr into the single\n// string a tool result presents: stdout first, then stderr if the\n// command wrote to both.\nfunc combineOutput(stdout, stderr string) string {\n\tswitch {\n\tcase stdout == \"\":\n\t\treturn stderr\n\tcase stderr == \"\":\n\t\treturn stdout\n\tcase strings.HasSuffix(stdout, \"\\n\"):\n\t\treturn stdout + stderr\n\tdefault:\n\t\treturn stdout + \"\\n\" + stderr\n\t}\n}\n\n// execErr wraps an Exec failure with whatever the command printed, so\n// a non-zero exit code doesn't swallow the error message.\nfunc execErr(err error, stdout, stderr string) error {\n\tif out := strings.TrimSpace(combineOutput(stdout, stderr)); out != \"\" {\n\t\treturn fmt.Errorf(\"%w: %s\", err, out)\n\t}\n\n\treturn err\n}\n\nfunc shellReadFile(ctx Context, path string) ([]byte, error) {\n\tp := resolvePath(path)\n\n\t// We use base64 encoding to avoid issues with binary data and\n\t// special characters in the file content. The output of the command\n\t// will be base64-encoded, which we then decode in Go.\n\toutput, stderr, err := ctx.Exec(context.Background(), \"base64 \"+shellQuote(p))\n\tif err != nil {\n\t\treturn nil, execErr(err, output, stderr)\n\t}\n\n\tdecoded, err := base64.StdEncoding.DecodeString(output)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"decode base64 output of %q: %w\", path, err)\n\t}\n\n\treturn decoded, nil\n}"}
1 occurrences replaced
{"path": "internal/tools/shell.go", "find": "\tcmd := fmt.Sprintf(\n\t\t`mkdir -p \"$(dirname %s)\" && echo %s | base64 -d > %s`,\n\t\tshellQuote(p), shellQuote(encoded), shellQuote(p))\n\n\t_, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"write %q: %w\", path, err)\n\t}\n\n\treturn nil\n}", "replace": "\tcmd := fmt.Sprintf(\n\t\t`mkdir -p \"$(dirname %s)\" && echo %s | base64 -d > %s`,\n\t\tshellQuote(p), shellQuote(encoded), shellQuote(p))\n\n\tstdout, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"write %q: %w\", path, execErr(err, stdout, stderr))\n\t}\n\n\treturn nil\n}"}
1 occurrences replaced
{"path": "internal/tools/bash.go", "find": "\toutput, err := ctx.Exec(execCtx, params.Command)\n\tif err != nil {\n\t\tif execCtx.Err() == context.DeadlineExceeded {\n\t\t\treturn \"\", fmt.Errorf(\"command timed out after %d seconds\", params.Timeout)\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"command failed: %w\", err)\n\t}\n\n\treturn output, nil\n}", "replace": "\tstdout, stderr, err := ctx.Exec(execCtx, params.Command)\n\tif err != nil {\n\t\tif execCtx.Err() == context.DeadlineExceeded {\n\t\t\treturn \"\", fmt.Errorf(\"command timed out after %d seconds\", params.Timeout)\n\t\t}\n\n\t\t// A non-zero exit is not a tool failure in itself, but the\n\t\t// output (usually the error message on stderr) must still reach\n\t\t// the agent, so it's carried in the error.\n\t\treturn \"\", execErr(err, stdout, stderr)\n\t}\n\n\treturn combineOutput(stdout, stderr), nil\n}"}
1 occurrences replaced
{"path": "internal/tools/grep_search.go", "find": "\toutput, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\t// grep exits non-zero (with empty output) when there are no\n\t\t// matches; that's a normal result, not a tool failure.\n\t\tif strings.TrimSpace(output) == \"\" {\n\t\t\treturn \"No matches found.\", nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"grep_search: %w\", err)\n\t}", "replace": "\toutput, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\t// grep exits non-zero (with empty output) when there are no\n\t\t// matches; that's a normal result, not a tool failure.\n\t\tif strings.TrimSpace(output) == \"\" && strings.TrimSpace(stderr) == \"\" {\n\t\t\treturn \"No matches found.\", nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"grep_search: %w\", execErr(err, output, stderr))\n\t}"}
1 occurrences replaced
{"path": "internal/tools/read_file.go", "find": "\toutput, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"read %q: %w\", params.Path, err)\n\t}", "replace": "\toutput, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"read %q: %w\", params.Path, execErr(err, output, stderr))\n\t}"}
1 occurrences replaced
{"path": "internal/tools/list_files.go", "find": "\toutput, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"list %q: %w\", params.Path, err)\n\t}", "replace": "\toutput, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"list %q: %w\", params.Path, execErr(err, output, stderr))\n\t}"}
1 occurrences replaced
{"end_line": 40, "path": "internal/tools/remove_file.go", "start_line": 15}
{"path":"internal/tools/remove_file.go","content":"\ttool := llm.NewTool(\r\n\t\t\"remove_file\",\r\n\t\t\"Remove an existing file in the project container.\")\r\n\r\n\ttool.AddStringProperty(\"path\", \"The path to the file to remove\", true)\r\n\r\n\tRegister(tool, removeFile)\r\n}\r\n\r\nfunc removeFile(ctx Context, params removeFileParams) (string, error) {\r\n\tp := resolvePath(params.Path)\r\n\r\n\tif _, err := ctx.Exec(context.Background(), \"rm -f \"+shellQuote(p)); err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"remove %q: %w\", params.Path, err)\r\n\t}\r\n\r\n\treturn \"File removed successfully\", nil\r\n}\r","start_line":15,"end_line":32,"total_lines":32,"truncated":false}
{"end_line": 45, "path": "internal/tools/move_file.go", "start_line": 20}
{"path":"internal/tools/move_file.go","content":"\ttool.AddStringProperty(\"from_path\", \"The current path to the file or directory\", true)\r\n\ttool.AddStringProperty(\"to_path\", \"The new path for the file or directory\", true)\r\n\r\n\tRegister(tool, moveFile)\r\n}\r\n\r\nfunc moveFile(ctx Context, params moveFileParams) (string, error) {\r\n\tfrom := resolvePath(params.FromPath)\r\n\tto := resolvePath(params.ToPath)\r\n\r\n\tcmd := fmt.Sprintf(`mkdir -p \"$(dirname %s)\" \u0026\u0026 mv %s %s`, shellQuote(to), shellQuote(from), shellQuote(to))\r\n\r\n\tif _, err := ctx.Exec(context.Background(), cmd); err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"move %q to %q: %w\", params.FromPath, params.ToPath, err)\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\"Moved %q to %q\", params.FromPath, params.ToPath), nil\r\n}\r","start_line":20,"end_line":37,"total_lines":37,"truncated":false}
{"path": "internal/tools/remove_file.go", "find": "\tif _, err := ctx.Exec(context.Background(), \"rm -f \"+shellQuote(p)); err != nil {\n\t\treturn \"\", fmt.Errorf(\"remove %q: %w\", params.Path, err)\n\t}", "replace": "\tstdout, stderr, err := ctx.Exec(context.Background(), \"rm -f \"+shellQuote(p))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"remove %q: %w\", params.Path, execErr(err, stdout, stderr))\n\t}"}
1 occurrences replaced
{"path": "internal/tools/move_file.go", "find": "\tif _, err := ctx.Exec(context.Background(), cmd); err != nil {\n\t\treturn \"\", fmt.Errorf(\"move %q to %q: %w\", params.FromPath, params.ToPath, err)\n\t}", "replace": "\tstdout, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"move %q to %q: %w\", params.FromPath, params.ToPath, execErr(err, stdout, stderr))\n\t}"}
1 occurrences replaced
{"path": "internal/agentrun/sandboxgit.go", "find": "// runSandboxGit runs `git <args...>` inside containerID (in its\n// working directory, /project) and returns its combined output. A\n// non-zero exit code is an error carrying the output.\nfunc runSandboxGit(ctx context.Context, rt *dockerRuntime, containerID string, args ...string) (string, error) {\n\tout, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))\n\tif err != nil {\n\t\treturn out, err\n\t}\n\n\tif exitCode != 0 {\n\t\treturn out, fmt.Errorf(\"git %s: exit %d: %s\", args[0], exitCode, out)\n\t}\n\n\treturn out, nil\n}", "replace": "// runSandboxGit runs `git <args...>` inside containerID (in its\n// working directory, /project) and returns its output (git writes\n// progress to stderr, so the two streams are merged). A non-zero exit\n// code is an error carrying the output.\nfunc runSandboxGit(ctx context.Context, rt *dockerRuntime, containerID string, args ...string) (string, error) {\n\tstdout, stderr, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))\n\n\tout := stdout\n\n\tif stderr != \"\" {\n\t\tif out != \"\" && !strings.HasSuffix(out, \"\\n\") {\n\t\t\tout += \"\\n\"\n\t\t}\n\n\t\tout += stderr\n\t}\n\n\tif err != nil {\n\t\treturn out, err\n\t}\n\n\tif exitCode != 0 {\n\t\treturn out, fmt.Errorf(\"git %s: exit %d: %s\", args[0], exitCode, out)\n\t}\n\n\treturn out, nil\n}"}
1 occurrences replaced
{"path": "internal/agentrun/docker_integration_test.go", "find": "\toutput, exitCode, err := rt.exec(ctx, containerID, \"echo hello-from-zoo\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 0 {\n\t\tt.Fatalf(\"expected exit code 0, got %d\", exitCode)\n\t}\n\tif !strings.Contains(output, \"hello-from-zoo\") {\n\t\tt.Fatalf(\"unexpected output: %q\", output)\n\t}\n\n\t_, exitCode, err = rt.exec(ctx, containerID, \"exit 3\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 3 {\n\t\tt.Fatalf(\"expected exit code 3, got %d\", exitCode)\n\t}\n}", "replace": "\tstdout, stderr, exitCode, err := rt.exec(ctx, containerID, \"echo hello-from-zoo\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 0 {\n\t\tt.Fatalf(\"expected exit code 0, got %d\", exitCode)\n\t}\n\tif stdout != \"hello-from-zoo\\n\" {\n\t\tt.Fatalf(\"unexpected stdout: %q\", stdout)\n\t}\n\tif stderr != \"\" {\n\t\tt.Fatalf(\"unexpected stderr: %q\", stderr)\n\t}\n\n\t_, _, exitCode, err = rt.exec(ctx, containerID, \"exit 3\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 3 {\n\t\tt.Fatalf(\"expected exit code 3, got %d\", exitCode)\n\t}\n}\n\n// TestDockerRuntimeExecStreams verifies the no-TTY exec contract:\n// stdout and stderr come back as separate, unframed strings (no CRLF\n// rewriting by a line discipline, no interleaving), the command sees no\n// TTY on its stdout, and a non-zero exit still returns its stderr.\nfunc TestDockerRuntimeExecStreams(t *testing.T) {\n\trt, err := newDockerRuntime()\n\tif err != nil {\n\t\tt.Fatalf(\"docker client: %v\", err)\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)\n\tdefer cancel()\n\n\tcontainerID, err := rt.createContainer(ctx, \"debian:unstable\", nil, \"zoo-test-streams\")\n\tif err != nil {\n\t\tt.Fatalf(\"create container: %v\", err)\n\t}\n\tdefer rt.remove(context.Background(), containerID)\n\n\tstdout, stderr, exitCode, err := rt.exec(ctx, containerID, \"echo out; echo err 1>&2\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 0 {\n\t\tt.Fatalf(\"expected exit code 0, got %d\", exitCode)\n\t}\n\tif stdout != \"out\\n\" {\n\t\tt.Fatalf(\"stdout = %q, want %q\", stdout, \"out\\n\")\n\t}\n\tif stderr != \"err\\n\" {\n\t\tt.Fatalf(\"stderr = %q, want %q\", stderr, \"err\\n\")\n\t}\n\n\tstdout, _, exitCode, err = rt.exec(ctx, containerID, \"[ -t 1 ] && echo tty || echo no-tty\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 0 {\n\t\tt.Fatalf(\"expected exit code 0, got %d\", exitCode)\n\t}\n\tif stdout != \"no-tty\\n\" {\n\t\tt.Fatalf(\"expected no TTY on stdout, got %q\", stdout)\n\t}\n\n\tstdout, stderr, exitCode, err = rt.exec(ctx, containerID, \"echo boom 1>&2; exit 3\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 3 {\n\t\tt.Fatalf(\"expected exit code 3, got %d\", exitCode)\n\t}\n\tif stdout != \"\" {\n\t\tt.Fatalf(\"unexpected stdout: %q\", stdout)\n\t}\n\tif stderr != \"boom\\n\" {\n\t\tt.Fatalf(\"stderr = %q, want %q\", stderr, \"boom\\n\")\n\t}\n}"}
1 occurrences replaced
{"path": "internal/agentrun/docker_integration_test.go", "find": "\toutput, _, err := rt.exec(ctx, containerID, \"git status\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif !strings.Contains(output, \"dubious ownership\") {\n\t\tt.Fatalf(\"expected the bind mount to reproduce dubious ownership before the fix, got: %s\", output)\n\t}\n\n\toutput, exitCode, err := rt.exec(ctx, containerID, \"git config --system --add safe.directory '*'\")\n\tif err != nil || exitCode != 0 {\n\t\tt.Fatalf(\"configure safe.directory: err=%v exit=%d: %s\", err, exitCode, output)\n\t}\n\n\toutput, exitCode, err = rt.exec(ctx, containerID, \"git status\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 0 || strings.Contains(output, \"dubious ownership\") {\n\t\tt.Fatalf(\"expected git status to succeed after the fix, got exit=%d: %s\", exitCode, output)\n\t}", "replace": "\t// git writes the \"dubious ownership\" fatal to stderr.\n\tstdout, stderr, _, err := rt.exec(ctx, containerID, \"git status\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif !strings.Contains(stderr, \"dubious ownership\") {\n\t\tt.Fatalf(\"expected the bind mount to reproduce dubious ownership before the fix, got stdout=%q stderr=%q\", stdout, stderr)\n\t}\n\n\tstdout, stderr, exitCode, err := rt.exec(ctx, containerID, \"git config --system --add safe.directory '*'\")\n\tif err != nil || exitCode != 0 {\n\t\tt.Fatalf(\"configure safe.directory: err=%v exit=%d: %s%s\", err, exitCode, stdout, stderr)\n\t}\n\n\tstdout, stderr, exitCode, err = rt.exec(ctx, containerID, \"git status\")\n\tif err != nil {\n\t\tt.Fatalf(\"exec: %v\", err)\n\t}\n\tif exitCode != 0 || strings.Contains(stdout, \"dubious ownership\") || strings.Contains(stderr, \"dubious ownership\") {\n\t\tt.Fatalf(\"expected git status to succeed after the fix, got exit=%d: %s%s\", exitCode, stdout, stderr)\n\t}"}
1 occurrences replaced
{"path": "internal/agentrun/docker_integration_test.go", "find": "\tif _, exitCode, err := rt.exec(ctx, containerID, \"echo more >> file.txt && git add file.txt && git commit -qm 'from sandbox'\"); err != nil || exitCode != 0 {\n\t\tt.Fatalf(\"commit in sandbox: err=%v exit=%d\", err, exitCode)\n\t}", "replace": "\tif _, _, exitCode, err := rt.exec(ctx, containerID, \"echo more >> file.txt && git add file.txt && git commit -qm 'from sandbox'\"); err != nil || exitCode != 0 {\n\t\tt.Fatalf(\"commit in sandbox: err=%v exit=%d\", err, exitCode)\n\t}"}
1 occurrences replaced
{"path": "internal/agentrun/docker_integration_test.go", "find": "\tout, _, err = rt.exec(ctx, containerID, \"grep -r super-secret-token /project || true\")", "replace": "\tout, _, _, err = rt.exec(ctx, containerID, \"grep -r super-secret-token /project || true\")"}
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\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {\n\tf.lastCmd = command\n\treturn f.output, f.err\n}", "replace": "type fakeContext struct {\n\tlastCmd string\n\tstdout string\n\tstderr string\n\terr error\n\tfg *fakeForgejoActions\n}\n\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, string, error) {\n\tf.lastCmd = command\n\treturn f.stdout, f.stderr, f.err\n}"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "\tfc := &fakeContext{output: \"3\\nline one\\nline two\\nline three\\n\"}", "replace": "\tfc := &fakeContext{stdout: \"3\\nline one\\nline two\\nline three\\n\"}"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "\tfc := &fakeContext{output: \"/project/foo.go:1:foo|bar\\n\"}", "replace": "\tfc := &fakeContext{stdout: \"/project/foo.go:1:foo|bar\\n\"}"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "\tfc := &fakeContext{output: \"\", err: fmt.Errorf(\"exit status 1\")}", "replace": "\tfc := &fakeContext{err: fmt.Errorf(\"exit status 1\")}"}
1 occurrences replaced
{"path": "internal/tools/tools_test.go", "find": "func TestRegistryRoundTrip(t *testing.T) {", "replace": "func TestBashReturnsCombinedOutput(t *testing.T) {\n\tfc := &fakeContext{stdout: \"out\\n\", stderr: \"err\\n\"}\n\n\tout, err := bash(fc, bashParams{Command: \"cmd\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"out\\nerr\\n\" {\n\t\tt.Fatalf(\"unexpected output: %q\", out)\n\t}\n}\n\nfunc TestBashCarriesOutputInError(t *testing.T) {\n\tfc := &fakeContext{\n\t\tstderr: \"ls: cannot access '/nope': No such file or directory\\n\",\n\t\terr: fmt.Errorf(\"command exited with status 2\"),\n\t}\n\n\t_, err := bash(fc, bashParams{Command: \"ls /nope\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected an error for a non-zero exit\")\n\t}\n\tif !strings.Contains(err.Error(), \"exited with status 2\") || !strings.Contains(err.Error(), \"cannot access\") {\n\t\tt.Fatalf(\"expected the exit code and the command's stderr in the error, got: %v\", err)\n\t}\n}\n\nfunc TestBashSilentFailure(t *testing.T) {\n\tfc := &fakeContext{err: fmt.Errorf(\"command exited with status 1\")}\n\n\t_, err := bash(fc, bashParams{Command: \"false\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected an error for a non-zero exit\")\n\t}\n\tif !strings.Contains(err.Error(), \"exited with status 1\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestCombineOutput(t *testing.T) {\n\tcases := []struct {\n\t\tstdout, stderr, want string\n\t}{\n\t\t{\"a\\n\", \"\", \"a\\n\"},\n\t\t{\"\", \"b\\n\", \"b\\n\"},\n\t\t{\"\", \"\", \"\"},\n\t\t{\"a\\n\", \"b\\n\", \"a\\nb\\n\"},\n\t\t{\"a\", \"b\\n\", \"a\\nb\\n\"},\n\t}\n\n\tfor _, c := range cases {\n\t\tif got := combineOutput(c.stdout, c.stderr); got != c.want {\n\t\t\tt.Errorf(\"combineOutput(%q, %q) = %q, want %q\", c.stdout, c.stderr, got, c.want)\n\t\t}\n\t}\n}\n\nfunc TestRegistryRoundTrip(t *testing.T) {"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -20", "timeout": 300}
go: downloading modernc.org/sqlite v1.56.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 github.com/google/go-cmp v0.7.0 go: downloading golang.org/x/sys v0.47.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.004s ok github.com/abrander/zoo/internal/config 0.006s ok github.com/abrander/zoo/internal/forgejo 1.388s 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.798s ok github.com/abrander/zoo/internal/store 1.549s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.788s
{"command": "cd /project && (docker version >/dev/null 2>&1 && echo DOCKER_AVAILABLE || echo NO_DOCKER); ls /var/run/docker.sock 2>/dev/null || true"}
NO_DOCKER
{"command": "cd /project && go vet -tags docker ./internal/agentrun/... && echo VET_DOCKER_OK && gofmt -l . | grep -v '^\\.git' ; echo FMT_CHECK_DONE", "timeout": 120}
VET_DOCKER_OK internal/agentrun/docker.go FMT_CHECK_DONE
{"command": "cd /project && gofmt -d internal/agentrun/docker.go"}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command": "cd /project && gofmt -d internal/agentrun/docker.go > /tmp/fmt.diff 2>&1; cat /tmp/fmt.diff"}
diff internal/agentrun/docker.go.orig internal/agentrun/docker.go
--- internal/agentrun/docker.go.orig
+++ internal/agentrun/docker.go
@@ -78,9 +78,9 @@
// credential from the terminal. With no TTY attached such a
// prompt would fail anyway, but failing fast with a clear error
// is preferable to any chance of a hang.
- Env: []string{"GIT_TERMINAL_PROMPT=0"},
- AttachStdout: true,
- AttachStderr: true,
+ Env: []string{"GIT_TERMINAL_PROMPT=0"},
+ AttachStdout: true,
+ AttachStderr: true,
})
if err != nil {
return "", "", 0, fmt.Errorf("exec create: %w", err)
{"command": "cd /project && gofmt -w internal/agentrun/docker.go && gofmt -l . | grep -v '^\\.git'; echo FMT_CLEAN && go build ./... && go test ./internal/tools/ ./internal/agentrun/ 2>&1 | tail -5", "timeout": 120}
FMT_CLEAN ok github.com/abrander/zoo/internal/tools (cached) ok github.com/abrander/zoo/internal/agentrun (cached)
{"glob": "*.go", "query": "\\.exec\\(ctx|Exec\\(context\\.Background", "context_after": 2}
/project/internal/tools/grep_search.go:67: output, stderr, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/grep_search.go-68- if err != nil {
/project/internal/tools/grep_search.go-69- // grep exits non-zero (with empty output) when there are no
--
/project/internal/tools/remove_file.go:27: stdout, stderr, err := ctx.Exec(context.Background(), "rm -f "+shellQuote(p))
/project/internal/tools/remove_file.go-28- if err != nil {
/project/internal/tools/remove_file.go-29- return "", fmt.Errorf("remove %q: %w", params.Path, execErr(err, stdout, stderr))
--
/project/internal/tools/move_file.go:32: stdout, stderr, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/move_file.go-33- if err != nil {
/project/internal/tools/move_file.go-34- return "", fmt.Errorf("move %q to %q: %w", params.FromPath, params.ToPath, execErr(err, stdout, stderr))
--
/project/internal/tools/read_file.go:65: output, stderr, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/read_file.go-66- if err != nil {
/project/internal/tools/read_file.go-67- return "", fmt.Errorf("read %q: %w", params.Path, execErr(err, output, stderr))
--
/project/internal/tools/list_files.go:36: output, stderr, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/list_files.go-37- if err != nil {
/project/internal/tools/list_files.go-38- return "", fmt.Errorf("list %q: %w", params.Path, execErr(err, output, stderr))
--
/project/internal/tools/shell.go:68: output, stderr, err := ctx.Exec(context.Background(), "base64 "+shellQuote(p))
/project/internal/tools/shell.go-69- if err != nil {
/project/internal/tools/shell.go-70- return nil, execErr(err, output, stderr)
--
/project/internal/tools/shell.go:89: stdout, stderr, err := ctx.Exec(context.Background(), cmd)
/project/internal/tools/shell.go-90- if err != nil {
/project/internal/tools/shell.go-91- return fmt.Errorf("write %q: %w", path, execErr(err, stdout, stderr))
--
/project/internal/agentrun/context.go:23: stdout, stderr, exitCode, err := c.docker.exec(ctx, c.containerID, command)
/project/internal/agentrun/context.go-24-
/project/internal/agentrun/context.go-25- if err != nil {
--
/project/internal/agentrun/sandboxgit.go:42: stdout, stderr, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))
/project/internal/agentrun/sandboxgit.go-43-
/project/internal/agentrun/sandboxgit.go-44- out := stdout
--
/project/internal/agentrun/docker_integration_test.go:32: stdout, stderr, exitCode, err := rt.exec(ctx, containerID, "echo hello-from-zoo")
/project/internal/agentrun/docker_integration_test.go-33- if err != nil {
/project/internal/agentrun/docker_integration_test.go-34- t.Fatalf("exec: %v", err)
--
/project/internal/agentrun/docker_integration_test.go:46: _, _, exitCode, err = rt.exec(ctx, containerID, "exit 3")
/project/internal/agentrun/docker_integration_test.go-47- if err != nil {
/project/internal/agentrun/docker_integration_test.go-48- t.Fatalf("exec: %v", err)
--
/project/internal/agentrun/docker_integration_test.go:74: stdout, stderr, exitCode, err := rt.exec(ctx, containerID, "echo out; echo err 1>&2")
/project/internal/agentrun/docker_integration_test.go-75- if err != nil {
/project/internal/agentrun/docker_integration_test.go-76- t.Fatalf("exec: %v", err)
--
/project/internal/agentrun/docker_integration_test.go:88: stdout, _, exitCode, err = rt.exec(ctx, containerID, "[ -t 1 ] && echo tty || echo no-tty")
/project/internal/agentrun/docker_integration_test.go-89- if err != nil {
/project/internal/agentrun/docker_integration_test.go-90- t.Fatalf("exec: %v", err)
--
/project/internal/agentrun/docker_integration_test.go:99: stdout, stderr, exitCode, err = rt.exec(ctx, containerID, "echo boom 1>&2; exit 3")
/project/internal/agentrun/docker_integration_test.go-100- if err != nil {
/project/internal/agentrun/docker_integration_test.go-101- t.Fatalf("exec: %v", err)
--
/project/internal/agentrun/docker_integration_test.go:147: stdout, stderr, _, err := rt.exec(ctx, containerID, "git status")
/project/internal/agentrun/docker_integration_test.go-148- if err != nil {
/project/internal/agentrun/docker_integration_test.go-149- t.Fatalf("exec: %v", err)
--
/project/internal/agentrun/docker_integration_test.go:155: stdout, stderr, exitCode, err := rt.exec(ctx, containerID, "git config --system --add safe.directory '*'")
/project/internal/agentrun/docker_integration_test.go-156- if err != nil || exitCode != 0 {
/project/internal/agentrun/docker_integration_test.go-157- t.Fatalf("configure safe.directory: err=%v exit=%d: %s%s", err, exitCode, stdout, stderr)
--
/project/internal/agentrun/docker_integration_test.go:160: stdout, stderr, exitCode, err = rt.exec(ctx, containerID, "git status")
/project/internal/agentrun/docker_integration_test.go-161- if err != nil {
/project/internal/agentrun/docker_integration_test.go-162- t.Fatalf("exec: %v", err)
--
/project/internal/agentrun/docker_integration_test.go:267: if _, _, exitCode, err := rt.exec(ctx, containerID, "echo more >> file.txt && git add file.txt && git commit -qm 'from sandbox'"); err != nil || exitCode != 0 {
/project/internal/agentrun/docker_integration_test.go-268- t.Fatalf("commit in sandbox: err=%v exit=%d", err, exitCode)
/project/internal/agentrun/docker_integration_test.go-269- }
--
/project/internal/agentrun/docker_integration_test.go:280: out, _, _, err = rt.exec(ctx, containerID, "grep -r super-secret-token /project || true")
/project/internal/agentrun/docker_integration_test.go-281- if err != nil {
/project/internal/agentrun/docker_integration_test.go-282- t.Fatalf("grep: %v", err)
{"command": "cd /project && git diff --stat"}
internal/agentrun/context.go | 13 ++-- internal/agentrun/docker.go | 85 ++++++++++++-------------- internal/agentrun/docker_integration_test.go | 91 +++++++++++++++++++++++----- internal/agentrun/sandboxgit.go | 18 +++++- internal/tools/bash.go | 9 ++- internal/tools/context.go | 6 +- internal/tools/grep_search.go | 6 +- internal/tools/list_files.go | 4 +- internal/tools/move_file.go | 5 +- internal/tools/read_file.go | 4 +- internal/tools/remove_file.go | 5 +- internal/tools/shell.go | 34 +++++++++-- internal/tools/tools_test.go | 70 +++++++++++++++++++-- 13 files changed, 257 insertions(+), 93 deletions(-)
{"command": "cd /project && git diff"}
diff --git a/internal/agentrun/context.go b/internal/agentrun/context.go
index 28cfcd6..ef36659 100644
--- a/internal/agentrun/context.go
+++ b/internal/agentrun/context.go
@@ -16,18 +16,21 @@ type runContext struct {
forgejo tools.ForgejoActions
}
-func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
- output, exitCode, err := c.docker.exec(ctx, c.containerID, command)
+// Exec runs command inside the container via `sh -c` and returns its
+// stdout and stderr as separate strings. A non-zero exit code is an
+// error; the output is still returned so callers can surface it.
+func (c *runContext) Exec(ctx context.Context, command string) (string, string, error) {
+ stdout, stderr, exitCode, err := c.docker.exec(ctx, c.containerID, command)
if err != nil {
- return output, err
+ return stdout, stderr, err
}
if exitCode != 0 {
- return output, fmt.Errorf("command exited with status %d", exitCode)
+ return stdout, stderr, fmt.Errorf("command exited with status %d", exitCode)
}
- return output, nil
+ return stdout, stderr, nil
}
func (c *runContext) Forgejo() tools.ForgejoActions {
diff --git a/internal/agentrun/docker.go b/internal/agentrun/docker.go
index c36ecb9..26304ef 100644
--- a/internal/agentrun/docker.go
+++ b/internal/agentrun/docker.go
@@ -1,13 +1,14 @@
package agentrun
import (
+ "bytes"
"context"
"fmt"
- "io"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
+ "github.com/docker/docker/pkg/stdcopy"
)
// containerCPUs and containerMemory bound each agent container's
@@ -62,75 +63,69 @@ func (d *dockerRuntime) createContainer(ctx context.Context, image string, binds
}
// exec runs command via `sh -c` inside containerID and returns its
-// combined stdout+stderr (a TTY is attached so the two streams merge
-// without needing to demultiplex Docker's stdcopy framing) plus its exit
-// code.
-func (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, int, error) {
+// stdout and stderr as separate strings, plus its exit code.
+//
+// No TTY is attached: a TTY would make every command believe it is
+// interactive (launching pagers, prompting for credentials, ...) and
+// the line discipline would rewrite stdout's line endings to CRLF.
+// Without a TTY, Docker frames the attached stream with its stdcopy
+// format, so the two streams are demultiplexed back apart with
+// stdcopy.StdCopy.
+func (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, string, int, error) {
created, err := d.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: []string{"sh", "-c", command},
- // A TTY is attached (see doc comment above), which makes git's
- // isatty-based color.ui=auto default to enabling ANSI color codes
- // that pollute the captured job log. NO_COLOR covers tools that
- // honor that convention; the GIT_CONFIG_* override forces git's
- // own color.ui to "never" regardless of tty detection, since git
- // does not honor NO_COLOR itself.
- //
- // The same isatty check makes git launch a pager for diff/log/show,
- // and the pager (waiting on a stdin nothing ever attaches or
- // closes) then blocks forever with no way to time it out β see
- // exec's read loop below. GIT_PAGER/PAGER=cat disable that.
- // GIT_TERMINAL_PROMPT=0 closes the same class of hang for
- // credential prompts on a private remote.
- Env: []string{
- "NO_COLOR=1",
- "GIT_CONFIG_COUNT=1",
- "GIT_CONFIG_KEY_0=color.ui",
- "GIT_CONFIG_VALUE_0=never",
- "GIT_PAGER=cat",
- "PAGER=cat",
- "GIT_TERMINAL_PROMPT=0",
- },
- Tty: true,
+ // GIT_TERMINAL_PROMPT=0 stops git from ever trying to read a
+ // credential from the terminal. With no TTY attached such a
+ // prompt would fail anyway, but failing fast with a clear error
+ // is preferable to any chance of a hang.
+ Env: []string{"GIT_TERMINAL_PROMPT=0"},
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
- return "", 0, fmt.Errorf("exec create: %w", err)
+ return "", "", 0, fmt.Errorf("exec create: %w", err)
}
- attached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{Tty: true})
+ attached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
if err != nil {
- return "", 0, fmt.Errorf("exec attach: %w", err)
+ return "", "", 0, fmt.Errorf("exec attach: %w", err)
}
defer attached.Close()
// Once hijacked, this stream is a raw connection that ctx cancellation
- // no longer reaches β a wedged child process (pager, credential
- // prompt, anything else reading a stdin nobody attaches) would
- // otherwise block this read forever, past the run's deadline, with no
- // way to recover the job. Race the read against ctx and force-remove
- // the container to sever the connection if it fires.
+ // no longer reaches β a wedged child process (anything reading a stdin
+ // nobody attaches) would otherwise block this read forever, past the
+ // run's deadline, with no way to recover the job. Race the read against
+ // ctx and force-remove the container to sever the connection if it
+ // fires.
type readResult struct {
- output []byte
+ stdout []byte
+ stderr []byte
err error
}
readDone := make(chan readResult, 1)
go func() {
- output, err := io.ReadAll(attached.Reader)
- readDone <- readResult{output, err}
+ var stdout, stderr bytes.Buffer
+
+ // Without a TTY the attached stream is Docker's stdcopy-framed
+ // multiplex of the process's stdout and stderr; StdCopy splits
+ // it back into the two separate streams.
+ _, err := stdcopy.StdCopy(&stdout, &stderr, attached.Reader)
+
+ readDone <- readResult{stdout.Bytes(), stderr.Bytes(), err}
}()
- var output []byte
+ var stdout, stderr []byte
select {
case res := <-readDone:
if res.err != nil {
- return "", 0, fmt.Errorf("exec read output: %w", res.err)
+ return "", "", 0, fmt.Errorf("exec read output: %w", res.err)
}
- output = res.output
+ stdout, stderr = res.stdout, res.stderr
case <-ctx.Done():
killCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -139,15 +134,15 @@ func (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (
_ = d.cli.ContainerRemove(killCtx, containerID, container.RemoveOptions{Force: true})
<-readDone
- return "", 0, ctx.Err()
+ return "", "", 0, ctx.Err()
}
inspect, err := d.cli.ContainerExecInspect(ctx, created.ID)
if err != nil {
- return string(output), 0, fmt.Errorf("exec inspect: %w", err)
+ return string(stdout), string(stderr), 0, fmt.Errorf("exec inspect: %w", err)
}
- return string(output), inspect.ExitCode, nil
+ return string(stdout), string(stderr), inspect.ExitCode, nil
}
// remove force-stops and removes containerID, ignoring "already gone"
diff --git a/internal/agentrun/docker_integration_test.go b/internal/agentrun/docker_integration_test.go
index 99102a8..a10470a 100644
--- a/internal/agentrun/docker_integration_test.go
+++ b/internal/agentrun/docker_integration_test.go
@@ -29,18 +29,21 @@ func TestDockerRuntimeExecRoundTrip(t *testing.T) {
}
defer rt.remove(context.Background(), containerID)
- output, exitCode, err := rt.exec(ctx, containerID, "echo hello-from-zoo")
+ stdout, stderr, exitCode, err := rt.exec(ctx, containerID, "echo hello-from-zoo")
if err != nil {
t.Fatalf("exec: %v", err)
}
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
- if !strings.Contains(output, "hello-from-zoo") {
- t.Fatalf("unexpected output: %q", output)
+ if stdout != "hello-from-zoo\n" {
+ t.Fatalf("unexpected stdout: %q", stdout)
+ }
+ if stderr != "" {
+ t.Fatalf("unexpected stderr: %q", stderr)
}
- _, exitCode, err = rt.exec(ctx, containerID, "exit 3")
+ _, _, exitCode, err = rt.exec(ctx, containerID, "exit 3")
if err != nil {
t.Fatalf("exec: %v", err)
}
@@ -49,6 +52,65 @@ func TestDockerRuntimeExecRoundTrip(t *testing.T) {
}
}
+// TestDockerRuntimeExecStreams verifies the no-TTY exec contract:
+// stdout and stderr come back as separate, unframed strings (no CRLF
+// rewriting by a line discipline, no interleaving), the command sees no
+// TTY on its stdout, and a non-zero exit still returns its stderr.
+func TestDockerRuntimeExecStreams(t *testing.T) {
+ rt, err := newDockerRuntime()
+ if err != nil {
+ t.Fatalf("docker client: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ containerID, err := rt.createContainer(ctx, "debian:unstable", nil, "zoo-test-streams")
+ if err != nil {
+ t.Fatalf("create container: %v", err)
+ }
+ defer rt.remove(context.Background(), containerID)
+
+ stdout, stderr, exitCode, err := rt.exec(ctx, containerID, "echo out; echo err 1>&2")
+ if err != nil {
+ t.Fatalf("exec: %v", err)
+ }
+ if exitCode != 0 {
+ t.Fatalf("expected exit code 0, got %d", exitCode)
+ }
+ if stdout != "out\n" {
+ t.Fatalf("stdout = %q, want %q", stdout, "out\n")
+ }
+ if stderr != "err\n" {
+ t.Fatalf("stderr = %q, want %q", stderr, "err\n")
+ }
+
+ stdout, _, exitCode, err = rt.exec(ctx, containerID, "[ -t 1 ] && echo tty || echo no-tty")
+ if err != nil {
+ t.Fatalf("exec: %v", err)
+ }
+ if exitCode != 0 {
+ t.Fatalf("expected exit code 0, got %d", exitCode)
+ }
+ if stdout != "no-tty\n" {
+ t.Fatalf("expected no TTY on stdout, got %q", stdout)
+ }
+
+ stdout, stderr, exitCode, err = rt.exec(ctx, containerID, "echo boom 1>&2; exit 3")
+ if err != nil {
+ t.Fatalf("exec: %v", err)
+ }
+ if exitCode != 3 {
+ t.Fatalf("expected exit code 3, got %d", exitCode)
+ }
+ if stdout != "" {
+ t.Fatalf("unexpected stdout: %q", stdout)
+ }
+ if stderr != "boom\n" {
+ t.Fatalf("stderr = %q, want %q", stderr, "boom\n")
+ }
+}
+
// TestDockerRuntimeGitSafeDirectory reproduces the "detected dubious
// ownership" error git raises against a bind-mounted repo owned by a
// different UID than the container runs as, and confirms the `git
@@ -81,25 +143,26 @@ func TestDockerRuntimeGitSafeDirectory(t *testing.T) {
}
defer rt.remove(context.Background(), containerID)
- output, _, err := rt.exec(ctx, containerID, "git status")
+ // git writes the "dubious ownership" fatal to stderr.
+ stdout, stderr, _, err := rt.exec(ctx, containerID, "git status")
if err != nil {
t.Fatalf("exec: %v", err)
}
- if !strings.Contains(output, "dubious ownership") {
- t.Fatalf("expected the bind mount to reproduce dubious ownership before the fix, got: %s", output)
+ if !strings.Contains(stderr, "dubious ownership") {
+ t.Fatalf("expected the bind mount to reproduce dubious ownership before the fix, got stdout=%q stderr=%q", stdout, stderr)
}
- output, exitCode, err := rt.exec(ctx, containerID, "git config --system --add safe.directory '*'")
+ stdout, stderr, exitCode, err := rt.exec(ctx, containerID, "git config --system --add safe.directory '*'")
if err != nil || exitCode != 0 {
- t.Fatalf("configure safe.directory: err=%v exit=%d: %s", err, exitCode, output)
+ t.Fatalf("configure safe.directory: err=%v exit=%d: %s%s", err, exitCode, stdout, stderr)
}
- output, exitCode, err = rt.exec(ctx, containerID, "git status")
+ stdout, stderr, exitCode, err = rt.exec(ctx, containerID, "git status")
if err != nil {
t.Fatalf("exec: %v", err)
}
- if exitCode != 0 || strings.Contains(output, "dubious ownership") {
- t.Fatalf("expected git status to succeed after the fix, got exit=%d: %s", exitCode, output)
+ if exitCode != 0 || strings.Contains(stdout, "dubious ownership") || strings.Contains(stderr, "dubious ownership") {
+ t.Fatalf("expected git status to succeed after the fix, got exit=%d: %s%s", exitCode, stdout, stderr)
}
}
@@ -201,7 +264,7 @@ func TestDockerRuntimeSandboxGit(t *testing.T) {
}
// Commits must pick up the identity from the system gitconfig.
- if _, exitCode, err := rt.exec(ctx, containerID, "echo more >> file.txt && git add file.txt && git commit -qm 'from sandbox'"); err != nil || exitCode != 0 {
+ if _, _, exitCode, err := rt.exec(ctx, containerID, "echo more >> file.txt && git add file.txt && git commit -qm 'from sandbox'"); err != nil || exitCode != 0 {
t.Fatalf("commit in sandbox: err=%v exit=%d", err, exitCode)
}
@@ -214,7 +277,7 @@ func TestDockerRuntimeSandboxGit(t *testing.T) {
}
// The token must not leak into the bind-mounted working tree.
- out, _, err = rt.exec(ctx, containerID, "grep -r super-secret-token /project || true")
+ out, _, _, err = rt.exec(ctx, containerID, "grep -r super-secret-token /project || true")
if err != nil {
t.Fatalf("grep: %v", err)
}
diff --git a/internal/agentrun/sandboxgit.go b/internal/agentrun/sandboxgit.go
index 9632fc8..b20267d 100644
--- a/internal/agentrun/sandboxgit.go
+++ b/internal/agentrun/sandboxgit.go
@@ -35,10 +35,22 @@ func shellGitCmd(args ...string) string {
}
// runSandboxGit runs `git <args...>` inside containerID (in its
-// working directory, /project) and returns its combined output. A
-// non-zero exit code is an error carrying the output.
+// working directory, /project) and returns its output (git writes
+// progress to stderr, so the two streams are merged). A non-zero exit
+// code is an error carrying the output.
func runSandboxGit(ctx context.Context, rt *dockerRuntime, containerID string, args ...string) (string, error) {
- out, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))
+ stdout, stderr, exitCode, err := rt.exec(ctx, containerID, shellGitCmd(args...))
+
+ out := stdout
+
+ if stderr != "" {
+ if out != "" && !strings.HasSuffix(out, "\n") {
+ out += "\n"
+ }
+
+ out += stderr
+ }
+
if err != nil {
return out, err
}
diff --git a/internal/tools/bash.go b/internal/tools/bash.go
index e8e082f..910fcca 100644
--- a/internal/tools/bash.go
+++ b/internal/tools/bash.go
@@ -45,14 +45,17 @@ func bash(ctx Context, params bashParams) (string, error) {
}
defer cancel()
- output, err := ctx.Exec(execCtx, params.Command)
+ stdout, stderr, err := ctx.Exec(execCtx, params.Command)
if err != nil {
if execCtx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("command timed out after %d seconds", params.Timeout)
}
- return "", fmt.Errorf("command failed: %w", err)
+ // A non-zero exit is not a tool failure in itself, but the
+ // output (usually the error message on stderr) must still reach
+ // the agent, so it's carried in the error.
+ return "", execErr(err, stdout, stderr)
}
- return output, nil
+ return combineOutput(stdout, stderr), nil
}
diff --git a/internal/tools/context.go b/internal/tools/context.go
index 85a0828..9445d29 100644
--- a/internal/tools/context.go
+++ b/internal/tools/context.go
@@ -10,8 +10,10 @@ import "context"
// global Context, since multiple agents run concurrently in zoo.
type Context interface {
// Exec runs command inside the run's container via `sh -c` and
- // returns combined stdout+stderr.
- Exec(ctx context.Context, command string) (string, error)
+ // returns its stdout and stderr as separate strings. A non-zero
+ // exit code is an error; the output is still returned so callers
+ // can surface it.
+ Exec(ctx context.Context, command string) (stdout, stderr string, err 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
diff --git a/internal/tools/grep_search.go b/internal/tools/grep_search.go
index b546e2b..2149eef 100644
--- a/internal/tools/grep_search.go
+++ b/internal/tools/grep_search.go
@@ -64,15 +64,15 @@ func grepSearch(ctx Context, params grepSearchParams) (string, error) {
cmd := strings.Join(args, " ") + fmt.Sprintf(" | head -n %d", maxResults)
- output, err := ctx.Exec(context.Background(), cmd)
+ output, stderr, err := ctx.Exec(context.Background(), cmd)
if err != nil {
// grep exits non-zero (with empty output) when there are no
// matches; that's a normal result, not a tool failure.
- if strings.TrimSpace(output) == "" {
+ if strings.TrimSpace(output) == "" && strings.TrimSpace(stderr) == "" {
return "No matches found.", nil
}
- return "", fmt.Errorf("grep_search: %w", err)
+ return "", fmt.Errorf("grep_search: %w", execErr(err, output, stderr))
}
if strings.TrimSpace(output) == "" {
diff --git a/internal/tools/list_files.go b/internal/tools/list_files.go
index 0d6f317..d06341d 100644
--- a/internal/tools/list_files.go
+++ b/internal/tools/list_files.go
@@ -33,9 +33,9 @@ func listFiles(ctx Context, params listFilesParams) (string, error) {
cmd = fmt.Sprintf(`ls -la %s | tail -n +2`, shellQuote(p))
}
- output, err := ctx.Exec(context.Background(), cmd)
+ output, stderr, err := ctx.Exec(context.Background(), cmd)
if err != nil {
- return "", fmt.Errorf("list %q: %w", params.Path, err)
+ return "", fmt.Errorf("list %q: %w", params.Path, execErr(err, output, stderr))
}
return output, nil
diff --git a/internal/tools/move_file.go b/internal/tools/move_file.go
index e5e4e16..116dfd9 100644
--- a/internal/tools/move_file.go
+++ b/internal/tools/move_file.go
@@ -29,8 +29,9 @@ func moveFile(ctx Context, params moveFileParams) (string, error) {
cmd := fmt.Sprintf(`mkdir -p "$(dirname %s)" && mv %s %s`, shellQuote(to), shellQuote(from), shellQuote(to))
- if _, err := ctx.Exec(context.Background(), cmd); err != nil {
- return "", fmt.Errorf("move %q to %q: %w", params.FromPath, params.ToPath, err)
+ stdout, stderr, err := ctx.Exec(context.Background(), cmd)
+ if err != nil {
+ return "", fmt.Errorf("move %q to %q: %w", params.FromPath, params.ToPath, execErr(err, stdout, stderr))
}
return fmt.Sprintf("Moved %q to %q", params.FromPath, params.ToPath), nil
diff --git a/internal/tools/read_file.go b/internal/tools/read_file.go
index acfe9bf..3c42536 100644
--- a/internal/tools/read_file.go
+++ b/internal/tools/read_file.go
@@ -62,9 +62,9 @@ func readFile(ctx Context, params readFileParams) (string, error) {
`f=%s; [ -f "$f" ] || { echo "read_file: no such file: $f" >&2; exit 1; }; wc -l < "$f"; sed -n '%d,%dp' "$f"`,
shellQuote(p), from, to)
- output, err := ctx.Exec(context.Background(), cmd)
+ output, stderr, err := ctx.Exec(context.Background(), cmd)
if err != nil {
- return "", fmt.Errorf("read %q: %w", params.Path, err)
+ return "", fmt.Errorf("read %q: %w", params.Path, execErr(err, output, stderr))
}
nl := strings.IndexByte(output, '\n')
diff --git a/internal/tools/remove_file.go b/internal/tools/remove_file.go
index 9082763..5674cd3 100644
--- a/internal/tools/remove_file.go
+++ b/internal/tools/remove_file.go
@@ -24,8 +24,9 @@ func init() {
func removeFile(ctx Context, params removeFileParams) (string, error) {
p := resolvePath(params.Path)
- if _, err := ctx.Exec(context.Background(), "rm -f "+shellQuote(p)); err != nil {
- return "", fmt.Errorf("remove %q: %w", params.Path, err)
+ stdout, stderr, err := ctx.Exec(context.Background(), "rm -f "+shellQuote(p))
+ if err != nil {
+ return "", fmt.Errorf("remove %q: %w", params.Path, execErr(err, stdout, stderr))
}
return "File removed successfully", nil
diff --git a/internal/tools/shell.go b/internal/tools/shell.go
index 94ea98d..c682400 100644
--- a/internal/tools/shell.go
+++ b/internal/tools/shell.go
@@ -33,15 +33,41 @@ func resolvePath(path string) string {
return path
}
+// combineOutput merges a command's stdout and stderr into the single
+// string a tool result presents: stdout first, then stderr if the
+// command wrote to both.
+func combineOutput(stdout, stderr string) string {
+ switch {
+ case stdout == "":
+ return stderr
+ case stderr == "":
+ return stdout
+ case strings.HasSuffix(stdout, "\n"):
+ return stdout + stderr
+ default:
+ return stdout + "\n" + stderr
+ }
+}
+
+// execErr wraps an Exec failure with whatever the command printed, so
+// a non-zero exit code doesn't swallow the error message.
+func execErr(err error, stdout, stderr string) error {
+ if out := strings.TrimSpace(combineOutput(stdout, stderr)); out != "" {
+ return fmt.Errorf("%w: %s", err, out)
+ }
+
+ return err
+}
+
func shellReadFile(ctx Context, path string) ([]byte, error) {
p := resolvePath(path)
// We use base64 encoding to avoid issues with binary data and
// special characters in the file content. The output of the command
// will be base64-encoded, which we then decode in Go.
- output, err := ctx.Exec(context.Background(), "base64 "+shellQuote(p))
+ output, stderr, err := ctx.Exec(context.Background(), "base64 "+shellQuote(p))
if err != nil {
- return nil, err
+ return nil, execErr(err, output, stderr)
}
decoded, err := base64.StdEncoding.DecodeString(output)
@@ -60,9 +86,9 @@ func shellWriteFile(ctx Context, path string, content []byte) error {
`mkdir -p "$(dirname %s)" && echo %s | base64 -d > %s`,
shellQuote(p), shellQuote(encoded), shellQuote(p))
- _, err := ctx.Exec(context.Background(), cmd)
+ stdout, stderr, err := ctx.Exec(context.Background(), cmd)
if err != nil {
- return fmt.Errorf("write %q: %w", path, err)
+ return fmt.Errorf("write %q: %w", path, execErr(err, stdout, stderr))
}
return nil
diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go
index 4fdc4a9..bc92117 100644
--- a/internal/tools/tools_test.go
+++ b/internal/tools/tools_test.go
@@ -34,14 +34,15 @@ func (f *fakeForgejoActions) SubmitReview(state, body string) error {
type fakeContext struct {
lastCmd string
- output string
+ stdout string
+ stderr string
err error
fg *fakeForgejoActions
}
-func (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {
+func (f *fakeContext) Exec(ctx context.Context, command string) (string, string, error) {
f.lastCmd = command
- return f.output, f.err
+ return f.stdout, f.stderr, f.err
}
func (f *fakeContext) Forgejo() ForgejoActions {
@@ -61,7 +62,7 @@ func TestShellQuote(t *testing.T) {
}
func TestReadFileParsesMetaAndContent(t *testing.T) {
- fc := &fakeContext{output: "3\nline one\nline two\nline three\n"}
+ fc := &fakeContext{stdout: "3\nline one\nline two\nline three\n"}
out, err := readFile(fc, readFileParams{Path: "src/main.go"})
if err != nil {
@@ -139,7 +140,7 @@ func TestSubmitReviewRequiresBodyUnlessApproved(t *testing.T) {
}
func TestGrepSearchUsesExtendedRegex(t *testing.T) {
- fc := &fakeContext{output: "/project/foo.go:1:foo|bar\n"}
+ fc := &fakeContext{stdout: "/project/foo.go:1:foo|bar\n"}
out, err := grepSearch(fc, grepSearchParams{Pattern: `foo|bar`})
if err != nil {
@@ -154,7 +155,7 @@ func TestGrepSearchUsesExtendedRegex(t *testing.T) {
}
func TestGrepSearchNoMatches(t *testing.T) {
- fc := &fakeContext{output: "", err: fmt.Errorf("exit status 1")}
+ fc := &fakeContext{err: fmt.Errorf("exit status 1")}
out, err := grepSearch(fc, grepSearchParams{Pattern: "nomatch"})
if err != nil {
@@ -165,6 +166,63 @@ func TestGrepSearchNoMatches(t *testing.T) {
}
}
+func TestBashReturnsCombinedOutput(t *testing.T) {
+ fc := &fakeContext{stdout: "out\n", stderr: "err\n"}
+
+ out, err := bash(fc, bashParams{Command: "cmd"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out != "out\nerr\n" {
+ t.Fatalf("unexpected output: %q", out)
+ }
+}
+
+func TestBashCarriesOutputInError(t *testing.T) {
+ fc := &fakeContext{
+ stderr: "ls: cannot access '/nope': No such file or directory\n",
+ err: fmt.Errorf("command exited with status 2"),
+ }
+
+ _, err := bash(fc, bashParams{Command: "ls /nope"})
+ if err == nil {
+ t.Fatal("expected an error for a non-zero exit")
+ }
+ if !strings.Contains(err.Error(), "exited with status 2") || !strings.Contains(err.Error(), "cannot access") {
+ t.Fatalf("expected the exit code and the command's stderr in the error, got: %v", err)
+ }
+}
+
+func TestBashSilentFailure(t *testing.T) {
+ fc := &fakeContext{err: fmt.Errorf("command exited with status 1")}
+
+ _, err := bash(fc, bashParams{Command: "false"})
+ if err == nil {
+ t.Fatal("expected an error for a non-zero exit")
+ }
+ if !strings.Contains(err.Error(), "exited with status 1") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestCombineOutput(t *testing.T) {
+ cases := []struct {
+ stdout, stderr, want string
+ }{
+ {"a\n", "", "a\n"},
+ {"", "b\n", "b\n"},
+ {"", "", ""},
+ {"a\n", "b\n", "a\nb\n"},
+ {"a", "b\n", "a\nb\n"},
+ }
+
+ for _, c := range cases {
+ if got := combineOutput(c.stdout, c.stderr); got != c.want {
+ t.Errorf("combineOutput(%q, %q) = %q, want %q", c.stdout, c.stderr, got, c.want)
+ }
+ }
+}
+
func TestRegistryRoundTrip(t *testing.T) {
if len(All()) == 0 {
t.Fatal("expected registered tools")
{"path": "internal/tools/edit-file.go", "start_line": 1, "end_line": 75}
{"path":"internal/tools/edit-file.go","content":"package tools\r\n\r\nimport (\r\n\t\"bytes\"\r\n\t\"fmt\"\r\n\t\"path\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype editFileParams struct {\r\n\tPath string `json:\"path\"`\r\n\tFind string `json:\"find\"`\r\n\tReplace string `json:\"replace\"`\r\n\tReplaceAll bool `json:\"replace_all\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"edit_file\",\r\n\t\t\"Edit a file by replacing text. The tool will read the file, replace the specified text, and write the changes back to the file.\")\r\n\r\n\ttool.AddStringProperty(\"path\", \"The path to the file to edit\", true)\r\n\ttool.AddStringProperty(\"find\", \"The text to find in the file\", true)\r\n\ttool.AddStringProperty(\"replace\", \"The text to replace the found text with\", true)\r\n\ttool.AddBooleanProperty(\"replace_all\", \"Whether to replace all occurrences of the found text (true) or just the first occurrence (false)\", false)\r\n\r\n\tRegister(tool, editFile)\r\n}\r\n\r\nfunc editFile(ctx Context, params editFileParams) (string, error) {\r\n\tp := path.Clean(params.Path)\r\n\r\n\tcontent, err := shellReadFile(ctx, p)\r\n\tif err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\tfind := []byte(params.Find)\r\n\treplace := []byte(params.Replace)\r\n\r\n\tcount := bytes.Count(content, find)\r\n\r\n\tif count == 0 {\r\n\t\treturn \"\", fmt.Errorf(\"%q not found in %q\", find, p)\r\n\t}\r\n\r\n\tif !params.ReplaceAll \u0026\u0026 count \u003e 1 {\r\n\t\treturn \"\", fmt.Errorf(\"%d occurrences of %q found in %q, but replace_all is false\", count, find, p)\r\n\t}\r\n\r\n\tcontent = bytes.ReplaceAll(content, find, replace)\r\n\r\n\terr = shellWriteFile(ctx, p, content)\r\n\tif err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"write %q: %w\", p, err)\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\"%d occurrences replaced\", count), nil\r\n}\r","start_line":1,"end_line":60,"total_lines":60,"truncated":false}
{"path": "internal/tools/grep_search.go", "find": "\tif strings.TrimSpace(output) == \"\" {\n\t\treturn \"No matches found.\", nil\n\t}\n\n\treturn output, nil\n}", "replace": "\tif strings.TrimSpace(output) == \"\" {\n\t\t// The pipeline's exit status is head's, so a grep failure (e.g.\n\t\t// a bad regex) doesn't surface as an error; its diagnostic is on\n\t\t// stderr instead of in the match list.\n\t\tif msg := strings.TrimSpace(stderr); msg != \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"grep_search: %s\", msg)\n\t\t}\n\n\t\treturn \"No matches found.\", nil\n\t}\n\n\treturn output, nil\n}"}
1 occurrences replaced
{"path": "internal/tools/list_files.go", "find": "import (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n)", "replace": "import (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n)"}
1 occurrences replaced
{"path": "internal/tools/list_files.go", "find": "\toutput, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"list %q: %w\", params.Path, execErr(err, output, stderr))\n\t}\n\n\treturn output, nil\n}", "replace": "\toutput, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"list %q: %w\", params.Path, execErr(err, output, stderr))\n\t}\n\n\t// The pipeline's exit status is tail's, so ls failing (e.g. on a\n\t// missing directory) doesn't surface as an error; its diagnostic is\n\t// on stderr instead of in the listing.\n\tif strings.TrimSpace(output) == \"\" && strings.TrimSpace(stderr) != \"\" {\n\t\treturn \"\", fmt.Errorf(\"list %q: %s\", params.Path, strings.TrimSpace(stderr))\n\t}\n\n\treturn output, nil\n}"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./... && go vet -tags docker ./internal/agentrun/... && gofmt -l . | grep -v '^\\.git'; go test ./... 2>&1 | tail -12", "timeout": 300}
? 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 (cached) ok github.com/abrander/zoo/internal/forgejo (cached) ok github.com/abrander/zoo/internal/livelog (cached) ok github.com/abrander/zoo/internal/llm (cached) ok github.com/abrander/zoo/internal/scheduler (cached) ok github.com/abrander/zoo/internal/store (cached) ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web (cached)
{"context_after": 2, "context_before": 2, "query": "stdout|stderr|PTY|TTY|combined", "glob": "*.md"}
No matches found.
{"context_after": 2, "context_before": 2, "query": "bash|output|stream", "glob": "*.md"}
/project/README.md-33-Copy the example config and customize it: /project/README.md-34- /project/README.md:35:```bash /project/README.md-36-cp zoo.hcl.example zoo.hcl /project/README.md-37-``` -- /project/README.md-41-### Running /project/README.md-42- /project/README.md:43:```bash /project/README.md-44-go build -o zoo ./cmd/zoo /project/README.md-45-./zoo -- /project/README.md-158-β βββ config/ # HCL config loader & validator /project/README.md-159-β βββ forgejo/ # Webhook receiver & event poller /project/README.md:160:β βββ livelog/ # Real-time log streaming hub /project/README.md:161:β βββ llm/ # LLM client & streaming /project/README.md-162-β βββ scheduler/ # Event-to-agent dispatcher /project/README.md-163-β βββ store/ # SQLite state management -- /project/README.md-173-### Running Tests /project/README.md-174- /project/README.md:175:```bash /project/README.md-176-go test ./... /project/README.md-177-``` -- /project/internal/agentrun/system.md-18-**Tools** /project/internal/agentrun/system.md-19- /project/internal/agentrun/system.md:20:- `bash`, `read_file`, `write_file`, `list_files`, `grep_search`, /project/internal/agentrun/system.md-21- `move_file`, `remove_file` operate on the project container. /project/internal/agentrun/system.md-22-- `comment`, `open_pull_request`, `request_review`, `submit_review`, -- /project/internal/agentrun/system.md-35-**Git** /project/internal/agentrun/system.md-36- /project/internal/agentrun/system.md:37:You may use normal git commands in `bash` to manage your working tree. /project/internal/agentrun/system.md-38-Remote (pull, fetch, push, etc.) git operations are authenticated for /project/internal/agentrun/system.md-39-you and simply work.
{"end_line": 150, "path": "internal/agentrun/docker.go", "start_line": 60}
{"path":"internal/agentrun/docker.go","content":"\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// stdout and stderr as separate strings, plus its exit code.\r\n//\r\n// No TTY is attached: a TTY would make every command believe it is\r\n// interactive (launching pagers, prompting for credentials, ...) and\r\n// the line discipline would rewrite stdout's line endings to CRLF.\r\n// Without a TTY, Docker frames the attached stream with its stdcopy\r\n// format, so the two streams are demultiplexed back apart with\r\n// stdcopy.StdCopy.\r\nfunc (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (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// GIT_TERMINAL_PROMPT=0 stops git from ever trying to read a\r\n\t\t// credential from the terminal. With no TTY attached such a\r\n\t\t// prompt would fail anyway, but failing fast with a clear error\r\n\t\t// is preferable to any chance of a hang.\r\n\t\tEnv: []string{\"GIT_TERMINAL_PROMPT=0\"},\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\n\tattached, err := d.cli.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})\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 (anything reading a stdin\r\n\t// nobody attaches) would otherwise block this read forever, past the\r\n\t// run's deadline, with no way to recover the job. Race the read against\r\n\t// ctx and force-remove the container to sever the connection if it\r\n\t// fires.\r\n\ttype readResult struct {\r\n\t\tstdout []byte\r\n\t\tstderr []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\tvar stdout, stderr bytes.Buffer\r\n\r\n\t\t// Without a TTY the attached stream is Docker's stdcopy-framed\r\n\t\t// multiplex of the process's stdout and stderr; StdCopy splits\r\n\t\t// it back into the two separate streams.\r\n\t\t_, err := stdcopy.StdCopy(\u0026stdout, \u0026stderr, attached.Reader)\r\n\r\n\t\treadDone \u003c- readResult{stdout.Bytes(), stderr.Bytes(), err}\r\n\t}()\r\n\r\n\tvar stdout, stderr []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\tstdout, stderr = res.stdout, res.stderr\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(stdout), string(stderr), 0, fmt.Errorf(\"exec inspect: %w\", err)\r\n\t}\r\n\r\n\treturn string(stdout), string(stderr), 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","start_line":60,"end_line":150,"total_lines":163,"truncated":true}
{"path": "internal/tools/bash.go", "start_line": 1, "end_line": 65}
{"path":"internal/tools/bash.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\nconst maxTimeout = 300\r\n\r\ntype bashParams struct {\r\n\tCommand string `json:\"command\"`\r\n\tTimeout int `json:\"timeout,omitempty\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"bash\",\r\n\t\t\"Execute a shell command in the project container. Use this to run shell commands and scripts.\")\r\n\r\n\ttool.AddStringProperty(\"command\", \"The shell command to execute\", true)\r\n\ttool.AddIntegerProperty(\"timeout\", \"Optional timeout in seconds. Must not exceed 300 seconds.\", false)\r\n\r\n\tRegister(tool, bash)\r\n}\r\n\r\nfunc bash(ctx Context, params bashParams) (string, error) {\r\n\tif params.Timeout \u003c 0 {\r\n\t\treturn \"\", fmt.Errorf(\"timeout must not be negative\")\r\n\t}\r\n\r\n\tif params.Timeout \u003e maxTimeout {\r\n\t\treturn \"\", fmt.Errorf(\"timeout exceeds maximum of %d seconds\", maxTimeout)\r\n\t}\r\n\r\n\tvar execCtx context.Context\r\n\tvar cancel context.CancelFunc\r\n\r\n\tif params.Timeout \u003e 0 {\r\n\t\texecCtx, cancel = context.WithTimeout(context.Background(), time.Duration(params.Timeout)*time.Second)\r\n\t} else {\r\n\t\texecCtx, cancel = context.WithCancel(context.Background())\r\n\t}\r\n\tdefer cancel()\r\n\r\n\tstdout, stderr, err := ctx.Exec(execCtx, params.Command)\r\n\tif err != nil {\r\n\t\tif execCtx.Err() == context.DeadlineExceeded {\r\n\t\t\treturn \"\", fmt.Errorf(\"command timed out after %d seconds\", params.Timeout)\r\n\t\t}\r\n\r\n\t\t// A non-zero exit is not a tool failure in itself, but the\r\n\t\t// output (usually the error message on stderr) must still reach\r\n\t\t// the agent, so it's carried in the error.\r\n\t\treturn \"\", execErr(err, stdout, stderr)\r\n\t}\r\n\r\n\treturn combineOutput(stdout, stderr), nil\r\n}\r","start_line":1,"end_line":61,"total_lines":61,"truncated":false}
{"command": "cd /project && git add -A && git commit -m \"tools: drop the PTY from docker exec, keep stdout/stderr separate\n\nAttaching a TTY to every exec made commands believe they were\ninteractive (pagers, credential prompts, ...) and the line discipline\nrewrote stdout's line endings to CRLF. Remove the TTY and demultiplex\nDocker's stdcopy framing instead, so exec returns stdout and stderr as\nseparate strings.\n\nThe stream separation also exposes how the tool layer swallowed\ncommand output on a non-zero exit: the bash tool (and the file tools)\nnow carry the command's output in the error, so an error message on\nstderr is no longer discarded.\n\n- agentrun: exec() returns (stdout, stderr, exitCode, err); drop the\n TTY and the TTY-only env workarounds (NO_COLOR, git color.ui,\n pagers), keep GIT_TERMINAL_PROMPT=0 as a hang safety net\n- tools: Context.Exec returns (stdout, stderr, err); bash presents\n both streams and includes them in the failure; read/list/grep and\n the file helpers parse stdout and surface stderr in errors\n- tests: unit coverage for the new bash/combine behavior, and a docker\n integration test for the no-TTY stream contract\" && git log --oneline -2"}
[issue-52-greg 4ff7ec5] tools: drop the PTY from docker exec, keep stdout/stderr separate 13 files changed, 272 insertions(+), 93 deletions(-) 4ff7ec5 (HEAD -> issue-52-greg) tools: drop the PTY from docker exec, keep stdout/stderr separate 10c3dce (origin/main, main) Better Docker/git inter.
{"command": "cd /project && go build ./... && go vet ./... && go vet -tags docker ./... 2>&1 | grep -v '^$'; go test ./... 2>&1 | tail -12 && git push -u origin issue-52-greg 2>&1 | tail -3", "timeout": 300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun (cached) ok github.com/abrander/zoo/internal/config (cached) ok github.com/abrander/zoo/internal/forgejo (cached) ok github.com/abrander/zoo/internal/livelog (cached) ok github.com/abrander/zoo/internal/llm (cached) ok github.com/abrander/zoo/internal/scheduler (cached) ok github.com/abrander/zoo/internal/store (cached) ok github.com/abrander/zoo/internal/tools (cached) ok github.com/abrander/zoo/internal/web (cached) To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-52-greg -> issue-52-greg branch 'issue-52-greg' set up to track 'origin/issue-52-greg'.
{"head": "issue-52-greg", "base": "main", "title": "tools: drop the PTY from docker exec, keep stdout/stderr separate", "body": "Fixes #52.\n\nI agree the PTY was more hassle than it gave β all three reported problems came from it, and removing it also exposed a fourth: the tool layer was discarding **all** command output (not just stderr) on a non-zero exit.\n\n## Changes\n\n**`internal/agentrun/docker.go`** β `exec()` no longer attaches a TTY:\n- Commands no longer see a TTY, so nothing will believe it's interactive (no pager launches, no credential prompts waiting on input that never comes β such commands now fail fast with a visible error instead of hanging until the timeout kills the whole container).\n- No more CRLF rewriting of stdout by the line discipline.\n- Without a TTY, Docker frames the attached stream with its stdcopy format, so the streams are demultiplexed with `pkg/stdcopy.StdCopy` into separate `stdout`/`stderr` buffers. `exec()` now returns `(stdout, stderr string, exitCode int, err error)`.\n- The TTY-only env workarounds (`NO_COLOR`, git `color.ui=never`, `GIT_PAGER`/`PAGER=cat`) are gone β git's isatty checks no longer fire. `GIT_TERMINAL_PROMPT=0` is kept as a hang safety net.\n\n**`internal/tools`** β `Context.Exec` now returns `(stdout, stderr string, err error)`:\n- `bash` presents both streams (stdout first, then stderr) and, on a non-zero exit, carries the output in the error β e.g. `command exited with status 2: ls: cannot access '/nope': No such file or directory` instead of a bare `command failed`.\n- `read_file`, `list_files`, `grep_search`, `write_file`, `edit_file`, `move_file`, `remove_file` parse stdout and surface stderr in their errors (e.g. `read_file`'s \"no such file\" message, which it writes to stderr, is no longer lost).\n- `grep_search`/`list_files` use pipelines whose exit status is the last command's, so a failing `grep`/`ls` doesn't surface as an error β their stderr diagnostics are now surfaced explicitly instead of being silently dropped.\n\n**`internal/agentrun/sandboxgit.go`** β `runSandboxGit` merges the two streams (git writes progress to stderr) and keeps its \"error carries the output\" contract.\n\n## Tests\n\n- New unit tests: bash output combining, output carried in the failure error, silent failure, `combineOutput` cases.\n- New docker integration test `TestDockerRuntimeExecStreams` (build tag `docker`): verifies separate unframed streams (no CRLF), no TTY on the command's stdout, and stderr returned on a non-zero exit. The existing `git status` \"dubious ownership\" test now asserts on stderr, where git actually writes that fatal.\n- `go build`, `go vet` (incl. `-tags docker`), `gofmt`, and `go test ./...` all pass. The docker-tagged integration tests need a daemon, which this environment doesn't have β they compile cleanly but weren't executed here."}
Pull request opened
{"body": "Agreed β the PTY was more hassle than it gave, and I've removed it. All three problems you listed came from it, and digging in exposed a fourth: on a non-zero exit the tool layer was actually discarding **all** output (stdout *and* stderr), not just stderr.\n\nWhat the fix does (PR is up for review):\n\n1. **No TTY anymore** (`internal/agentrun/docker.go`). Commands no longer see a terminal, so nothing will believe it's interactive β a pager or credential prompt can't hang waiting for input that never comes; such commands now fail fast with a visible error instead of running until the timeout force-kills the whole container.\n2. **No CRLF rewriting** β that was the PTY line discipline. Without a TTY, Docker frames the stream with its stdcopy format, so `exec` demultiplexes it with `pkg/stdcopy.StdCopy` and returns `stdout` and `stderr` as separate strings.\n3. **Nothing discarded on failure** β `Context.Exec` now returns `(stdout, stderr, err)`, and the `bash` tool carries the output in the error on a non-zero exit: `command exited with status 2: ls: cannot access '/nope': No such file or directory`. The file tools (`read_file`, `list_files`, `grep_search`, `write_file`, `edit_file`, `move_file`, `remove_file`) got the same treatment, so e.g. `read_file`'s \"no such file\" message (which it writes to stderr) reaches the agent.\n4. Cleanup: the TTY-only env workarounds (`NO_COLOR`, git `color.ui=never`, `GIT_PAGER`/`PAGER=cat`) are gone since git's isatty checks no longer fire; `GIT_TERMINAL_PROMPT=0` stays as a hang safety net.\n\nTests: new unit tests for the bash output/error behavior, and a new docker integration test (`-tags docker`) asserting separate unframed streams, no TTY on the command's stdout, and stderr returned on non-zero exit. `go build`, `go vet`, `gofmt` and `go test ./...` all pass; the docker-tagged tests compile but need a daemon, which this environment doesn't have."}
Comment posted