package plugin_test import ( "encoding/json" "fmt" "io" "net" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" ) // Each adapter case owns its HTTP server and unread request queue, even when // an assertion terminates the case before consuming a request. func newCodexUnixSessionEndFixture(t *testing.T) (port string, requests chan string, isolationQueued, isolationHandlerDone chan struct{}) { t.Helper() requests = make(chan string, 8) isolationQueued = make(chan struct{}) isolationHandlerDone = make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/sessions/") { http.NotFound(w, r) // External unknown hosts require client inspection. return } if r.URL.Path == "/project/current" { _, _ = io.WriteString(w, `{"project":"end-project","project_source":"config"}`) return } var payload struct{ ID, Project, Directory string } if json.NewDecoder(r.Body).Decode(&payload) != nil || payload.Directory == "" || payload.Project != "end-project" { t.Error("end lost canonical project or cwd") } requests <- r.Method + " " + r.URL.EscapedPath() + " " + payload.ID if payload.ID == "slow" { if *codexSessionEndIsolationChild { close(isolationQueued) defer close(isolationHandlerDone) } <-r.Context().Done() return } _, _ = fmt.Fprintf(w, `{"id":%q,"status":"ended"}`, payload.ID) })) // httptest.Close closes connections and waits for handlers to finish, so // handler assertions cannot outlive the owning subtest, including on Fatal. t.Cleanup(server.Close) port = strings.TrimPrefix(server.URL, "http://127.0.0.1:") return } func TestCodexUnixSessionEndAdapter(t *testing.T) { bashPath := codexTestBash(t) requireCodexUnixTools(t, bashPath) adapterPath := filepath.Join(repoRoot(t), "plugin", "codex", "scripts", "session-end.sh") t.Run("posts to a valid custom port", func(t *testing.T) { port, requests, _, _ := newCodexUnixSessionEndFixture(t) assertSilentCodexUnixSessionEnd(t, bashPath, adapterPath, `{"session_id":"custom-port"}`, &port, "") assertCodexUnixRequest(t, requests, "POST /runtime-sessions/end custom-port") }) t.Run("preserves reserved session ID in JSON rather than URL path", func(t *testing.T) { port, requests, _, _ := newCodexUnixSessionEndFixture(t) assertSilentCodexUnixSessionEnd(t, bashPath, adapterPath, `{"session_id":"session id/with?characters#%"}`, &port, "") assertCodexUnixRequest(t, requests, "POST /runtime-sessions/end session id/with?characters#%") }) t.Run("bounds an unresponsive HTTP request", func(t *testing.T) { port, requests, isolationQueued, isolationHandlerDone := newCodexUnixSessionEndFixture(t) if *codexSessionEndIsolationChild { queueCodexSessionEndIsolationRequest(t, port, isolationQueued, isolationHandlerDone) if got := len(requests); got != 1 { t.Fatalf("isolation setup: queued requests = %d, want 1", got) } t.Fatal(codexSessionEndIsolationMarker) } assertSilentCodexUnixSessionEnd(t, bashPath, adapterPath, `{"session_id":"slow"}`, &port, "") assertCodexUnixRequest(t, requests, "POST /runtime-sessions/end slow") }) for _, tc := range []struct { name string input string }{ {name: "empty input", input: ""}, {name: "malformed input", input: "{"}, {name: "missing session ID", input: `{}`}, {name: "empty session ID", input: `{"session_id":""}`}, {name: "empty cwd", input: `{"session_id":"id","cwd":""}`}, {name: "numeric session ID", input: `{"session_id":42}`}, {name: "boolean session ID", input: `{"session_id":true}`}, {name: "array session ID", input: `{"session_id":[]}`}, {name: "object session ID", input: `{"session_id":{}}`}, } { t.Run(tc.name+" makes no request", func(t *testing.T) { port, requests, _, _ := newCodexUnixSessionEndFixture(t) assertSilentCodexUnixSessionEnd(t, bashPath, adapterPath, tc.input, &port, "") assertNoCodexUnixRequest(t, requests) }) } for _, tc := range []struct { name string port string }{ {name: "nonnumeric", port: "invalid"}, {name: "URL authority syntax", port: "80@host.example"}, {name: "out of range", port: "65536"}, {name: "zero", port: "0"}, } { t.Run("rejects "+tc.name+" port before curl", func(t *testing.T) { if got := codexFixtureEndpoint(t, &tc.port); got != "" { t.Fatalf("invalid port selected endpoint %q", got) } }) } for _, tc := range []struct { name string port *string }{ {name: "missing", port: nil}, {name: "blank", port: stringPointer("")}, } { t.Run(tc.name+" port defaults to 7437 without binding it", func(t *testing.T) { if got := codexFixtureEndpoint(t, tc.port); got != "http://127.0.0.1:7437" { t.Fatalf("default endpoint = %q", got) } }) } t.Run("fails open when the API closes without a response", func(t *testing.T) { failureListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen for failed response: %v", err) } defer failureListener.Close() failurePort := strings.TrimPrefix(failureListener.Addr().String(), "127.0.0.1:") accepted := make(chan struct{}) go func() { connection, acceptErr := failureListener.Accept() if acceptErr == nil { close(accepted) _ = connection.Close() } }() assertSilentCodexUnixSessionEnd(t, bashPath, adapterPath, `{"session_id":"id"}`, &failurePort, "") select { case <-accepted: case <-time.After(3 * time.Second): t.Fatal("adapter did not connect to the failure listener") } }) } func TestCodexTestBashWithoutFixtureOverrides(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Git for Windows discovery") } t.Setenv("CODEX_TEST_TRUSTED_BASH", "") t.Setenv("CODEX_TEST_TRUSTED_JQ", "") bash := codexTestBash(t) info, err := os.Stat(bash) if err != nil || !info.Mode().IsRegular() || !filepath.IsAbs(bash) || !strings.EqualFold(filepath.Base(bash), "bash.exe") { t.Fatalf("expected discovered Git Bash executable, got %q: %v", bash, err) } } func codexTestBash(t *testing.T) string { t.Helper() if runtime.GOOS != "windows" { _, err := exec.LookPath("bash") if err != nil { t.Fatalf("find bash: %v", err) } return newCodexIntentFixture(t).bash } gitPath, err := exec.LookPath("git.exe") if err != nil { t.Fatalf("find Git for Windows: %v", err) } path, err := codexTestBashCandidate(gitPath) if err != nil { t.Fatal(err) } return path } func codexTestBashCandidate(gitPath string) (string, error) { gitDir := filepath.Dir(gitPath) // Use the actual shell, not Git's bin launcher: the launcher adds a process // outside the adapter's HTTP deadline. Match the trusted fixture/CI shell. // git.exe can live in cmd, bin, usr/bin, or mingw64/bin. for _, candidate := range []string{ filepath.Join(gitDir, "bash.exe"), filepath.Join(gitDir, "..", "usr", "bin", "bash.exe"), filepath.Join(gitDir, "..", "..", "usr", "bin", "bash.exe"), } { if !strings.HasSuffix(strings.ToLower(filepath.ToSlash(filepath.Clean(candidate))), "/usr/bin/bash.exe") { continue } if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return filepath.Clean(candidate), nil } } return "", fmt.Errorf("find Git Bash near git.exe at %s", gitPath) } func TestCodexTestBashCandidates(t *testing.T) { for _, tc := range []struct { name, gitDir, bashPath string directory, missing bool }{ {name: "cmd sibling usr bin", gitDir: "cmd", bashPath: "usr/bin/bash.exe"}, {name: "bin sibling usr bin", gitDir: "bin", bashPath: "usr/bin/bash.exe"}, {name: "mingw64 sibling usr bin", gitDir: "mingw64/bin", bashPath: "usr/bin/bash.exe"}, {name: "usr bin colocated", gitDir: "usr/bin", bashPath: "usr/bin/bash.exe"}, {name: "reject bin launcher", gitDir: "cmd", bashPath: "bin/bash.exe", missing: true}, {name: "reject directory", gitDir: "cmd", bashPath: "usr/bin/bash.exe", directory: true, missing: true}, {name: "no candidate", gitDir: "cmd", missing: true}, } { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() gitPath := filepath.Join(root, filepath.FromSlash(tc.gitDir), "git.exe") if tc.bashPath != "" { candidate := filepath.Join(root, filepath.FromSlash(tc.bashPath)) if err := os.MkdirAll(filepath.Dir(candidate), 0o755); err != nil { t.Fatal(err) } if tc.directory { if err := os.Mkdir(candidate, 0o755); err != nil { t.Fatal(err) } } else if err := os.WriteFile(candidate, nil, 0o600); err != nil { t.Fatal(err) } } got, err := codexTestBashCandidate(gitPath) if tc.missing { if got != "" || err == nil || err.Error() != "find Git Bash near git.exe at "+gitPath { t.Fatalf("expected no-candidate contract, got %q, %v", got, err) } return } want := filepath.Join(root, filepath.FromSlash(tc.bashPath)) if err != nil || got != want { t.Fatalf("candidate = %q, %v; want %q", got, err, want) } }) } } func requireCodexUnixTools(t *testing.T, bashPath string) { t.Helper() cwd := t.TempDir() check := exec.Command(bashPath, "--noprofile", "--norc", "-c", "command -v jq >/dev/null && command -v curl >/dev/null") check.Env, check.Dir = codexHandoffEnv(t, cwd, "http://127.0.0.1:1"), cwd if output, err := check.CombinedOutput(); err != nil { t.Fatalf("Unix SessionEnd runtime tests require jq and curl: %v: %s", err, output) } } func assertSilentCodexUnixSessionEnd(t *testing.T, bashPath, adapterPath, input string, port *string, pathPrefix string) { t.Helper() cwd := t.TempDir() var payload map[string]any if json.Unmarshal([]byte(input), &payload) == nil && payload != nil { if _, present := payload["cwd"]; !present { payload["cwd"] = cwd } data, _ := json.Marshal(payload) input = string(data) } run := exec.Command(bashPath, adapterPath) run.Dir = cwd id, validID := payload["session_id"].(string) validInput := validID && strings.TrimSpace(id) != "" && payload["cwd"] == cwd if validInput { codexFixtureProject(t, cwd, "end-project") } run.Env = codexHandoffEnv(t, cwd, "http://127.0.0.1:"+*port, buildCodexFixtureCLI(t)) run.Stdin = strings.NewReader(input) var stdout, stderr strings.Builder run.Stdout = &stdout run.Stderr = &stderr started := time.Now() err := run.Run() if elapsed := time.Since(started); elapsed > 2*time.Second { t.Fatalf("closure exceeded two seconds: %s", elapsed) } code := 0 if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { code = exitErr.ExitCode() } else { t.Fatalf("run Unix adapter: %v", err) } } if !validInput { if _, err := os.Stat(filepath.Join(cwd, ".engram")); !os.IsNotExist(err) { t.Fatal("rejected input acquired project configuration") } } if code != 0 || stdout.String() != "" || stderr.String() != "" { t.Fatalf("exit=%d stdout=%q stderr=%q, want silent exit 0", code, stdout.String(), stderr.String()) } } func assertCodexUnixRequest(t *testing.T, requests <-chan string, want string) { t.Helper() select { case got := <-requests: if got != want { t.Errorf("request = %q, want %q", got, want) } case <-time.After(3 * time.Second): t.Fatal("adapter did not post to the loopback server") } } func assertNoCodexUnixRequest(t *testing.T, requests <-chan string) { t.Helper() select { case got := <-requests: t.Fatalf("unexpected request %q", got) default: } } func codexFixtureEndpoint(t *testing.T, port *string) string { t.Helper() data, err := os.ReadFile(filepath.Join(repoRoot(t), "cmd", "engram", "hook_codex.go")) if err != nil { t.Fatal(err) } _, body, found := strings.Cut(strings.ReplaceAll(string(data), "\r\n", "\n"), "func codexHookURL() string {") body, _, ended := strings.Cut(body, "\n}\n") if !found || !ended { t.Fatal("endpoint selector boundary changed") } dir := t.TempDir() source := "package main\nimport (\"fmt\";\"os\";\"strconv\";\"strings\")\nfunc codexHookURL() string {" + body + "\n}\nfunc main(){fmt.Print(codexHookURL())}\n" if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(source), 0o600); err != nil { t.Fatal(err) } binary := filepath.Join(dir, "endpoint.exe") build := exec.Command("go", "build", "-o", binary, filepath.Join(dir, "main.go")) if output, err := build.CombinedOutput(); err != nil { t.Fatalf("build endpoint probe: %v: %s", err, output) } run := exec.Command(binary) run.Env = []string{"ENGRAM_URL=", "ENGRAM_SOCKET="} if port != nil { run.Env = append(run.Env, "ENGRAM_PORT="+*port) } output, err := run.Output() if err != nil { t.Fatal(err) } return string(output) }