diff --git a/docs/concurrency.md b/docs/concurrency.md index e28da4267..04147d271 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -248,6 +248,13 @@ Shell and kernel subprocesses run in managed process groups. This is operational containment, not a security sandbox against another hostile process already running as the same OS user. +Managed host processes inherit the Go build settings `GOCACHE`, `GOMODCACHE`, +`GOPATH`, `GOROOT`, `GOTOOLCHAIN`, and `GOFLAGS` captured when the process manager +starts. This preserves preloaded modules and toolchains in offline environments. +Explicit child-process settings take precedence over that snapshot. Other Go +variables, including credential-bearing proxy/auth settings, are not implicitly +inherited; the allowlist is in `internal/capability/process.go`. + ## Gateway process ownership The socket-only daemon owns execution; `internal/webgateway` owns HTTP/WS diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 20ed32973..061af215c 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1223,8 +1223,32 @@ func (a *Agent) ManualCompact(ctx context.Context, ev Events) error { // finalAnswer makes one last completion with tools disabled, so a run that hit // the tool-turn cap still returns the model's best answer instead of an error. -// A system nudge tells the model to stop calling tools and answer now. +// The capped answer goes through the same final-message check the loop runs +// on a no-tools reply: a definition's output contract is a property of the +// turn, not of how it ended. One correction round is allowed, as in the loop; +// a second miss fails the turn. func (a *Agent) finalAnswer(ctx context.Context, ev Events) (string, error) { + text, err := a.cappedAnswer(ctx, ev) + if err != nil || ev.CheckFinal == nil { + return text, err + } + retry, err := ev.CheckFinal(text) + if err != nil || !retry { + return text, err + } + if text, err = a.cappedAnswer(ctx, ev); err != nil { + return text, err + } + if _, err := ev.CheckFinal(text); err != nil { + return "", err + } + return text, nil +} + +// cappedAnswer is one tools-disabled completion. A system nudge tells the +// model to stop calling tools and answer now; the turn's ephemeral notices +// (an output-contract correction among them) ride along as usual. +func (a *Agent) cappedAnswer(ctx context.Context, ev Events) (string, error) { msgs := append(slices.Clone(withEphemeralSystem(a.Messages, ev.ephemeral())), llm.Message{Role: "system", Content: "You have reached the tool-call limit. Do NOT request any more tools. Give your final answer now using only what you have already gathered."}) client := *a.Client diff --git a/internal/agent/final_answer_test.go b/internal/agent/final_answer_test.go new file mode 100644 index 000000000..0504ad172 --- /dev/null +++ b/internal/agent/final_answer_test.go @@ -0,0 +1,118 @@ +package agent + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/context-labs/whip/internal/llm" + "github.com/context-labs/whip/internal/tools" +) + +// A turn that hits the cap ends with a tools-disabled answer. That answer must +// pass the same final-message check a no-tools reply does, with the one +// correction round the check may ask for. +func TestFinalAnswerRunsCheckFinalWithOneCorrection(t *testing.T) { + t.Parallel() + var finals []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request llm.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/event-stream") + switch { + case len(request.Tools) > 0: + fmt.Fprint(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"s\":\"work\"}"}}]}}]}`+"\n\n") + default: + // The correction notice rides as an ephemeral system message + // ahead of the tools-disabled nudge. + var system []string + for _, message := range request.Messages { + if message.Role == "system" { + system = append(system, message.Content) + } + } + joined := strings.Join(system, "\n") + finals = append(finals, joined) + answer := "not json" + if strings.Contains(joined, "correct") { + answer = `{"ok":true}` + } + fmt.Fprintf(w, `data: {"choices":[{"delta":{"content":%q},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}`+"\n\n", answer) + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer server.Close() + ag := NewRuntime(llm.New(server.URL, "key"), "model", 1024, "system", tools.NewServices()) + defer ag.Services.Close() + ag.Tools = []tools.Tool{echoTool()} + ag.MaxTurns = 1 + var notice string + checks := 0 + ev := Events{ + EphemeralNotices: func() string { return notice }, + CheckFinal: func(text string) (bool, error) { + checks++ + if text == `{"ok":true}` { + return false, nil + } + if checks > 1 { + return false, fmt.Errorf("output_invalid: %q", text) + } + notice = "please correct: reply with one JSON value" + return true, nil + }, + } + final, err := ag.Turn(t.Context(), "go", ev) + if err != nil || final != `{"ok":true}` { + t.Fatalf("capped turn = %q, %v", final, err) + } + if checks != 2 || len(finals) != 2 { + t.Fatalf("checks=%d final calls=%d", checks, len(finals)) + } + if strings.Contains(finals[0], "correct") || !strings.Contains(finals[1], "correct") { + t.Fatalf("correction notice not delivered on the second final call: %q", finals) + } +} + +func TestFinalAnswerFailsTheTurnOnASecondMiss(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request llm.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/event-stream") + if len(request.Tools) > 0 { + fmt.Fprint(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"s\":\"work\"}"}}]}}]}`+"\n\n") + } else { + fmt.Fprint(w, `data: {"choices":[{"delta":{"content":"still not json"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer server.Close() + ag := NewRuntime(llm.New(server.URL, "key"), "model", 1024, "system", tools.NewServices()) + defer ag.Services.Close() + ag.Tools = []tools.Tool{echoTool()} + ag.MaxTurns = 1 + checks := 0 + _, err := ag.Turn(t.Context(), "go", Events{CheckFinal: func(string) (bool, error) { + checks++ + if checks > 1 { + return false, errors.New("output_invalid") + } + return true, nil + }}) + if err == nil || !strings.Contains(err.Error(), "output_invalid") || checks != 2 { + t.Fatalf("second miss should fail the turn: err=%v checks=%d", err, checks) + } +} diff --git a/internal/capability/process.go b/internal/capability/process.go index 7820d8062..7736964af 100644 --- a/internal/capability/process.go +++ b/internal/capability/process.go @@ -372,6 +372,10 @@ func allowedBaseEnvironment(name string) bool { "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE", "TERM", "COLORTERM", "NO_COLOR": return true + case "GOCACHE", "GOMODCACHE", "GOPATH", "GOROOT", "GOTOOLCHAIN", "GOFLAGS": + // Keep the host's Go toolchain and preloaded caches usable offline. + // Do not allow GO* wholesale: proxy/auth settings can carry credentials. + return true } return name == buildinfo.Env("HOME") || strings.HasPrefix(name, "LC_") } diff --git a/internal/capability/process_go_test.go b/internal/capability/process_go_test.go new file mode 100644 index 000000000..564906310 --- /dev/null +++ b/internal/capability/process_go_test.go @@ -0,0 +1,65 @@ +//go:build darwin || linux + +package capability + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestProcessGoUsesPreloadedModuleCacheOffline(t *testing.T) { + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("Go executable is not on PATH") + } + work := t.TempDir() + cache := t.TempDir() + const dependency = "example.com/whip-cache-test" + // Listing the module graph needs cached metadata, so this fixture exercises + // a real Go command without downloads or toolchain compilation. + mod := filepath.Join(cache, "cache", "download", dependency, "@v", "v1.0.0.mod") + if err := os.MkdirAll(filepath.Dir(mod), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mod, []byte("module "+dependency+"\n\ngo 1.20\n"), 0o644); err != nil { + t.Fatal(err) + } + info := filepath.Join(filepath.Dir(mod), "v1.0.0.info") + if err := os.WriteFile(info, []byte(`{"Version":"v1.0.0","Time":"2020-01-01T00:00:00Z"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "go.mod"), []byte("module example.com/app\n\ngo 1.20\n\nrequire "+dependency+" v1.0.0\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GOMODCACHE", cache) + t.Setenv("GOCACHE", t.TempDir()) + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOTOOLCHAIN", "local") + t.Setenv("GOFLAGS", "-mod=mod") + m := NewProcessManager() + t.Cleanup(func() { _ = m.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var stdout, stderr bytes.Buffer + p, err := m.Start(ctx, "root", goBin, []string{"list", "-m", "all"}, ProcessOptions{ + Cwd: work, + // Explicitly prohibit downloads and ignore host-specific Go settings. + Env: map[string]string{"GOPROXY": "off", "GOSUMDB": "off", "GOENV": "off", "GOWORK": "off"}, + Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr, + }) + if err != nil { + t.Fatal(err) + } + if err := p.Wait(); err != nil { + t.Fatalf("offline module lookup failed: %v\n%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), dependency+" v1.0.0\n") { + t.Fatalf("cached dependency missing from module graph: %s", stdout.String()) + } +} diff --git a/internal/capability/process_test.go b/internal/capability/process_test.go index 7ff08c9a1..1bfede6aa 100644 --- a/internal/capability/process_test.go +++ b/internal/capability/process_test.go @@ -61,8 +61,23 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) { t.Setenv("SSH_AUTH_SOCK", "/daemon-agent") t.Setenv("RANDOM_DAEMON_SECRET", "daemon-random") t.Setenv("WHIPCODE_HOME", "/snapshot-whip-home") + goEnvironment := map[string]string{ + "GOCACHE": "/snapshot-go-build-cache", + "GOMODCACHE": "/snapshot-go-module-cache", + "GOPATH": "/snapshot-gopath", + "GOROOT": "/snapshot-goroot", + "GOTOOLCHAIN": "local", + "GOFLAGS": "-mod=mod", + } + for name, value := range goEnvironment { + t.Setenv(name, value) + } + t.Setenv("GOPROXY", "https://user:secret@proxy.invalid") + t.Setenv("GOAUTH", "daemon-auth-command") + t.Setenv("GO_PRIVATE_TOKEN", "daemon-go-secret") m := NewProcessManager() t.Setenv("HOME", "/changed-after-snapshot") + t.Setenv("GOMODCACHE", "/changed-after-snapshot") f, err := os.CreateTemp(t.TempDir(), "descriptor") if err != nil { @@ -112,7 +127,12 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) { t.Errorf("%s = %q, want %q", name, env[name], want) } } - for _, name := range []string{"PROVIDER_API_KEY", "SSH_AUTH_SOCK", "RANDOM_DAEMON_SECRET"} { + for name, want := range goEnvironment { + if env[name] != want { + t.Errorf("%s = %q, want %q", name, env[name], want) + } + } + for _, name := range []string{"PROVIDER_API_KEY", "SSH_AUTH_SOCK", "RANDOM_DAEMON_SECRET", "GOPROXY", "GOAUTH", "GO_PRIVATE_TOKEN"} { if _, ok := env[name]; ok { t.Errorf("daemon environment leaked %s", name) } @@ -130,6 +150,21 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) { if built, err := m.environment(nil); err != nil || !slices.IsSorted(built) { t.Errorf("environment not deterministically sorted: %v, %v", built, err) } + // Launchers that own exec.Cmd receive the captured build settings too; + // explicit per-process values still take precedence. + child, err := m.ChildEnvironment(map[string]string{"GOFLAGS": "-mod=readonly"}) + if err != nil { + t.Fatal(err) + } + childEnv := envMap(child) + for name, want := range goEnvironment { + if name == "GOFLAGS" { + want = "-mod=readonly" + } + if childEnv[name] != want { + t.Errorf("ChildEnvironment %s = %q, want %q", name, childEnv[name], want) + } + } if err := m.Close(); err != nil { t.Fatal(err) }