diff --git a/cmd/harnesscli/go_code_script_test.go b/cmd/harnesscli/go_code_script_test.go index f2d30a63..04cc38a7 100644 --- a/cmd/harnesscli/go_code_script_test.go +++ b/cmd/harnesscli/go_code_script_test.go @@ -145,3 +145,60 @@ func TestGoCodeScriptPropagatesHarnessCLIExitCode(t *testing.T) { }) } } + +// TestGoCodeScriptStartsHarnessdOnLoopback pins the wrapper's bind contract +// (issue #1411): a harnessd the wrapper starts for its own use must listen on +// loopback only. +// +// The wrapper's client base URL is always http://127.0.0.1:${port}, so a wider +// bind has no consumer — it only exposes an unauthenticated agent-execution +// service to the local network. cmd/harnessd/bind_guard.go refuses exactly that +// address when no auth is configured, which killed `go-code` at startup on any +// machine without an API key store. +func TestGoCodeScriptStartsHarnessdOnLoopback(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "go-code.sh")) + if err != nil { + t.Fatalf("resolve go-code script path: %v", err) + } + + tmp := t.TempDir() + binDir := t.TempDir() + addrFile := filepath.Join(tmp, "harnessd.addr") + recordFile := filepath.Join(tmp, "harnesscli.called") + countFile := filepath.Join(tmp, "curl.count") + + // curl: fail the first health check so the wrapper takes the start_server + // path, then succeed so it proceeds to harnesscli. + writeExecutable(t, filepath.Join(binDir, "curl"), "#!/usr/bin/env bash\nf=\"$CURL_COUNT_FILE\"\nn=0\nif [ -f \"$f\" ]; then n=$(cat \"$f\"); fi\nn=$((n+1))\necho \"$n\" > \"$f\"\nif [ \"$n\" -eq 1 ]; then exit 1; fi\nexit 0\n") + // harnessd: record the bind address it was handed, then exit. + writeExecutable(t, filepath.Join(binDir, "harnessd"), "#!/usr/bin/env bash\nprintf '%s' \"$HARNESS_ADDR\" > \"$ADDR_FILE\"\nexit 0\n") + writeExecutable(t, filepath.Join(binDir, "harnesscli"), "#!/usr/bin/env bash\nprintf 'called\\n' >> \"$RECORD_FILE\"\nexit 0\n") + + cmd := exec.Command("bash", scriptPath, "runs") + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "HARNESS_ADDR=:19282", + "ADDR_FILE="+addrFile, + "RECORD_FILE="+recordFile, + "CURL_COUNT_FILE="+countFile, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go-code runs failed: %v\n%s", err, out) + } + + gotRaw, err := os.ReadFile(addrFile) + if err != nil { + t.Fatalf("read recorded harnessd address: %v\nscript output:\n%s", err, out) + } + got := strings.TrimSpace(string(gotRaw)) + + // The port from HARNESS_ADDR must still be honored, so a fix that hardcodes + // 127.0.0.1:8080 fails here too. + const want = "127.0.0.1:19282" + if got != want { + t.Fatalf("harnessd bind address = %q, want %q\n"+ + "a wildcard bind is refused by cmd/harnessd/bind_guard.go when no auth is configured\nscript output:\n%s", + got, want, out) + } +} diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 53b92bdb..c8193b99 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -1,5 +1,39 @@ # Engineering Log +## 2026-09-08 — Issue #1411 go-code wrapper bound harnessd beyond loopback + +- Symptom: plain `go-code` (no flags) died on a clean machine with no API key + store configured and no `HARNESS_AUTH_DISABLED` set — the daemon exited + before becoming healthy, and the wrapper's failure hint blamed port + contention. +- Cause: `scripts/go-code.sh:196` (pre-fix) hardcoded + `HARNESS_ADDR=":${port}"` — a wildcard bind — when spawning `harnessd` for + its own use, even though the wrapper's client `base_url` is always built as + `http://127.0.0.1:${port}`. `cmd/harnessd/bind_guard.go` (issue #1328) + refuses to start an unauthenticated daemon that listens beyond loopback, so + the wider bind had no legitimate purpose and just tripped the guard. +- Fix: `start_server()` now spawns with `HARNESS_ADDR="127.0.0.1:${port}"`; + the failure hint no longer asserts port contention as the only cause and + points at the harnessd log above it instead; the header comment and + `--help` text now say `HARNESS_ADDR` supplies only the port and that a + wrapper-started `harnessd` always binds `127.0.0.1`. +- Security note: this is strictly tightening, not a new restriction users + need to work around. Before the #1328 guard existed, this same line + silently ran an unauthenticated agent-execution daemon on every network + interface, reachable by anyone on the LAN with the user's provider + credentials. +- Regression: `TestGoCodeScriptStartsHarnessdOnLoopback` in + `cmd/harnesscli/go_code_script_test.go`, red before the fix with + `harnessd bind address = ":19282", want "127.0.0.1:19282"`. +- Cross-reference: the 2026-09-05 `#1380` entry below first spotted this + pattern in `internal/workspace/bootstrap.go:36` (VM cloud-init template), + filed as a follow-up rather than fixed there; that sibling instance is + tracked separately as issue #1392 and is unaffected by this fix. +- Left alone deliberately: `scripts/soak.sh:271-272`, `scripts/smoke-test.sh:87`, + and `scripts/run-bench-smoke.sh:135-136` also bind `:${PORT}` (wildcard), + but all three pass `HARNESS_AUTH_DISABLED=true`, so the bind guard admits + them. + ## 2026-09-06 — TUI scenario walk: plan mode, @ completion, question box, bubble width (#1407) - Twelve multi-step TUI scenarios driven live in tmux (fake provider with scripted streaming/tool turns, and OpenRouter DeepSeek). Fixes: `/plan` command toggles enforced plan mode with a `PLAN` status badge and `harnesscli --tui --plan-mode` now honored (`runTUI` passes the flag into `TUIConfig`); `@name` Tab completion completes bare relative file names, not only `./`, `/`, `~/` paths; the AskUserQuestion and Plan-Approval boxes size their top/bottom borders to the content instead of a fixed 40-col rule; assistant markdown bubbles render at the indented width, expand tabs, and trim padding so a bubble never exceeds the terminal width. Guards added: streamed-transcript integrity and no duplicate tool card on ctrl+o after interrupt. diff --git a/scripts/go-code.sh b/scripts/go-code.sh index 5df14274..f4e3fb43 100755 --- a/scripts/go-code.sh +++ b/scripts/go-code.sh @@ -20,9 +20,11 @@ set -euo pipefail # Run or plan the self-improvement test loop. # # Environment: -# HARNESS_ADDR Listen address (default :8080). The port is extracted and -# used to construct the BASE_URL for health checks and CLI -# invocations. +# HARNESS_ADDR Listen address (default :8080). Only the port is used: it +# constructs the BASE_URL for health checks and CLI +# invocations, and a wrapper-started harnessd always binds +# 127.0.0.1 on that port. Run harnessd directly for a daemon +# that listens beyond this machine. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DATA_DIR="" @@ -49,7 +51,8 @@ Usage: Run or plan the self-improvement test loop. Environment: - HARNESS_ADDR Override the server address (default: :8080). + HARNESS_ADDR Override the server port (default: :8080). A wrapper-started + harnessd always binds 127.0.0.1 on that port. Example: HARNESS_ADDR=:9090 go-code "ls *.go" Description: @@ -193,7 +196,13 @@ start_server() { # harnessd unchanged. # # Start in background, capturing the PID. - HARNESS_ADDR=":${port}" "$harnessd_bin" & + # Loopback only. The wrapper talks to this daemon exclusively over + # http://127.0.0.1:${port}, so a wildcard bind has no consumer — it would just + # publish an unauthenticated agent-execution service to the local network, and + # harnessd's bind guard (cmd/harnessd/bind_guard.go, issue #1328) refuses to + # start there without auth. HARNESS_ADDR supplies the port; the host is ours. + # Issue #1411. + HARNESS_ADDR="127.0.0.1:${port}" "$harnessd_bin" & local pid=$! PID_FILE="${TMPDIR:-/tmp}/harnessd.${$}.pid" echo "$pid" > "$PID_FILE" @@ -209,7 +218,7 @@ start_server() { die "server did not become healthy within 10 s" fi if ! kill -0 "$pid" 2>/dev/null; then - die "harnessd (pid ${pid}) exited before becoming healthy on port ${port}. If the port is already in use (see the harnessd log above), free it or run on another port with HARNESS_ADDR=:PORT (e.g. HARNESS_ADDR=:9090 go-code)." + die "harnessd (pid ${pid}) exited before becoming healthy on port ${port}. See the harnessd log above for the reason it stopped. If it reports the port is already in use, free it or run on another port with HARNESS_ADDR=:PORT (e.g. HARNESS_ADDR=:9090 go-code)." fi done info "server is ready" diff --git a/website/docs/cli/go-code-wrapper.md b/website/docs/cli/go-code-wrapper.md index 1039f2f7..2c752afb 100644 --- a/website/docs/cli/go-code-wrapper.md +++ b/website/docs/cli/go-code-wrapper.md @@ -171,7 +171,7 @@ The full code table (`0` completed, `1` client error, `2` failed, `3` blocked, ` ### Server address: `HARNESS_ADDR` -The `HARNESS_ADDR` environment variable controls the listen address. The default is `127.0.0.1:8080`. The wrapper extracts the port from this value and constructs the base URL as `http://127.0.0.1:`. +The `HARNESS_ADDR` environment variable controls the listen address. The default is `127.0.0.1:8080`. The wrapper only uses the **port** from this value — the host part is ignored. A `harnessd` that the wrapper starts always binds `127.0.0.1` on that port, because the wrapper only ever talks to it over loopback (`http://127.0.0.1:`). ```bash # Run on a different port @@ -180,6 +180,8 @@ HARNESS_ADDR=:9090 go-code "List the Go source files" The address can also be set in your project or user config file (`~/.harness/config.toml` or `.harness/config.toml`). `HARNESS_ADDR` takes precedence over the TOML layers. +If you need a daemon reachable from another machine, don't go through `go-code` — run `harnessd` directly with an explicit `HARNESS_ADDR` (a real host, not just a port) and either a configured API key store or `HARNESS_AUTH_DISABLED=true`. An unauthenticated daemon that listens beyond loopback refuses to start otherwise. + ### Project root detection `go-code` automatically resolves the workspace root before launching TUI or prompt mode. It walks parent directories from `$PWD`, looking for: