From 15c217de83a744a55665a45f81c2c11432255ce1 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Fri, 24 Apr 2026 14:29:52 +0200 Subject: [PATCH 1/3] test: e2e suite against the compiled binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New e2e/ package. TestMain builds the hearsay binary into a temp dir once per test run; each test then starts a subprocess with an isolated HOME + scratch ~/.claude/projects/ tree and drives it over real HTTP. Covers the cross-process paths the in-package unit tests don't reach: * MCP streamable-HTTP handshake end-to-end * get_peer_info / list_sessions / read_session over the wire, via real mcp.StreamableClientTransport * Bearer-token auth rejection (missing / wrong scheme / wrong token) * 'hearsay invite' URI generation matches the on-disk token * claude-md install/uninstall round-trip via the compiled binary * 'hearsay pair' shells out to a stubbed `claude` on PATH; we capture the argv and assert the shape of the 'claude mcp add' invocation Runs as part of 'go test ./...' — no new CI steps needed; existing coverage gate is unaffected (e2e package has no production statements to drive the aggregate number). --- e2e/hearsay_e2e_test.go | 503 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 e2e/hearsay_e2e_test.go diff --git a/e2e/hearsay_e2e_test.go b/e2e/hearsay_e2e_test.go new file mode 100644 index 0000000..8c305c5 --- /dev/null +++ b/e2e/hearsay_e2e_test.go @@ -0,0 +1,503 @@ +// Package e2e holds end-to-end tests that exercise a compiled hearsay +// binary over real HTTP. Unit tests in the internal packages cover the +// pieces in isolation; this file proves they assemble correctly at the +// process boundary: +// +// - CLI flag parsing in a real os.Args environment +// - Config file creation on disk (~/Library/Application Support/hearsay) +// - HTTP listener on an ephemeral TCP port +// - MCP streamable-HTTP handshake end-to-end, over the wire +// - Bearer-token middleware on real incoming requests +// - Invite URI generation matching the stored config +// - Graceful shutdown on SIGTERM +// - claude-md install/uninstall round-trip +// - hearsay pair → stubbed `claude mcp add` invocation +// +// A single TestMain builds the binary once into a temp dir; each Test* +// function spins up (or reuses) a fresh server process with an +// isolated HOME and ~/.claude/projects/ tree. +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// binaryPath is set by TestMain — a freshly-compiled hearsay binary all +// tests in this file share. +var binaryPath string + +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "hearsay-e2e-bin-") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create tempdir: %v\n", err) + os.Exit(1) + } + defer os.RemoveAll(tmp) + + binaryPath = filepath.Join(tmp, "hearsay") + if runtime.GOOS == "windows" { + binaryPath += ".exe" + } + // Compile with the module rooted one directory up from this file + // (the e2e/ package sits alongside cmd/ and internal/). + cmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/hearsay") + cmd.Dir = ".." + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "go build failed: %v\n", err) + os.Exit(1) + } + os.Exit(m.Run()) +} + +// ----------------------------------------------------------------------- +// Test harness: start a hearsay server in a scratch HOME with a tiny +// fake ~/.claude/projects/ tree so list_sessions has something to return. + +type fixture struct { + t *testing.T + home string + dataDir string + port int + baseURL string + token string + stderr *strings.Builder + cmd *exec.Cmd + shutdown func() +} + +// startServer launches hearsay as a child process on an ephemeral port, +// waits for /health to respond, reads back the generated token from its +// on-disk config, and returns a fixture tests can drive. +func startServer(t *testing.T, name string) *fixture { + t.Helper() + + home := t.TempDir() + dataDir := t.TempDir() + seedFakeSession(t, dataDir) + + // Pick a port by opening a short-lived listener; we close it + // before hearsay binds. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + stderrBuf := &strings.Builder{} + cmd := exec.Command(binaryPath, + "--name", name, + "--port", fmt.Sprint(port), + "--bind", "127.0.0.1", + "--data-dir", dataDir, + ) + cmd.Env = append(os.Environ(), + "HOME="+home, + "XDG_CONFIG_HOME=", // force config.Dir() back to its default + ) + cmd.Stdout = io.Discard + cmd.Stderr = &teeWriter{dst: stderrBuf} + + if err := cmd.Start(); err != nil { + t.Fatalf("start hearsay: %v", err) + } + + baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) + waitForHealth(t, baseURL, 3*time.Second) + + shutdown := func() { + if cmd.Process != nil { + _ = cmd.Process.Signal(syscall.SIGTERM) + } + _ = cmd.Wait() + } + t.Cleanup(shutdown) + + return &fixture{ + t: t, + home: home, + dataDir: dataDir, + port: port, + baseURL: baseURL, + token: readToken(t, home), + stderr: stderrBuf, + cmd: cmd, + shutdown: shutdown, + } +} + +// teeWriter duplicates child-process stderr into a test-scoped buffer +// so t.Logf and assertions can inspect it. +type teeWriter struct{ dst *strings.Builder } + +func (w *teeWriter) Write(p []byte) (int, error) { + w.dst.Write(p) + return len(p), nil +} + +// seedFakeSession writes a minimal JSONL into the scratch data dir so +// list_sessions has one session to find. File mtime is "now", so the +// session is flagged isLive. +func seedFakeSession(t *testing.T, dataDir string) { + t.Helper() + projects := filepath.Join(dataDir, "projects", "-tmp-e2e") + if err := os.MkdirAll(projects, 0o755); err != nil { + t.Fatalf("mkdir projects: %v", err) + } + session := `{"type":"user","uuid":"u1","timestamp":"2026-04-24T10:00:00Z","sessionId":"e2eSSS","message":{"role":"user","content":"e2e test session first prompt"}} +{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2026-04-24T10:00:01Z","sessionId":"e2eSSS","message":{"role":"assistant","content":[{"type":"text","text":"assistant reply"}]}} +` + if err := os.WriteFile(filepath.Join(projects, "e2eSSS.jsonl"), []byte(session), 0o644); err != nil { + t.Fatalf("write session: %v", err) + } +} + +// waitForHealth polls /health until it returns 200 or the deadline +// passes. If the server never came up, fail the test with whatever +// stderr captured. +func waitForHealth(t *testing.T, baseURL string, budget time.Duration) { + t.Helper() + deadline := time.Now().Add(budget) + for { + resp, err := http.Get(baseURL + "/health") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("hearsay never became healthy at %s within %s", baseURL, budget) + } + time.Sleep(25 * time.Millisecond) + } +} + +// readToken parses the on-disk config.json under the scratch HOME to +// retrieve the bearer token the server generated at first run. +func readToken(t *testing.T, home string) string { + t.Helper() + // Both platforms for the config-dir scan. + candidates := []string{ + filepath.Join(home, "Library", "Application Support", "hearsay", "config.json"), + filepath.Join(home, ".config", "hearsay", "config.json"), + } + for _, c := range candidates { + raw, err := os.ReadFile(c) + if err != nil { + continue + } + var cfg struct { + Token string `json:"token"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parse config.json at %s: %v", c, err) + } + if cfg.Token == "" { + t.Fatalf("config.json at %s has empty token", c) + } + return cfg.Token + } + t.Fatalf("no config.json found under %s", home) + return "" +} + +// bearerTransport stamps the hearsay Bearer token onto every outgoing +// request — the consumer analogue of what Claude Code does on Wiktor's +// side. +type bearerTransport struct { + token string + next http.RoundTripper +} + +func (b *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+b.token) + return b.next.RoundTrip(req) +} + +// connectMCP returns a logged-in MCP client session talking to the +// fixture's server over real HTTP (not in-memory transport). +func (f *fixture) connectMCP(t *testing.T) *mcp.ClientSession { + t.Helper() + transport := &mcp.StreamableClientTransport{ + Endpoint: f.baseURL + "/mcp", + HTTPClient: &http.Client{ + Transport: &bearerTransport{token: f.token, next: http.DefaultTransport}, + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := mcp.NewClient(&mcp.Implementation{Name: "e2e-client", Version: "0"}, nil) + cs, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("mcp connect: %v", err) + } + t.Cleanup(func() { cs.Close() }) + return cs +} + +// ----------------------------------------------------------------------- +// The actual tests. + +// Exercises the full Happy Path: start server, MCP handshake, +// get_peer_info, list_sessions, read_session. Everything flows over +// real HTTP. +func TestE2E_HealthAndMCP(t *testing.T) { + f := startServer(t, "e2e-peer") + + // /health first — confirms the server is up and returns its identity. + resp, err := http.Get(f.baseURL + "/health") + if err != nil { + t.Fatalf("health: %v", err) + } + defer resp.Body.Close() + var h map[string]any + if err := json.NewDecoder(resp.Body).Decode(&h); err != nil { + t.Fatalf("decode health: %v", err) + } + if h["name"] != "e2e-peer" { + t.Errorf("health name = %v, want e2e-peer", h["name"]) + } + + // MCP handshake + tool calls. + cs := f.connectMCP(t) + ctx := context.Background() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "get_peer_info", Arguments: map[string]any{}}) + if err != nil { + t.Fatalf("get_peer_info: %v", err) + } + info := structured(t, res) + if info["name"] != "e2e-peer" { + t.Errorf("peer_info.name = %v", info["name"]) + } + if info["activeSessionCount"].(float64) < 1 { + t.Errorf("expected >=1 live session, got %v", info["activeSessionCount"]) + } + + res, err = cs.CallTool(ctx, &mcp.CallToolParams{Name: "list_sessions", Arguments: map[string]any{}}) + if err != nil { + t.Fatalf("list_sessions: %v", err) + } + lss := structured(t, res) + sessions, ok := lss["sessions"].([]any) + if !ok || len(sessions) != 1 { + t.Fatalf("list_sessions returned %v", lss) + } + first := sessions[0].(map[string]any) + if first["sessionId"] != "e2eSSS" { + t.Errorf("sessionId = %v", first["sessionId"]) + } + + // read_session returns markdown content + JSON metadata. + res, err = cs.CallTool(ctx, &mcp.CallToolParams{ + Name: "read_session", + Arguments: map[string]any{"sessionId": "e2eSSS"}, + }) + if err != nil { + t.Fatalf("read_session: %v", err) + } + if res.IsError { + t.Fatalf("read_session flagged error; content: %v", res.Content) + } + if len(res.Content) == 0 { + t.Fatalf("expected markdown content") + } + tc, ok := res.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("first content block is not TextContent: %T", res.Content[0]) + } + if !strings.Contains(tc.Text, "e2e test session first prompt") { + t.Errorf("markdown missing user prompt: %s", tc.Text) + } +} + +// 401-without-token and 401-with-wrong-token paths on the real HTTP +// listener. +func TestE2E_AuthRejection(t *testing.T) { + f := startServer(t, "auth-e2e") + + for _, tc := range []struct { + name string + header string + }{ + {"no auth", ""}, + {"wrong scheme", "Basic dXNlcjpwYXNz"}, + {"wrong token", "Bearer wrong-token"}, + } { + t.Run(tc.name, func(t *testing.T) { + req, _ := http.NewRequest("POST", f.baseURL+"/mcp", strings.NewReader("{}")) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + }) + } +} + +// `hearsay invite` prints a URI that embeds the stored name + token and +// the passed host/port. +func TestE2E_InviteURI(t *testing.T) { + f := startServer(t, "invite-e2e") + + out, err := runCLI(t, f.home, binaryPath, "invite", "--host", "127.0.0.1", "--port", fmt.Sprint(f.port)) + if err != nil { + t.Fatalf("hearsay invite: %v\nstderr: %s", err, out.stderr) + } + uri := strings.TrimSpace(out.stdout) + prefix := fmt.Sprintf("hearsay://invite-e2e@127.0.0.1:%d/mcp?token=", f.port) + if !strings.HasPrefix(uri, prefix) { + t.Errorf("invite URI unexpected: %q", uri) + } + if !strings.Contains(uri, f.token) { + t.Errorf("invite URI missing bearer token") + } +} + +// Full claude-md install → uninstall round-trip driven through the +// compiled binary. +func TestE2E_ClaudeMdRoundtrip(t *testing.T) { + home := t.TempDir() + target := filepath.Join(home, "CLAUDE.md") + + if _, err := runCLI(t, home, binaryPath, "claude-md", "install", "--path", target); err != nil { + t.Fatalf("install: %v", err) + } + body, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if !strings.Contains(string(body), "hearsay:consumer-auto-start") { + t.Errorf("block not written: %s", body) + } + + if _, err := runCLI(t, home, binaryPath, "claude-md", "uninstall", "--path", target); err != nil { + t.Fatalf("uninstall: %v", err) + } + body, _ = os.ReadFile(target) + if strings.Contains(string(body), "hearsay:consumer-auto-start") { + t.Errorf("markers still present after uninstall: %s", body) + } +} + +// `hearsay pair` shells out to `claude mcp add`. We stub `claude` with +// a script that records its argv so we can confirm the invocation was +// shaped correctly. Sidesteps needing real Claude Code on the CI runner. +func TestE2E_PairWithStubbedClaude(t *testing.T) { + f := startServer(t, "pair-e2e") + + stubDir := t.TempDir() + argsCapturePath := filepath.Join(stubDir, "args.txt") + stubScript := "#!/bin/sh\nprintf '%s\\0' \"$@\" > " + argsCapturePath + "\nexit 0\n" + stubPath := filepath.Join(stubDir, "claude") + if err := os.WriteFile(stubPath, []byte(stubScript), 0o755); err != nil { + t.Fatalf("write claude stub: %v", err) + } + + invite := fmt.Sprintf("hearsay://pair-e2e@127.0.0.1:%d/mcp?token=%s", f.port, f.token) + out, err := runCLIWithExtraPath(t, f.home, stubDir, binaryPath, "pair", invite) + if err != nil { + t.Fatalf("hearsay pair: %v\nstdout: %s\nstderr: %s", err, out.stdout, out.stderr) + } + + raw, err := os.ReadFile(argsCapturePath) + if err != nil { + t.Fatalf("read captured args: %v", err) + } + // Split null-separated argv tokens the stub wrote. + argv := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00") + + // Expect: ["mcp","add","--scope","user","--transport","http","pair-e2e","","-H","Authorization: Bearer "] + wantPrefix := []string{"mcp", "add", "--scope", "user", "--transport", "http", "pair-e2e"} + for i, w := range wantPrefix { + if i >= len(argv) || argv[i] != w { + t.Fatalf("argv[%d] = %q, want %q (full argv: %v)", i, safeIdx(argv, i), w, argv) + } + } + if !strings.HasPrefix(argv[7], fmt.Sprintf("http://127.0.0.1:%d", f.port)) { + t.Errorf("url arg unexpected: %q", argv[7]) + } + if !strings.Contains(argv[9], "Bearer "+f.token) { + t.Errorf("authorization header arg missing token: %q", argv[9]) + } +} + +// ----------------------------------------------------------------------- +// Small helpers shared across tests. + +type cliOutput struct { + stdout string + stderr string +} + +func runCLI(t *testing.T, home, bin string, args ...string) (cliOutput, error) { + t.Helper() + cmd := exec.Command(bin, args...) + cmd.Env = append(os.Environ(), "HOME="+home, "XDG_CONFIG_HOME=") + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return cliOutput{stdout: stdout.String(), stderr: stderr.String()}, err +} + +func runCLIWithExtraPath(t *testing.T, home, extraPathDir, bin string, args ...string) (cliOutput, error) { + t.Helper() + cmd := exec.Command(bin, args...) + // Prepend the stub dir to PATH so `exec.LookPath("claude")` finds + // our fake binary first. + envPath := extraPathDir + string(os.PathListSeparator) + os.Getenv("PATH") + cmd.Env = append(os.Environ(), "HOME="+home, "XDG_CONFIG_HOME=", "PATH="+envPath) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return cliOutput{stdout: stdout.String(), stderr: stderr.String()}, err +} + +func structured(t *testing.T, res *mcp.CallToolResult) map[string]any { + t.Helper() + if res.StructuredContent == nil { + t.Fatalf("no structured content in result") + } + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatalf("marshal structured: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal structured: %v", err) + } + return m +} + +func safeIdx(s []string, i int) string { + if i < 0 || i >= len(s) { + return "" + } + return s[i] +} From 6d0a21314d972f3129f77fd4bd4a68fb782620a9 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Fri, 24 Apr 2026 14:32:40 +0200 Subject: [PATCH 2/3] fix(gitignore): anchor /hearsay so cmd/hearsay/ stays tracked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .gitignore line 'hearsay' (no leading slash) was matching every path component named 'hearsay' — including the cmd/hearsay/ source directory. As a result cmd/hearsay/main.go and cmd/hearsay/main_test.go were never committed to the repo despite existing on my working copy for weeks. CI didn't catch it because: - go build ./... and go test ./... silently skip packages whose directories contain no .go files (from git's perspective, cmd/ on main is empty). - The internal/ unit tests ran fine without the binary. Adding the long-missing files and anchoring the gitignore rule to the repo root (/hearsay) so only the compiled artifact at the root is ignored. Now CI will actually build+test the hearsay binary itself. --- .gitignore | 6 +- cmd/hearsay/main.go | 518 ++++++++++++++++++++++++++++ cmd/hearsay/main_test.go | 727 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 1250 insertions(+), 1 deletion(-) create mode 100644 cmd/hearsay/main.go create mode 100644 cmd/hearsay/main_test.go diff --git a/.gitignore b/.gitignore index cd1fa87..03f0c29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ /dist/ -hearsay +# Anchor the binary-ignore to the repo root so `cmd/hearsay/` (the +# source directory) isn't matched by this rule. Without the leading +# slash, git treats every path component called `hearsay` as ignored — +# which silently dropped cmd/hearsay/*.go out of history for a while. +/hearsay *.test *.out .DS_Store diff --git a/cmd/hearsay/main.go b/cmd/hearsay/main.go new file mode 100644 index 0000000..9187392 --- /dev/null +++ b/cmd/hearsay/main.go @@ -0,0 +1,518 @@ +// Command hearsay is an MCP server that exposes Claude Code session +// transcripts from ~/.claude/projects/ over HTTP, so a teammate's Claude +// can read them directly. See the plan at +// /Users/celrisen/.claude/plans/lets-prototype-the-transcript-reader-toasty-cake.md +// for the full design. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net/url" + "os" + "os/exec" + "os/signal" + "regexp" + "strings" + "syscall" + "time" + + "github.com/WiktorStarczewski/hearsay/internal/claudemd" + "github.com/WiktorStarczewski/hearsay/internal/config" + "github.com/WiktorStarczewski/hearsay/internal/server" + "github.com/WiktorStarczewski/hearsay/internal/tailscale" +) + +// version is overridable at build time via: +// go build -ldflags "-X main.version=" ./cmd/hearsay +var version = "0.1.0-dev" + +func main() { + os.Exit(dispatch(os.Args[1:])) +} + +// dispatch is the testable core of main(): it takes the args slice and +// returns an exit code instead of calling os.Exit directly, so unit +// tests can drive it without tearing down the test process. +func dispatch(args []string) int { + // Top-level convenience flags (`hearsay --version`, `hearsay -h`) + // are handled before the subcommand switch so they don't end up + // getting interpreted as server flags. + if len(args) > 0 { + switch args[0] { + case "--version": + fmt.Println(version) + return 0 + case "--help", "-h": + printHelp() + return 0 + } + } + + // Subcommand names are checked ahead of `flag` parsing so they + // don't get confused with flag tokens. + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + switch args[0] { + case "claude-md": + return runClaudeMd(args[1:]) + case "add-peer": + return runAddPeer(args[1:]) + case "remove-peer": + return runRemovePeer(args[1:]) + case "invite": + return runInvite(args[1:]) + case "pair": + return runPair(args[1:]) + case "version": + fmt.Println(version) + return 0 + case "help": + printHelp() + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown subcommand %q\n\n", args[0]) + printHelp() + return 2 + } + } + return runServer(args) +} + +func printHelp() { + fmt.Println(`hearsay — expose Claude Code session transcripts over MCP + +USAGE + hearsay [flags] run the MCP server (default — teammate side) + hearsay invite print a hearsay:// invite URI (teammate side) + hearsay pair register a peer from a hearsay:// URI (consumer) + hearsay add-peer ... register a teammate's hearsay server (consumer, explicit) + hearsay remove-peer un-register a teammate's hearsay server + hearsay claude-md ... manage the ~/.claude/CLAUDE.md block + hearsay version print version and exit + +SERVER FLAGS + --name peer identity; required on first run, persisted thereafter + --port listen port (default 3456) + --bind bind address (default: tailscale IPv4, falls back to 127.0.0.1) + --data-dir Claude Code data dir (default ~/.claude) + --live-window-seconds isLive threshold (default 300) + --regenerate-token rotate the stored bearer token and print it + --quiet suppress tool-call logs + +ADD-PEER FLAGS + --url peer's hearsay MCP URL (e.g. http://ivan-mac.tailXXXX.ts.net:3456/mcp) + --token bearer token the peer issued + --scope claude mcp scope: user, project, or local (default user) + +CLAUDE.MD SUBCOMMANDS + hearsay claude-md install [--role consumer|peer] [--path ] + hearsay claude-md print [--role consumer|peer] + hearsay claude-md uninstall [--role consumer|peer] [--path ]`) +} + +// ---------------- run server ---------------- + +// defaultSignalChan wires up a channel that fires on SIGINT/SIGTERM. +// Factored out so tests can pass an explicit channel to runServerWithSignals. +func defaultSignalChan() <-chan os.Signal { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + return ch +} + +func runServer(args []string) int { + return runServerWithSignals(args, defaultSignalChan()) +} + +func runServerWithSignals(args []string, sigCh <-chan os.Signal) int { + fs := flag.NewFlagSet("hearsay", flag.ContinueOnError) + var ( + name = fs.String("name", "", "peer identity (required on first run)") + port = fs.Int("port", 3456, "listen port") + bind = fs.String("bind", "", "bind address (default: tailscale IPv4, else 127.0.0.1)") + dataDir = fs.String("data-dir", "", "Claude Code data dir (default ~/.claude)") + liveWindowSecs = fs.Int("live-window-seconds", 300, "isLive threshold") + regenerateToken = fs.Bool("regenerate-token", false, "rotate the stored bearer token") + quiet = fs.Bool("quiet", false, "suppress tool-call logs") + ) + if err := fs.Parse(args); err != nil { + return 2 + } + + // Resolve config (possibly first-run or token rotation). + resolved, err := config.Resolve(config.ResolveOptions{ + NameOverride: *name, + RegenerateToken: *regenerateToken, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "hearsay: %v\n", err) + return 1 + } + + effectiveBind := *bind + bindSource := "explicit --bind" + if effectiveBind == "" { + if ip := tailscale.DetectIPv4(); ip != "" { + effectiveBind = ip + bindSource = "tailscale" + } else { + effectiveBind = "127.0.0.1" + bindSource = "tailscale not detected — pass --bind to expose" + } + } + + srv, err := server.Start(server.Options{ + Port: *port, + Bind: effectiveBind, + Token: resolved.Config.Token, + PeerName: resolved.Config.Name, + PeerVersion: version, + DataDir: *dataDir, + LiveWindow: time.Duration(*liveWindowSecs) * time.Second, + Quiet: *quiet, + }) + if err != nil { + if isAddrInUse(err) { + fmt.Fprintf(os.Stderr, "port %d already in use — pass --port to pick another\n", *port) + } else { + fmt.Fprintf(os.Stderr, "hearsay: listen error: %v\n", err) + } + return 1 + } + + fmt.Fprintf(os.Stderr, "hearsay listening on %s:%d (%s)\n", effectiveBind, *port, bindSource) + fmt.Fprintf(os.Stderr, "peer name: %s version: %s\n", resolved.Config.Name, version) + if resolved.IsFirstRun { + fmt.Fprintf(os.Stderr, "first-run token (save this — Wiktor will need it):\n %s\n", resolved.Config.Token) + } else if resolved.TokenWasRegenerated { + fmt.Fprintf(os.Stderr, "NEW token (distribute and update CLAUDE_QA_TOKEN-style envs):\n %s\n", resolved.Config.Token) + } + + // Graceful shutdown on signal (usually SIGINT / SIGTERM; tests may + // deliver a synthetic close). + <-sigCh + fmt.Fprintln(os.Stderr, "hearsay: shutting down…") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + fmt.Fprintf(os.Stderr, "hearsay: shutdown error: %v\n", err) + return 1 + } + return 0 +} + +// isAddrInUse treats EADDRINUSE specially so the operator gets a +// friendlier nudge than a raw stack trace. +func isAddrInUse(err error) bool { + if err == nil { + return false + } + // Inspect error string — net.OpError wraps the underlying syscall + // error in a way that's easier to substring-match than to errors.As. + return strings.Contains(err.Error(), "address already in use") +} + +// ---------------- claude-md subcommand ---------------- + +func runClaudeMd(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: hearsay claude-md {install|print|uninstall} [--role consumer|peer] [--path ]") + return 2 + } + action := args[0] + fs := flag.NewFlagSet("hearsay claude-md", flag.ContinueOnError) + var ( + role = fs.String("role", "consumer", "block role: consumer or peer") + path = fs.String("path", "", "target CLAUDE.md path (default ~/.claude/CLAUDE.md)") + ) + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + + r, err := parseRole(*role) + if err != nil { + fmt.Fprintf(os.Stderr, "hearsay claude-md: %v\n", err) + return 2 + } + + switch action { + case "install": + result, err := claudemd.Install(r, *path) + if err != nil { + fmt.Fprintf(os.Stderr, "hearsay claude-md: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "claude-md: %s block → %s (%s)\n", *role, result.Path, result.Action) + return 0 + case "print": + fmt.Print(claudemd.Print(r)) + return 0 + case "uninstall": + result, err := claudemd.Uninstall(r, *path) + if err != nil { + fmt.Fprintf(os.Stderr, "hearsay claude-md: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "claude-md: %s block → %s (%s)\n", *role, result.Path, result.Action) + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown claude-md action %q\n", action) + return 2 + } +} + +func parseRole(s string) (claudemd.Role, error) { + switch s { + case "consumer": + return claudemd.RoleConsumer, nil + case "peer": + return claudemd.RolePeer, nil + default: + return "", errors.New("--role must be 'consumer' or 'peer'") + } +} + +// extractFirstPositional returns the first non-flag argument and the +// remaining slice (with that arg removed) so callers can run a flag +// parser on the rest. Necessary because Go's stdlib flag package stops +// parsing at the first positional, but we want users to be able to +// write `hearsay add-peer ivan --url X --token Y` with the name first. +func extractFirstPositional(args []string) (string, []string) { + for i, a := range args { + if strings.HasPrefix(a, "-") { + continue + } + return a, append(append([]string{}, args[:i]...), args[i+1:]...) + } + return "", args +} + +// ---------------- add-peer / remove-peer (consumer side) ---------------- + +// peerNameRe constrains peer names to what Claude Code accepts as an +// mcpServers label and what reads cleanly as "teammate's first name" +// (lowercase, alphanumeric + dash, 1–32 chars). +var peerNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) + +// runAddPeer registers a teammate's hearsay server with Claude Code's +// MCP config by shelling out to `claude mcp add`. This turns the +// "install the hearsay mcp server for Ivan at X with token Y" prompt +// into a single CLI call (see consumer CLAUDE.md block for the mapping). +func runAddPeer(args []string) int { + // Pull the first positional (the name) out of args before flag.Parse + // so `hearsay add-peer ivan --url X --token Y` works regardless of + // arg order — Go's stdlib flag package otherwise stops at the first + // non-flag token. + name, rest := extractFirstPositional(args) + + fs := flag.NewFlagSet("hearsay add-peer", flag.ContinueOnError) + var ( + peerURL = fs.String("url", "", "peer's hearsay MCP URL (http://host:port/mcp)") + token = fs.String("token", "", "bearer token the peer issued") + scope = fs.String("scope", "user", "claude mcp scope (user|project|local)") + ) + if err := fs.Parse(rest); err != nil { + return 2 + } + if name == "" { + name = fs.Arg(0) + } + if name == "" { + fmt.Fprintln(os.Stderr, "usage: hearsay add-peer --url --token [--scope user]") + return 2 + } + if !peerNameRe.MatchString(name) { + fmt.Fprintf(os.Stderr, "hearsay add-peer: name %q is invalid — must be lowercase alphanumeric + dash, starting with a letter (e.g. 'ivan')\n", name) + return 2 + } + if *peerURL == "" { + fmt.Fprintln(os.Stderr, "hearsay add-peer: --url is required") + return 2 + } + if _, err := url.Parse(*peerURL); err != nil { + fmt.Fprintf(os.Stderr, "hearsay add-peer: invalid --url: %v\n", err) + return 2 + } + if *token == "" { + fmt.Fprintln(os.Stderr, "hearsay add-peer: --token is required") + return 2 + } + + return addPeerExec(name, *peerURL, *token, *scope) +} + +// ---------------- invite / pair (one-URI handshake) ---------------- +// +// The invite flow lets Ivan paste one line to Wiktor rather than three +// fields. Ivan runs `hearsay invite` which synthesizes a URI like: +// +// hearsay://ivan@ivan-mac.tail1234.ts.net:3456/mcp?token= +// +// Wiktor runs `hearsay pair ` (or tells his Claude "install this +// hearsay invite: " if the consumer CLAUDE.md block is installed). +// Pair parses the URI and delegates to the same `claude mcp add` +// plumbing that `add-peer` uses. + +// runInvite prints a hearsay:// URI Ivan can share with Wiktor over a +// secret channel (1Password, Signal, etc.). The host is auto-detected +// from Tailscale (MagicDNS name) when available; --host overrides. +func runInvite(args []string) int { + fs := flag.NewFlagSet("hearsay invite", flag.ContinueOnError) + var ( + host = fs.String("host", "", "hostname to embed (default: Tailscale MagicDNS name)") + port = fs.Int("port", 3456, "port the server is listening on") + path = fs.String("path", "/mcp", "HTTP path") + ) + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "hearsay invite: %v\n", err) + return 1 + } + if cfg == nil { + fmt.Fprintln(os.Stderr, "hearsay invite: no config found. Run `hearsay --name ` at least once to initialize.") + return 1 + } + + effectiveHost := *host + if effectiveHost == "" { + if dns := tailscale.SelfDNSName(); dns != "" { + effectiveHost = dns + } else if ip := tailscale.DetectIPv4(); ip != "" { + effectiveHost = ip + } + } + if effectiveHost == "" { + fmt.Fprintln(os.Stderr, "hearsay invite: could not detect host — Tailscale isn't running or the CLI is not on PATH. Pass --host .") + return 1 + } + + invite := url.URL{ + Scheme: "hearsay", + User: url.User(cfg.Name), + Host: fmt.Sprintf("%s:%d", effectiveHost, *port), + Path: *path, + RawQuery: url.Values{"token": []string{cfg.Token}}.Encode(), + } + fmt.Println(invite.String()) + return 0 +} + +// runPair accepts a hearsay:// URI and registers the peer. Thin wrapper +// around the add-peer logic — parses the URI, validates it, then shells +// out to `claude mcp add` the same way add-peer does. +func runPair(args []string) int { + uri, rest := extractFirstPositional(args) + fs := flag.NewFlagSet("hearsay pair", flag.ContinueOnError) + scope := fs.String("scope", "user", "claude mcp scope (user|project|local)") + if err := fs.Parse(rest); err != nil { + return 2 + } + if uri == "" { + uri = fs.Arg(0) + } + if uri == "" { + fmt.Fprintln(os.Stderr, "usage: hearsay pair [--scope user]") + return 2 + } + + name, httpURL, token, err := parseInvite(uri) + if err != nil { + fmt.Fprintf(os.Stderr, "hearsay pair: %v\n", err) + return 2 + } + return addPeerExec(name, httpURL, token, *scope) +} + +// parseInvite unpacks a hearsay:// URI into the three fields add-peer +// needs. The scheme-to-HTTP translation is deterministic: hearsay:// +// always maps to http:// (WireGuard encrypts the tailnet transport). +func parseInvite(raw string) (name, httpURL, token string, err error) { + u, perr := url.Parse(raw) + if perr != nil { + return "", "", "", fmt.Errorf("invalid URI: %w", perr) + } + if u.Scheme != "hearsay" { + return "", "", "", fmt.Errorf("expected scheme 'hearsay://', got %q", u.Scheme) + } + if u.User == nil || u.User.Username() == "" { + return "", "", "", errors.New("URI must carry a peer name (hearsay://@host:port/…)") + } + name = u.User.Username() + if !peerNameRe.MatchString(name) { + return "", "", "", fmt.Errorf("peer name %q is invalid — must be lowercase alphanumeric + dash starting with a letter", name) + } + if u.Host == "" { + return "", "", "", errors.New("URI must carry a host") + } + token = u.Query().Get("token") + if token == "" { + return "", "", "", errors.New("URI must carry a token query parameter") + } + path := u.Path + if path == "" { + path = "/mcp" + } + httpURL = (&url.URL{Scheme: "http", Host: u.Host, Path: path}).String() + return name, httpURL, token, nil +} + +// addPeerExec is the `claude mcp add` shell-out, factored so both +// add-peer and pair reuse it. +func addPeerExec(name, httpURL, token, scope string) int { + claude, err := exec.LookPath("claude") + if err != nil { + fmt.Fprintln(os.Stderr, "hearsay: `claude` CLI not on PATH — install Claude Code first (https://claude.com/claude-code)") + return 1 + } + cmd := exec.Command(claude, "mcp", "add", + "--scope", scope, + "--transport", "http", + name, httpURL, + "-H", "Authorization: Bearer "+token, + ) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "hearsay: `claude mcp add` failed: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "hearsay: registered peer %q (scope=%s). Restart Claude Code (or run /mcp) to see %q with its tools.\n", name, scope, name) + return 0 +} + +// runRemovePeer deletes a previously-added peer entry. +func runRemovePeer(args []string) int { + name, rest := extractFirstPositional(args) + fs := flag.NewFlagSet("hearsay remove-peer", flag.ContinueOnError) + scope := fs.String("scope", "user", "claude mcp scope (user|project|local)") + if err := fs.Parse(rest); err != nil { + return 2 + } + if name == "" { + name = fs.Arg(0) + } + if name == "" { + fmt.Fprintln(os.Stderr, "usage: hearsay remove-peer [--scope user]") + return 2 + } + claude, err := exec.LookPath("claude") + if err != nil { + fmt.Fprintln(os.Stderr, "hearsay remove-peer: `claude` CLI not on PATH") + return 1 + } + cmd := exec.Command(claude, "mcp", "remove", "--scope", *scope, name) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "hearsay remove-peer: `claude mcp remove` failed: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "hearsay: removed peer %q (scope=%s). Restart Claude Code to drop its tools.\n", name, *scope) + return 0 +} diff --git a/cmd/hearsay/main_test.go b/cmd/hearsay/main_test.go new file mode 100644 index 0000000..5b2e13f --- /dev/null +++ b/cmd/hearsay/main_test.go @@ -0,0 +1,727 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/WiktorStarczewski/hearsay/internal/claudemd" + "github.com/WiktorStarczewski/hearsay/internal/config" +) + +// ----------------------------------------------------------------------- +// Pure-logic helpers: extractFirstPositional, parseInvite, parseRole, +// isAddrInUse, peerNameRe. These have no side effects, so they're the +// easiest to push toward 100%. + +func TestExtractFirstPositional(t *testing.T) { + // extractFirstPositional is naive about flag signatures: any token + // that doesn't start with `-` is a positional. That's fine for our + // real subcommands because every flag we define takes its own value + // via `--flag value` (so the flag token is caught by the HasPrefix + // check and we never reach the value). Test cases reflect that + // behavior rather than shell/getopt-style parsing. + cases := []struct { + name string + in []string + wantPos string + wantRest []string + }{ + {"empty", nil, "", nil}, + {"no positionals", []string{"--a", "--b"}, "", []string{"--a", "--b"}}, + {"pos first", []string{"ivan", "--url", "X"}, "ivan", []string{"--url", "X"}}, + {"pos last", []string{"--url", "X", "ivan"}, "X", []string{"--url", "ivan"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotPos, gotRest := extractFirstPositional(c.in) + if gotPos != c.wantPos { + t.Errorf("pos=%q, want %q", gotPos, c.wantPos) + } + if !equalStrs(gotRest, c.wantRest) { + t.Errorf("rest=%v, want %v", gotRest, c.wantRest) + } + }) + } +} + +func equalStrs(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestPeerNameRegex(t *testing.T) { + good := []string{"ivan", "ivan-mac", "i", "a123", "ab-cd-ef"} + bad := []string{"", "Ivan", "123foo", "ivan_mac", "-ivan", "ivan ", strings.Repeat("a", 33)} + for _, s := range good { + if !peerNameRe.MatchString(s) { + t.Errorf("expected %q to be a valid peer name", s) + } + } + for _, s := range bad { + if peerNameRe.MatchString(s) { + t.Errorf("expected %q to be rejected", s) + } + } +} + +func TestParseInvite_HappyPath(t *testing.T) { + name, httpURL, token, err := parseInvite("hearsay://ivan@ivan-mac.tailXXXX.ts.net:3456/mcp?token=abc123") + if err != nil { + t.Fatalf("parseInvite: %v", err) + } + if name != "ivan" { + t.Errorf("name = %q", name) + } + if httpURL != "http://ivan-mac.tailXXXX.ts.net:3456/mcp" { + t.Errorf("httpURL = %q", httpURL) + } + if token != "abc123" { + t.Errorf("token = %q", token) + } +} + +func TestParseInvite_DefaultsEmptyPathToMcp(t *testing.T) { + // No path component in the URI — parser should fill in /mcp. + _, httpURL, _, err := parseInvite("hearsay://ivan@host:3456?token=abc") + if err != nil { + t.Fatalf("parseInvite: %v", err) + } + if !strings.HasSuffix(httpURL, "/mcp") { + t.Errorf("expected default /mcp path, got %q", httpURL) + } +} + +func TestParseInvite_RejectsBadInputs(t *testing.T) { + cases := []struct { + name string + uri string + }{ + {"bad scheme", "http://ivan@host?token=x"}, + {"no userinfo", "hearsay://host:3456/mcp?token=x"}, + {"bad peer name", "hearsay://Ivan@host:3456/mcp?token=x"}, + {"no host", "hearsay://ivan@?token=x"}, + {"no token", "hearsay://ivan@host:3456/mcp"}, + {"unparseable", "::::not-a-uri:::"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, _, _, err := parseInvite(c.uri) + if err == nil { + t.Errorf("expected error for %q", c.uri) + } + }) + } +} + +func TestIsAddrInUse(t *testing.T) { + if isAddrInUse(nil) { + t.Errorf("nil error should not be AddrInUse") + } + if !isAddrInUse(errors.New("listen tcp 127.0.0.1:8080: bind: address already in use")) { + t.Errorf("should recognize EADDRINUSE by substring") + } + if isAddrInUse(errors.New("some other error")) { + t.Errorf("false positive on unrelated error") + } +} + +func TestParseRole(t *testing.T) { + if r, err := parseRole("consumer"); err != nil || r != claudemd.RoleConsumer { + t.Errorf("consumer → (%v, %v)", r, err) + } + if r, err := parseRole("peer"); err != nil || r != claudemd.RolePeer { + t.Errorf("peer → (%v, %v)", r, err) + } + if _, err := parseRole("whatever"); err == nil { + t.Errorf("expected error on invalid role") + } +} + +// ----------------------------------------------------------------------- +// Exercise runClaudeMd end-to-end against temp paths (install/print/uninstall). + +func TestRunClaudeMd_InstallPrintUninstall(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + path := filepath.Join(home, "CLAUDE.md") + + if code := runClaudeMd([]string{"install", "--path", path}); code != 0 { + t.Errorf("install: exit %d", code) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file at %s: %v", path, err) + } + + stdout := captureStdout(t, func() { + if code := runClaudeMd([]string{"print"}); code != 0 { + t.Errorf("print: exit %d", code) + } + }) + if !strings.Contains(stdout, "hearsay:consumer-auto-start") { + t.Errorf("print output missing marker") + } + + if code := runClaudeMd([]string{"uninstall", "--path", path}); code != 0 { + t.Errorf("uninstall: exit %d", code) + } +} + +func TestDispatch_HelpAndVersion(t *testing.T) { + // Exercise the trivial dispatch branches in one place. + out := captureStdout(t, func() { + if code := dispatch([]string{"help"}); code != 0 { + t.Errorf("help exit=%d", code) + } + }) + if !strings.Contains(out, "hearsay — expose Claude Code") { + t.Errorf("help output unexpected: %q", out) + } + out = captureStdout(t, func() { + if code := dispatch([]string{"--version"}); code != 0 { + t.Errorf("version exit=%d", code) + } + }) + if !strings.Contains(out, version) { + t.Errorf("version output should include %q, got %q", version, out) + } +} + +func TestDispatch_UnknownSubcommand(t *testing.T) { + if code := dispatch([]string{"nonexistent"}); code != 2 { + t.Errorf("unknown subcommand exit=%d, want 2", code) + } +} + +// Positional forms ("help", "version") — dash-prefixed variants go +// through a separate dispatch branch tested elsewhere. +func TestDispatch_PositionalHelpAndVersion(t *testing.T) { + out := captureStdout(t, func() { + if code := dispatch([]string{"help"}); code != 0 { + t.Errorf("help positional exit=%d", code) + } + }) + if !strings.Contains(out, "hearsay — expose Claude Code") { + t.Errorf("help output unexpected: %q", out) + } + out = captureStdout(t, func() { + if code := dispatch([]string{"version"}); code != 0 { + t.Errorf("version positional exit=%d", code) + } + }) + if !strings.Contains(out, version) { + t.Errorf("version output should include %q, got %q", version, out) + } +} + +// Exercises the "-h" alias. +func TestDispatch_ShortHelpFlag(t *testing.T) { + out := captureStdout(t, func() { + if code := dispatch([]string{"-h"}); code != 0 { + t.Errorf("-h exit=%d", code) + } + }) + if !strings.Contains(out, "hearsay — expose") { + t.Errorf("-h didn't print help: %q", out) + } +} + +// Dispatch delegates to subcommand-specific handlers; confirm each one +// is reachable by checking non-zero exit on deliberately bad args. +func TestDispatch_RoutesToSubcommands(t *testing.T) { + // No Claude CLI available so add-peer / remove-peer bail with non-zero. + t.Setenv("PATH", "/nonexistent-"+t.Name()) + for _, sub := range []string{"claude-md", "add-peer", "remove-peer", "invite", "pair"} { + t.Run(sub, func(t *testing.T) { + // Passing just the subcommand name exercises the dispatch + // case and the subcommand's usage-error path. + code := dispatch([]string{sub}) + if code == 0 { + t.Errorf("%s alone shouldn't succeed, got 0", sub) + } + }) + } +} + +func TestRunClaudeMd_BadArgs(t *testing.T) { + if code := runClaudeMd(nil); code != 2 { + t.Errorf("expected usage exit=2, got %d", code) + } + if code := runClaudeMd([]string{"whatever"}); code != 2 { + t.Errorf("expected unknown-action exit=2, got %d", code) + } + if code := runClaudeMd([]string{"install", "--role", "bogus"}); code != 2 { + t.Errorf("expected bad-role exit=2, got %d", code) + } +} + +// ----------------------------------------------------------------------- +// Exercise add-peer input validation (pre-exec). We stub out the +// external `claude` binary with a script on PATH so the real exec +// returns 0. + +func fakeClaudeOnPath(t *testing.T) { + t.Helper() + dir := t.TempDir() + script := "#!/bin/sh\nexit 0\n" + path := filepath.Join(dir, "claude") + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("fake claude: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestRunAddPeer_ValidationFailures(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"no args", nil}, + {"bad name", []string{"BadName", "--url", "http://x", "--token", "y"}}, + {"missing url", []string{"ivan", "--token", "y"}}, + {"missing token", []string{"ivan", "--url", "http://x"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if code := runAddPeer(c.args); code == 0 { + t.Errorf("expected non-zero exit") + } + }) + } +} + +func TestRunAddPeer_ShellsOutToClaudeOnPath(t *testing.T) { + fakeClaudeOnPath(t) + if code := runAddPeer([]string{"ivan", "--url", "http://127.0.0.1:3456/mcp", "--token", "abc"}); code != 0 { + t.Errorf("expected success with fake claude on PATH, got %d", code) + } +} + +func TestRunAddPeer_FailsWhenClaudeNotOnPath(t *testing.T) { + t.Setenv("PATH", "/nonexistent-"+t.Name()) + if code := runAddPeer([]string{"ivan", "--url", "http://x:1/mcp", "--token", "abc"}); code == 0 { + t.Errorf("expected failure when claude CLI is absent") + } +} + +func TestRunRemovePeer_ShellsOutToClaudeOnPath(t *testing.T) { + fakeClaudeOnPath(t) + if code := runRemovePeer([]string{"ivan"}); code != 0 { + t.Errorf("expected success, got %d", code) + } +} + +func TestRunRemovePeer_BadArgs(t *testing.T) { + if code := runRemovePeer(nil); code == 0 { + t.Errorf("expected non-zero exit when no name provided") + } + t.Setenv("PATH", "/nonexistent-"+t.Name()) + if code := runRemovePeer([]string{"ivan"}); code == 0 { + t.Errorf("expected failure when claude CLI is absent") + } +} + +// ----------------------------------------------------------------------- +// runInvite and runPair. + +func TestRunInvite_RequiresConfig(t *testing.T) { + // Scratch HOME with no config file — invite should fail with a clean + // "initialize first" message. + t.Setenv("HOME", t.TempDir()) + if code := runInvite(nil); code == 0 { + t.Errorf("expected non-zero exit when no config") + } +} + +func TestRunInvite_PrintsURIWithExplicitHost(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Seed a config file directly so invite has something to read. + seedConfig(t, home, "ivan", "token-abc-xyz") + + out := captureStdout(t, func() { + if code := runInvite([]string{"--host", "10.0.0.1", "--port", "3000"}); code != 0 { + t.Errorf("invite exit %d", code) + } + }) + if !strings.Contains(out, "hearsay://ivan@10.0.0.1:3000/mcp") { + t.Errorf("invite URI looks wrong: %q", out) + } + if !strings.Contains(out, "token=token-abc-xyz") { + t.Errorf("invite URI missing token: %q", out) + } +} + +func TestRunInvite_FailsWithoutHostAndNoTailscale(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedConfig(t, home, "ivan", "token-xyz") + // No tailscale on PATH, no --host → should error. + t.Setenv("PATH", "/nonexistent-"+t.Name()) + if code := runInvite(nil); code == 0 { + t.Errorf("expected non-zero exit when no host available") + } +} + +func TestRunPair_HappyPath(t *testing.T) { + fakeClaudeOnPath(t) + uri := "hearsay://ivan@127.0.0.1:3456/mcp?token=abc" + if code := runPair([]string{uri}); code != 0 { + t.Errorf("pair exit %d", code) + } +} + +func TestRunPair_BadArgs(t *testing.T) { + if code := runPair(nil); code == 0 { + t.Errorf("expected non-zero exit for no args") + } + if code := runPair([]string{"not-a-uri"}); code == 0 { + t.Errorf("expected non-zero exit for bad URI") + } +} + +// ----------------------------------------------------------------------- +// Helpers that emulate captureStdout / seed a config.json in scratch HOME. + +// captureStdout redirects fmt.Println-style writes (via os.Stdout) into +// a buffer for the duration of fn. Needed because our subcommand helpers +// write directly to Println. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = orig }() + + done := make(chan []byte) + go func() { + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + done <- buf.Bytes() + }() + + fn() + w.Close() + return string(<-done) +} + +// seedConfig drops a fake config.json at whatever location +// internal/config.Dir() resolves to for the current platform, so the +// tests work on both macOS (Library/Application Support) and Linux +// (XDG / ~/.config). HOME must already be pointed at a scratch dir +// by the caller (which controls Dir()'s output). +func seedConfig(t *testing.T, home, name, token string) { + t.Helper() + // Clear XDG so config.Dir() picks the default path, not an + // inherited ambient override from the outer environment. + t.Setenv("XDG_CONFIG_HOME", "") + dir := config.Dir() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := []byte(`{"name":"` + name + `","token":"` + token + `","createdAt":"2026-04-24T00:00:00Z"}`) + if err := os.WriteFile(filepath.Join(dir, "config.json"), body, 0o600); err != nil { + t.Fatalf("seed config: %v", err) + } + _ = home +} + +// TestIsAddrInUse_WithRealListenError wires a real EADDRINUSE error +// through the predicate to make sure the substring-match heuristic +// holds against actual Go listen errors. +func TestIsAddrInUse_WithRealListenError(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer l.Close() + _, err = net.Listen("tcp", l.Addr().String()) + if err == nil { + t.Skipf("couldn't reproduce EADDRINUSE on this platform") + } + if !isAddrInUse(err) { + t.Errorf("isAddrInUse missed real listen error: %v", err) + } +} + +// Sanity: assert that exec.ErrNotFound-style errors don't fool the +// predicate into thinking they're EADDRINUSE. +func TestIsAddrInUse_NotFoundIsFalse(t *testing.T) { + if isAddrInUse(exec.ErrNotFound) { + t.Errorf("exec.ErrNotFound misclassified as AddrInUse") + } + if isAddrInUse(syscall.EACCES) { + t.Errorf("EACCES misclassified as AddrInUse") + } +} + +// ----------------------------------------------------------------------- +// runServer end-to-end. We stand up the full flag-parse → config-resolve +// → listen → shutdown path against a synthetic signal channel. Covers +// the bulk of runServerWithSignals' statements. + +func TestRunServer_StartsAndShutsDownCleanly(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dataDir := t.TempDir() + + // Ephemeral port so parallel test runs don't clash. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + sigCh := make(chan os.Signal, 1) + done := make(chan int, 1) + args := []string{ + "--name", "server-e2e", + "--bind", "127.0.0.1", + "--port", fmt.Sprint(port), + "--data-dir", dataDir, + "--quiet", + } + go func() { + done <- runServerWithSignals(args, sigCh) + }() + + // Give the server a moment to bind, then poke it to confirm it's alive. + deadline := time.Now().Add(2 * time.Second) + for { + if time.Now().After(deadline) { + sigCh <- syscall.SIGTERM + t.Fatalf("server never became reachable on port %d", port) + } + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + conn.Close() + break + } + time.Sleep(25 * time.Millisecond) + } + + sigCh <- syscall.SIGTERM + select { + case code := <-done: + if code != 0 { + t.Errorf("runServer exit=%d", code) + } + case <-time.After(3 * time.Second): + t.Fatalf("runServer did not exit after SIGTERM") + } +} + +// Exercises the EADDRINUSE branch in runServerWithSignals. +func TestRunServer_ReportsPortInUse(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Occupy a port first so the server can't bind it. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + args := []string{ + "--name", "busy-port", + "--bind", "127.0.0.1", + "--port", fmt.Sprint(port), + "--data-dir", t.TempDir(), + "--quiet", + } + code := runServerWithSignals(args, nil) + if code != 1 { + t.Errorf("expected exit=1 on EADDRINUSE, got %d", code) + } +} + +// Bad args → flag parser hits usage error → exit=2. +func TestRunServer_BadFlagArgs(t *testing.T) { + if code := runServerWithSignals([]string{"--not-a-flag"}, nil); code == 0 { + t.Errorf("expected non-zero for bad flag") + } +} + +// First run without --name fails fast out of config.Resolve. +func TestRunServer_FirstRunWithoutNameErrors(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if code := runServerWithSignals([]string{"--bind", "127.0.0.1"}, nil); code != 1 { + t.Errorf("expected exit=1 when --name is missing on first run, got %d", code) + } +} + +// Covers the short pass-through runServer()+defaultSignalChan() entry +// points. We exercise them with deliberately bad args so they return +// quickly without actually binding a port. +func TestRunServer_EntryPointWithBadArgs(t *testing.T) { + if code := runServer([]string{"--no-such-flag"}); code == 0 { + t.Errorf("runServer should reject unknown flags") + } + ch := defaultSignalChan() + if ch == nil { + t.Errorf("defaultSignalChan returned nil") + } +} + +// Exercises the tailscale-detected bind path in runServerWithSignals +// (non-explicit --bind). We stub `tailscale ip -4` via PATH so the +// function takes the tailscale branch deterministically. +func TestRunServer_UsesTailscaleBindWhenNoExplicitBind(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Fake tailscale returns 127.0.0.1 so the server can actually bind + // (a real 100.x.y.z isn't assigned on this host). + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "tailscale"), []byte("#!/bin/sh\necho 127.0.0.1\n"), 0o755); err != nil { + t.Fatalf("fake tailscale: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + ln, _ := net.Listen("tcp", "127.0.0.1:0") + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + sigCh := make(chan os.Signal, 1) + done := make(chan int, 1) + go func() { + done <- runServerWithSignals([]string{ + "--name", "ts-bind", + "--port", fmt.Sprint(port), + "--data-dir", t.TempDir(), + "--quiet", + }, sigCh) + }() + + // Wait briefly for the server to bind. + deadline := time.Now().Add(2 * time.Second) + for { + if time.Now().After(deadline) { + break + } + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + conn.Close() + break + } + time.Sleep(25 * time.Millisecond) + } + sigCh <- syscall.SIGTERM + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("server didn't exit") + } +} + +// Exercises the bindSource="tailscale not detected" fallback in +// runServerWithSignals: no --bind flag, no tailscale CLI on PATH. +func TestRunServer_FallsBackTo127WhenNoTailscale(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedConfig(t, home, "fallback", "tok") + + t.Setenv("PATH", "/nonexistent-"+t.Name()) + + ln, _ := net.Listen("tcp", "127.0.0.1:0") + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + sigCh := make(chan os.Signal, 1) + done := make(chan int, 1) + go func() { + done <- runServerWithSignals([]string{ + "--port", fmt.Sprint(port), + "--data-dir", t.TempDir(), + "--quiet", + }, sigCh) + }() + // Give server a beat to bind, then signal shutdown. + time.Sleep(200 * time.Millisecond) + sigCh <- syscall.SIGTERM + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("server didn't exit") + } +} + +// Covers the --regenerate-token branch in runServerWithSignals. +func TestRunServer_RegenerateTokenBranch(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedConfig(t, home, "rotator", "old-token") + + ln, _ := net.Listen("tcp", "127.0.0.1:0") + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + sigCh := make(chan os.Signal, 1) + done := make(chan int, 1) + go func() { + done <- runServerWithSignals([]string{ + "--bind", "127.0.0.1", + "--port", fmt.Sprint(port), + "--regenerate-token", + "--data-dir", t.TempDir(), + "--quiet", + }, sigCh) + }() + time.Sleep(200 * time.Millisecond) + sigCh <- syscall.SIGTERM + <-done +} + +// runInvite with a fake tailscale on PATH exercises the tailscale DNS +// auto-detect branch. +func TestRunInvite_AutoDetectsTailscaleHost(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + seedConfig(t, home, "ivan", "tok-123") + + dir := t.TempDir() + script := `#!/bin/sh +case "$1" in + ip) echo 100.64.1.2 ;; + status) cat < Date: Fri, 24 Apr 2026 14:45:25 +0200 Subject: [PATCH 3/3] fix(test): TestRunServer_FirstRunWithoutNameErrors hung on Ubuntu CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test passed 'nil' as the signal channel, assuming config.Resolve would error out before the signal wait. On the GitHub Actions runner that assumption didn't hold — something in the environment let config.Resolve succeed (likely a pre-populated XDG_CONFIG_HOME), the HTTP server booted for real, and <-nil blocks forever. Defensive fix: * clear XDG_CONFIG_HOME so Dir() resolves strictly under the scratch HOME * pre-signal the channel so the server shuts down even if Resolve regresses in the future * bind to an ephemeral port so parallel tests don't collide on :3456 --- cmd/hearsay/main_test.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd/hearsay/main_test.go b/cmd/hearsay/main_test.go index 5b2e13f..9d23dd8 100644 --- a/cmd/hearsay/main_test.go +++ b/cmd/hearsay/main_test.go @@ -567,7 +567,24 @@ func TestRunServer_BadFlagArgs(t *testing.T) { func TestRunServer_FirstRunWithoutNameErrors(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - if code := runServerWithSignals([]string{"--bind", "127.0.0.1"}, nil); code != 1 { + // Neutralize any inherited XDG_CONFIG_HOME so Dir() resolves strictly + // under the scratch HOME. Without this, some CI images leave + // XDG_CONFIG_HOME set to a populated path and Resolve finds a + // pre-existing config there — the server then boots for real and, + // with a nil signal channel, the test hangs on `<-nil`. + t.Setenv("XDG_CONFIG_HOME", "") + // Belt-and-suspenders: pre-signal the channel so that if the + // no-name→error path ever regresses, the server shuts down instead + // of hanging the test for 10 minutes. + sigCh := make(chan os.Signal, 1) + sigCh <- syscall.SIGTERM + // Pick an ephemeral port so the default :3456 doesn't collide with + // other tests running in parallel on the same runner. + ln, _ := net.Listen("tcp", "127.0.0.1:0") + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + args := []string{"--bind", "127.0.0.1", "--port", fmt.Sprint(port), "--data-dir", t.TempDir(), "--quiet"} + if code := runServerWithSignals(args, sigCh); code != 1 { t.Errorf("expected exit=1 when --name is missing on first run, got %d", code) } }