diff --git a/agenticcli/claude_live.go b/agenticcli/claude_live.go index bcc97912..2cec576f 100644 --- a/agenticcli/claude_live.go +++ b/agenticcli/claude_live.go @@ -25,6 +25,7 @@ import ( "io" "os/exec" "strings" + "syscall" "time" "github.com/cowdogmoo/squad/logging" @@ -195,7 +196,14 @@ type liveSession struct { // loop keeps draining until EOF so the process never blocks on a full pipe. func (s *liveSession) loop(userPrompt string) (Result, error) { if err := s.send(userMessageEvent(userPrompt)); err != nil { - return Result{}, err + if !errors.Is(err, syscall.EPIPE) { + return Result{}, err + } + // The CLI died before reading the prompt. Don't surface the raw + // broken pipe: fall through to drain stdout so the exit path + // reports the far more diagnostic exited-without-a-result error + // (which carries the CLI's stderr tail). + logging.DebugContext(s.ctx, "claude live: initial prompt write hit EPIPE; draining stdout") } for { line, readErr := s.reader.ReadString('\n') diff --git a/agenticcli/claude_live_test.go b/agenticcli/claude_live_test.go index 9d8464fc..7ab88f3a 100644 --- a/agenticcli/claude_live_test.go +++ b/agenticcli/claude_live_test.go @@ -1,6 +1,7 @@ package agenticcli import ( + "bufio" "bytes" "context" "encoding/json" @@ -178,6 +179,32 @@ func TestRunLive_ProtocolGarbage(t *testing.T) { }) } +// TestLoop_InitialWriteEPIPEFallsThroughToExitDiagnosis pins the race where +// the CLI dies before reading the initial prompt: the EPIPE from the stdin +// write must not mask the exited-without-a-result diagnosis, which is what +// carries the CLI's stderr tail in RunLive. Before the fix this surfaced as +// a flaky "write stdin: broken pipe" on slow CI runners. +func TestLoop_InitialWriteEPIPEFallsThroughToExitDiagnosis(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { // dead reader: writes now hit EPIPE + t.Fatal(err) + } + t.Cleanup(func() { _ = w.Close() }) + + s := &liveSession{ + ctx: context.Background(), + stdin: w, + reader: bufio.NewReader(strings.NewReader("")), + } + _, err = s.loop("prompt") + if err == nil || !strings.Contains(err.Error(), "without a result event") { + t.Fatalf("err = %v, want exit-without-result", err) + } +} + func TestRunLive_ExitWithoutResult(t *testing.T) { installFakeClaude(t, map[string]string{"FAKE_CLAUDE_EXIT_EARLY": "1"}) diff --git a/browser/active.go b/browser/active.go index 955b3da5..d49b6dcd 100644 --- a/browser/active.go +++ b/browser/active.go @@ -21,7 +21,7 @@ func ActivePort(name string) (string, error) { data, err := os.ReadFile(portFile) if err != nil { if errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("no active browser session found for profile %q (DevToolsActivePort not found)", name) + return "", fmt.Errorf("no active browser session found for profile %q (DevToolsActivePort not found; launch one with `squad browser open %s --remote-debug`)", name, name) } return "", fmt.Errorf("read DevToolsActivePort: %w", err) } diff --git a/browser/launch.go b/browser/launch.go index 54958947..ead5f5c8 100644 --- a/browser/launch.go +++ b/browser/launch.go @@ -36,9 +36,10 @@ func chromeCandidates() []string { } } -// findChrome resolves the first launchable Chrome binary in chromeCandidates. +// FindChrome resolves the first launchable Chrome binary in chromeCandidates. // Absolute paths are checked with os.Stat; bare names go through exec.LookPath. -func findChrome() (string, error) { +// SQUAD_BROWSER_BIN overrides the candidate list entirely. +func FindChrome() (string, error) { for _, c := range chromeCandidates() { if c == "" { continue @@ -81,6 +82,12 @@ type LaunchOptions struct { // Chrome is started and Launch returns immediately — useful for // scripted setup where the caller doesn't want to wedge a terminal. Wait bool + // RemoteDebug, when true, starts Chrome with remote debugging on a + // random port (recorded in the profile's DevToolsActivePort file) so + // `squad browser eval` can attach. Off by default: the CDP endpoint + // gives ANY local process full control of the browser, including its + // cookies and logged-in sessions. + RemoteDebug bool // Stderr receives diagnostic output (Chrome's own logs). nil discards. Stderr *os.File } @@ -94,7 +101,7 @@ func Launch(name string, opts LaunchOptions) error { if err != nil { return err } - bin, err := findChrome() + bin, err := FindChrome() if err != nil { return err } @@ -104,10 +111,12 @@ func Launch(name string, opts LaunchOptions) error { } args := []string{ "--user-data-dir=" + dir, - "--remote-debugging-port=0", "--new-window", - url, } + if opts.RemoteDebug { + args = append(args, "--remote-debugging-port=0") + } + args = append(args, url) cmd := exec.Command(bin, args...) if opts.Stderr != nil { cmd.Stderr = opts.Stderr diff --git a/browser/launch_test.go b/browser/launch_test.go index c629af17..ef545e0c 100644 --- a/browser/launch_test.go +++ b/browser/launch_test.go @@ -133,9 +133,9 @@ func TestChromeCandidatesDefaults(t *testing.T) { func TestFindChromeNoCandidates(t *testing.T) { // Empty SQUAD_BROWSER_BIN with PATH that contains no chrome binaries. t.Setenv("SQUAD_BROWSER_BIN", filepath.Join(t.TempDir(), "absent")) - _, err := findChrome() + _, err := FindChrome() if !errors.Is(err, ErrChromeNotFound) { - t.Fatalf("findChrome() err = %v, want ErrChromeNotFound", err) + t.Fatalf("FindChrome() err = %v, want ErrChromeNotFound", err) } } @@ -162,7 +162,7 @@ func TestFindChromeEnvBareNameResolves(t *testing.T) { // Set env to the bare filename, not the absolute path. base := filepath.Base(path) t.Setenv("SQUAD_BROWSER_BIN", base) - resolved, err := findChrome() + resolved, err := FindChrome() if err != nil { t.Fatalf("findChrome error: %v", err) } diff --git a/cmd/squad/browser.go b/cmd/squad/browser.go index 1e30cfe2..c95fc0a1 100644 --- a/cmd/squad/browser.go +++ b/cmd/squad/browser.go @@ -31,6 +31,13 @@ reference a profile by name from agent.yaml: - chrome-devtools-mcp@latest - --userDataDir={{.BrowserProfile "amazon"}} +or via the built-in browser MCP server (no npx needed): + + mcp_servers: + - name: browser + command: squad + args: [mcp, server, browser, --profile, amazon] + Typical workflow: squad browser open amazon https://www.amazon.com/ @@ -48,6 +55,7 @@ Typical workflow: func newBrowserOpenCmd() *cobra.Command { var wait bool + var remoteDebug bool cmd := &cobra.Command{ Use: "open NAME [URL]", Short: "Open a profile in Chrome for interactive setup", @@ -58,6 +66,11 @@ needs later. By default this command starts Chrome and returns immediately, leaving the browser window open for you to interact with. Use --wait to block until you quit Chrome. + +Pass --remote-debug to enable Chrome's remote-debugging endpoint so +"squad browser eval" can attach to the session. Leave it off when just +signing into sites: the endpoint lets any local process drive the +browser and read its cookies. `, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { @@ -78,13 +91,16 @@ until you quit Chrome. "Sign in / set things up, then quit Chrome to save the session.\n", name, dir) return browser.Launch(name, browser.LaunchOptions{ - URL: url, - Wait: wait, - Stderr: os.Stderr, + URL: url, + Wait: wait, + RemoteDebug: remoteDebug, + Stderr: os.Stderr, }) }, } cmd.Flags().BoolVar(&wait, "wait", false, "Block until Chrome quits (default: start and return)") + cmd.Flags().BoolVar(&remoteDebug, "remote-debug", false, + "Enable Chrome remote debugging so `squad browser eval` can attach (exposes browser control to local processes)") return cmd } diff --git a/cmd/squad/browser_eval.go b/cmd/squad/browser_eval.go index a6cece67..2b753da1 100644 --- a/cmd/squad/browser_eval.go +++ b/cmd/squad/browser_eval.go @@ -1,8 +1,13 @@ package main import ( + "context" + "encoding/json" + "errors" "fmt" + "strings" + "github.com/chromedp/cdproto/target" "github.com/chromedp/chromedp" "github.com/cowdogmoo/squad/browser" "github.com/spf13/cobra" @@ -13,7 +18,12 @@ func newBrowserEvalCmd() *cobra.Command { Use: "eval NAME SCRIPT", Short: "Evaluate JavaScript in an active browser session", Long: `Connects to the active Chrome session for the given profile and -evaluates the provided JavaScript code. The result is printed to stdout. +evaluates the provided JavaScript in its first open page. The result is +printed to stdout as JSON. + +The session must have been started with remote debugging enabled: + + squad browser open myprofile --remote-debug Example: squad browser eval myprofile "document.body.innerText" @@ -23,29 +33,93 @@ Example: name := args[0] script := args[1] - if err := browser.ValidateName(name); err != nil { - return err - } - - wsURL, err := browser.ActivePort(name) + tabCtx, cleanup, err := attachToActivePage(cmd.Context(), name) if err != nil { return err } + defer cleanup() - allocCtx, cancel := chromedp.NewRemoteAllocator(cmd.Context(), wsURL) - defer cancel() - - ctx, cancelCtx := chromedp.NewContext(allocCtx) - defer cancelCtx() - - var res any - if err := chromedp.Run(ctx, chromedp.Evaluate(script, &res)); err != nil { + var res json.RawMessage + err = chromedp.Run(tabCtx, chromedp.Evaluate(script, &res)) + switch { + case errors.Is(err, chromedp.ErrJSUndefined): + res = json.RawMessage("undefined") + case errors.Is(err, chromedp.ErrJSNull): + res = json.RawMessage("null") + case err != nil: return fmt.Errorf("evaluate failed: %w", err) } - _, err = fmt.Fprintln(cmd.OutOrStdout(), res) + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(res)) return err }, } return cmd } + +// attachToActivePage connects to the active browser session for the named +// profile (via its DevToolsActivePort endpoint) and returns a context +// attached to the session's first real page, plus a cleanup func. The +// cleanup detaches WITHOUT closing the page: chromedp's own context cancel +// sends Target.closeTarget to any attached target, which would close the +// user's tab out from under them. +func attachToActivePage(ctx context.Context, profile string) (context.Context, func(), error) { + wsURL, err := browser.ActivePort(profile) + if err != nil { + return nil, nil, err + } + + allocCtx, cancelAlloc := chromedp.NewRemoteAllocator(ctx, wsURL) + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + teardown := func() { + cancelBrowser() + cancelAlloc() + } + + page, err := firstPageTarget(browserCtx) + if err != nil { + teardown() + return nil, nil, err + } + + tabCtx, cancelTab := chromedp.NewContext(browserCtx, chromedp.WithTargetID(page.TargetID)) + cleanup := func() { + // Drop the attachment before cancelling so chromedp's cleanup + // goroutine (which fires Target.closeTarget on a non-nil Target) + // leaves the page open. The CDP session itself dies with the + // websocket connection. + if c := chromedp.FromContext(tabCtx); c != nil { + c.Target = nil + } + cancelTab() + teardown() + } + + // Attach now, on the long-lived context: chromedp binds the session's + // event handling to the context of the first Run, and callers may only + // ever Run with short-lived contexts derived from tabCtx. + if err := chromedp.Run(tabCtx); err != nil { + cleanup() + return nil, nil, fmt.Errorf("attach to page: %w", err) + } + return tabCtx, cleanup, nil +} + +// firstPageTarget returns the session's first real page, skipping Chrome's +// internal targets — evaluating in those (or in a fresh tab, which is what +// a target-less context would silently create) is never what the caller +// asked for. +func firstPageTarget(browserCtx context.Context) (*target.Info, error) { + targets, err := chromedp.Targets(browserCtx) + if err != nil { + return nil, fmt.Errorf("list browser targets: %w", err) + } + for _, t := range targets { + if t.Type == "page" && + !strings.HasPrefix(t.URL, "chrome://") && + !strings.HasPrefix(t.URL, "devtools://") { + return t, nil + } + } + return nil, fmt.Errorf("no open page in the browser session (%d targets)", len(targets)) +} diff --git a/cmd/squad/browser_eval_test.go b/cmd/squad/browser_eval_test.go index bc6cab75..05a0394c 100644 --- a/cmd/squad/browser_eval_test.go +++ b/cmd/squad/browser_eval_test.go @@ -77,13 +77,13 @@ func TestBrowserEvalLiveSuccess(t *testing.T) { if err := os.MkdirAll(profileDir, 0o755); err != nil { t.Fatal(err) } + scrubProfileDir(t, profileDir) - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", true), chromedp.Flag("no-sandbox", true), + opts := chromeExecOpts( chromedp.UserDataDir(profileDir), chromedp.Flag("remote-debugging-port", "0"), ) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, opts...) @@ -92,7 +92,7 @@ func TestBrowserEvalLiveSuccess(t *testing.T) { browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) defer cancelBrowser() - if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil { + if err := chromedp.Run(browserCtx, chromedp.Navigate("data:text/html,
HELLO-EVAL-PROBE")); err != nil { t.Skipf("skipping live browser test: %v", err) } @@ -106,8 +106,17 @@ func TestBrowserEvalLiveSuccess(t *testing.T) { if err := cmd.RunE(cmd, []string{"liveeval", "2 + 2"}); err != nil { t.Fatalf("eval failed: %v", err) } - if !strings.Contains(stdout.String(), "4") { t.Fatalf("expected output '4', got: %s", stdout.String()) } + + // The eval must run in the session's open page, not a fresh blank tab: + // reading the page body is the command's documented use case. + stdout.Reset() + if err := cmd.RunE(cmd, []string{"liveeval", "document.body.innerText"}); err != nil { + t.Fatalf("eval failed: %v", err) + } + if !strings.Contains(stdout.String(), "HELLO-EVAL-PROBE") { + t.Fatalf("expected the open page's body text, got: %s", stdout.String()) + } } diff --git a/cmd/squad/browser_indicator.go b/cmd/squad/browser_indicator.go new file mode 100644 index 00000000..6a2961ee --- /dev/null +++ b/cmd/squad/browser_indicator.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "time" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + "github.com/cowdogmoo/squad/logging" +) + +// attachIndicatorJS frames the controlled page in orange and pins a "squad" +// badge to it — the in-page equivalent of the tab decoration browser +// extensions use to show which tab an agent is driving. Idempotent, and +// pointer-events:none keeps the page fully interactive. Injected both into +// the current document and (via Page.addScriptToEvaluateOnNewDocument) every +// future navigation while the server stays attached. +const attachIndicatorJS = `(() => { + const install = () => { + if (document.getElementById('__squad_indicator__')) return; + const style = document.createElement('style'); + style.id = '__squad_indicator_style__'; + style.textContent = ` + "`" + ` + #__squad_indicator__ { position: fixed; inset: 0; border: 3px solid #e8956b; pointer-events: none; z-index: 2147483647; } + #__squad_indicator__ .__squad_badge__ { position: absolute; top: 0; left: 50%; transform: translateX(-50%); background: #e8956b; color: #1f1f1f; font: 600 12px/1.6 system-ui, sans-serif; padding: 1px 12px; border-radius: 0 0 8px 8px; } + ` + "`" + `; + const frame = document.createElement('div'); + frame.id = '__squad_indicator__'; + const badge = document.createElement('div'); + badge.className = '__squad_badge__'; + badge.textContent = 'squad'; + frame.appendChild(badge); + document.documentElement.appendChild(style); + document.documentElement.appendChild(frame); + }; + if (document.documentElement) { install(); } else { addEventListener('DOMContentLoaded', install); } + return true; +})()` + +// removeIndicatorJS undoes attachIndicatorJS on the current document. +const removeIndicatorJS = `(() => { + document.getElementById('__squad_indicator__')?.remove(); + document.getElementById('__squad_indicator_style__')?.remove(); + return true; +})()` + +// installAttachIndicator marks the attached page as agent-controlled and +// returns a remove func for detach time. Installation failure is fatal to +// the attach: the whole point of driving a user's real browser is that they +// can see it happening. +func installAttachIndicator(tabCtx context.Context) (func(), error) { + var scriptID page.ScriptIdentifier + err := chromedp.Run(tabCtx, + chromedp.ActionFunc(func(ctx context.Context) error { + id, err := page.AddScriptToEvaluateOnNewDocument(attachIndicatorJS).Do(ctx) + if err != nil { + return err + } + scriptID = id + return nil + }), + chromedp.Evaluate(attachIndicatorJS, nil), + ) + if err != nil { + return nil, err + } + + remove := func() { + // Best-effort: the session (or the whole browser) may already be + // gone by detach time, and detaching must not hang on it. + rmCtx, cancel := context.WithTimeout(tabCtx, 5*time.Second) + defer cancel() + err := chromedp.Run(rmCtx, + chromedp.ActionFunc(func(ctx context.Context) error { + return page.RemoveScriptToEvaluateOnNewDocument(scriptID).Do(ctx) + }), + chromedp.Evaluate(removeIndicatorJS, nil), + ) + if err != nil { + logging.Debug("squad browser: remove attach indicator: %v", err) + } + } + return remove, nil +} diff --git a/cmd/squad/mcp_server.go b/cmd/squad/mcp_server.go index e731b73b..c6e67874 100644 --- a/cmd/squad/mcp_server.go +++ b/cmd/squad/mcp_server.go @@ -2,16 +2,23 @@ package main import ( "context" + "encoding/json" + "errors" "fmt" "io" - "os" + "time" "github.com/chromedp/chromedp" + "github.com/cowdogmoo/squad/browser" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" ) +// browserToolTimeout bounds one tool call against the shared browser: a page +// that never finishes loading must not wedge the server forever. +const browserToolTimeout = 2 * time.Minute + func newMCPServerCmd() *cobra.Command { cmd := &cobra.Command{ Use: "server", @@ -21,52 +28,142 @@ func newMCPServerCmd() *cobra.Command { return cmd } +// browserServerOptions selects how the browser MCP server gets its browser: +// attach to a running session (Profile) or launch its own (everything else). +type browserServerOptions struct { + // Profile names a squad browser profile whose already-running Chrome + // session (started with `squad browser open NAME --remote-debug`) the + // server attaches to. This is the mode that shares the user's login + // state and launches nothing. Mutually exclusive with UserDataDir. + Profile string + // UserDataDir is the profile directory for a launched browser. + UserDataDir string + // Headless controls the launched browser's mode. + Headless bool + // NoSandbox disables the launched Chrome's sandbox. Needed in some + // containers; weakens isolation from visited pages. + NoSandbox bool +} + func newMCPServerBrowserCmd() *cobra.Command { - var userDataDir string - var headless bool + var opts browserServerOptions cmd := &cobra.Command{ Use: "browser", Short: "Run the built-in browser MCP server", + Long: `Run an MCP server exposing browser automation tools (navigate, +read_page, evaluate_js, click). It speaks standard MCP over stdio, so +any squad agent can use it regardless of model provider. + +By default a headless browser is launched for the server's own use. Pass +--profile to instead attach to the already-running Chrome session of a +squad browser profile, so the tools drive a real, logged-in browser and +nothing new is launched. While attached, the controlled page is framed +in orange with a "squad" badge so it's always visible which page the +agent is driving: + + squad browser open myprofile --remote-debug + squad mcp server browser --profile myprofile + +Wire it into any agent via agent.yaml: + + mcp_servers: + - name: browser + command: squad + args: [mcp, server, browser, --profile, myprofile] +`, RunE: func(cmd *cobra.Command, args []string) error { - return runBrowserServerIO(cmd.Context(), userDataDir, headless, cmd.InOrStdin(), cmd.OutOrStdout()) + return runBrowserServerIO(cmd.Context(), opts, cmd.InOrStdin(), cmd.OutOrStdout()) }, } - cmd.Flags().StringVar(&userDataDir, "user-data-dir", "", "Path to browser profile directory") - cmd.Flags().BoolVar(&headless, "headless", true, "Run in headless mode") + cmd.Flags().StringVar(&opts.Profile, "profile", "", + "Attach to the active Chrome session of this squad browser profile instead of launching a browser") + cmd.Flags().StringVar(&opts.UserDataDir, "user-data-dir", "", "Path to browser profile directory") + cmd.Flags().BoolVar(&opts.Headless, "headless", true, "Run in headless mode") + cmd.Flags().BoolVar(&opts.NoSandbox, "no-sandbox", false, + "Disable Chrome's sandbox (needed in some containers; weakens isolation from visited pages)") return cmd } -func runBrowserServer(ctx context.Context, userDataDir string, headless bool) error { - return runBrowserServerIO(ctx, userDataDir, headless, os.Stdin, os.Stdout) +func runBrowserServerIO(ctx context.Context, opts browserServerOptions, in io.Reader, out io.Writer) error { + s := server.NewMCPServer("squad-browser", "1.0.0") + + browserCtx, cleanup, err := connectBrowserServer(ctx, opts) + if err != nil { + return err + } + defer cleanup() + + registerBrowserTools(s, browserCtx) + + stdioServer := server.NewStdioServer(s) + return stdioServer.Listen(ctx, in, out) } -func runBrowserServerIO(ctx context.Context, userDataDir string, headless bool, in io.Reader, out io.Writer) error { - s := server.NewMCPServer("squad-browser", "1.0.0") +// connectBrowserServer resolves the browser context the tools run against: +// an attachment to the profile's running session, or a freshly launched +// browser. The launched path pins the binary to the same discovery Launch +// uses (SQUAD_BROWSER_BIN, then Google Chrome before Chromium) instead of +// chromedp's own preference order. +func connectBrowserServer(ctx context.Context, opts browserServerOptions) (context.Context, func(), error) { + if opts.Profile != "" { + if opts.UserDataDir != "" { + return nil, nil, errors.New("--profile attaches to a running session and cannot be combined with --user-data-dir") + } + tabCtx, cleanup, err := attachToActivePage(ctx, opts.Profile) + if err != nil { + return nil, nil, err + } + removeIndicator, err := installAttachIndicator(tabCtx) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("install attach indicator: %w", err) + } + return tabCtx, func() { + removeIndicator() + cleanup() + }, nil + } - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", headless), - chromedp.Flag("no-sandbox", true), + execOpts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", opts.Headless), ) - if userDataDir != "" { - opts = append(opts, chromedp.UserDataDir(userDataDir)) + if bin, err := browser.FindChrome(); err == nil { + execOpts = append(execOpts, chromedp.ExecPath(bin)) } - allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, opts...) - defer cancelAlloc() - + if opts.NoSandbox { + execOpts = append(execOpts, chromedp.NoSandbox) + } + if opts.UserDataDir != "" { + execOpts = append(execOpts, chromedp.UserDataDir(opts.UserDataDir)) + } + allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, execOpts...) browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) - defer cancelBrowser() + cleanup := func() { + cancelBrowser() + cancelAlloc() + } if err := chromedp.Run(browserCtx, chromedp.Navigate("about:blank")); err != nil { - return fmt.Errorf("failed to initialize browser: %w", err) + cleanup() + return nil, nil, fmt.Errorf("failed to initialize browser: %w", err) } + return browserCtx, cleanup, nil +} - registerBrowserTools(s, browserCtx) - - stdioServer := server.NewStdioServer(s) - return stdioServer.Listen(ctx, in, out) +// runBrowserAction runs actions against the shared browser context, bounded +// by browserToolTimeout and aborted early when the MCP request's own context +// is cancelled. chromedp actions must run on the browser context (not the +// request context), which would otherwise let a hung page load outlive both +// the request and the timeout. +func runBrowserAction(reqCtx, browserCtx context.Context, actions ...chromedp.Action) error { + runCtx, cancel := context.WithTimeout(browserCtx, browserToolTimeout) + defer cancel() + stop := context.AfterFunc(reqCtx, cancel) + defer stop() + return chromedp.Run(runCtx, actions...) } func registerBrowserTools(s *server.MCPServer, browserCtx context.Context) { @@ -83,7 +180,7 @@ func registerBrowserTools(s *server.MCPServer, browserCtx context.Context) { return mcp.NewToolResultError("url must be a string"), nil } - err := chromedp.Run(browserCtx, chromedp.Navigate(url), chromedp.WaitReady("body", chromedp.ByQuery)) + err := runBrowserAction(ctx, browserCtx, chromedp.Navigate(url), chromedp.WaitReady("body", chromedp.ByQuery)) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to navigate: %v", err)), nil } @@ -95,7 +192,7 @@ func registerBrowserTools(s *server.MCPServer, browserCtx context.Context) { mcp.WithDescription("Extract all text from the current page body"), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { var text string - err := chromedp.Run(browserCtx, chromedp.Text("body", &text, chromedp.ByQuery)) + err := runBrowserAction(ctx, browserCtx, chromedp.Text("body", &text, chromedp.ByQuery)) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to read page text: %v", err)), nil } @@ -103,7 +200,7 @@ func registerBrowserTools(s *server.MCPServer, browserCtx context.Context) { }) s.AddTool(mcp.NewTool("evaluate_js", - mcp.WithDescription("Evaluate JavaScript on the current page"), + mcp.WithDescription("Evaluate JavaScript on the current page; the result is returned as JSON"), mcp.WithString("script", mcp.Required(), mcp.Description("The JavaScript code to evaluate")), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args, ok := req.Params.Arguments.(map[string]any) @@ -115,13 +212,18 @@ func registerBrowserTools(s *server.MCPServer, browserCtx context.Context) { return mcp.NewToolResultError("script must be a string"), nil } - var res any - err := chromedp.Run(browserCtx, chromedp.Evaluate(script, &res)) - if err != nil { + var res json.RawMessage + err := runBrowserAction(ctx, browserCtx, chromedp.Evaluate(script, &res)) + switch { + case errors.Is(err, chromedp.ErrJSUndefined): + res = json.RawMessage("undefined") + case errors.Is(err, chromedp.ErrJSNull): + res = json.RawMessage("null") + case err != nil: return mcp.NewToolResultError(fmt.Sprintf("Failed to evaluate: %v", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("%v", res)), nil + return mcp.NewToolResultText(string(res)), nil }) s.AddTool(mcp.NewTool("click", @@ -137,7 +239,7 @@ func registerBrowserTools(s *server.MCPServer, browserCtx context.Context) { return mcp.NewToolResultError("selector must be a string"), nil } - err := chromedp.Run(browserCtx, chromedp.Click(sel, chromedp.ByQuery)) + err := runBrowserAction(ctx, browserCtx, chromedp.Click(sel, chromedp.ByQuery)) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to click %s: %v", sel, err)), nil } diff --git a/cmd/squad/mcp_server_test.go b/cmd/squad/mcp_server_test.go index 5b489412..fd14c823 100644 --- a/cmd/squad/mcp_server_test.go +++ b/cmd/squad/mcp_server_test.go @@ -6,15 +6,80 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" + "sync" "testing" "time" "github.com/chromedp/chromedp" + "github.com/cowdogmoo/squad/browser" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) +// chromeExecOpts returns headless allocator options pinned to the same +// binary discovery browser.Launch uses, so tests don't launch whatever +// stray Chromium chromedp's own preference order finds first. +func chromeExecOpts(extra ...chromedp.ExecAllocatorOption) []chromedp.ExecAllocatorOption { + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), chromedp.NoSandbox) + if bin, err := browser.FindChrome(); err == nil { + opts = append(opts, chromedp.ExecPath(bin)) + } + return append(opts, extra...) +} + +// scrubProfileDir registers a tolerant cleanup for a Chrome profile dir that +// lives under t.TempDir: Chrome's helper processes can still be flushing +// profile files for a moment after the allocator reports the browser gone, +// which makes t.TempDir's strict RemoveAll flake with "directory not empty". +// Registered after withBrowserRoot, it runs before the TempDir cleanup and +// retries until Chrome's stragglers have quiesced. +func scrubProfileDir(t *testing.T, dir string) { + t.Helper() + t.Cleanup(func() { + deadline := time.Now().Add(10 * time.Second) + for { + err := os.RemoveAll(dir) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Logf("profile dir cleanup gave up: %v", err) + return + } + time.Sleep(200 * time.Millisecond) + } + }) +} + +var ( + chromeCheckOnce sync.Once + chromeCheckErr error +) + +// requireChrome skips the test when no Chrome/Chromium can be launched: +// tests that exercise a real browser must degrade to a skip, not a failure, +// on machines without one. The probe result is cached — launching Chrome is +// too slow to repeat per test. +func requireChrome(t *testing.T) { + t.Helper() + chromeCheckOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, chromeExecOpts()...) + defer cancelAlloc() + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + defer cancelBrowser() + chromeCheckErr = chromedp.Run(browserCtx, chromedp.Navigate("about:blank")) + }) + if chromeCheckErr != nil { + t.Skipf("chrome unavailable: %v", chromeCheckErr) + } +} + func TestMCPServerCmd(t *testing.T) { cmd := newMCPServerCmd() if cmd == nil { @@ -33,46 +98,65 @@ func TestMCPServerBrowserCmd(t *testing.T) { if cmd.Use != "browser" { t.Errorf("expected Use 'browser', got %q", cmd.Use) } - if cmd.Flags().Lookup("user-data-dir") == nil { - t.Error("expected user-data-dir flag") - } - if cmd.Flags().Lookup("headless") == nil { - t.Error("expected headless flag") + for _, flag := range []string{"profile", "user-data-dir", "headless", "no-sandbox"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected %s flag", flag) + } } } -func TestMCPServerRunBrowserServerCancelledContext(t *testing.T) { +func TestMCPServerRunBrowserServerIOCancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - err := runBrowserServer(ctx, "", true) + err := runBrowserServerIO(ctx, browserServerOptions{Headless: true, NoSandbox: true}, strings.NewReader(""), &bytes.Buffer{}) if err == nil { t.Fatal("expected error from cancelled context, got nil") } } +func TestMCPServerConnectBrowserServerProfileConflicts(t *testing.T) { + _, _, err := connectBrowserServer(context.Background(), browserServerOptions{ + Profile: "myprofile", + UserDataDir: "/tmp/somewhere", + }) + if err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("err = %v, want profile/user-data-dir conflict", err) + } +} + +func TestMCPServerConnectBrowserServerProfileNoSession(t *testing.T) { + withBrowserRoot(t) + _, _, err := connectBrowserServer(context.Background(), browserServerOptions{Profile: "ghost"}) + if err == nil || !strings.Contains(err.Error(), "no active browser session") { + t.Fatalf("err = %v, want no-active-session error", err) + } +} + func TestMCPServerRunBrowserServerIO_Success(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + requireChrome(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var stdout bytes.Buffer stdin := strings.NewReader("") // Providing empty reader will cause stdioServer.Listen to finish on EOF. - err := runBrowserServerIO(ctx, "", true, stdin, &stdout) + err := runBrowserServerIO(ctx, browserServerOptions{Headless: true, NoSandbox: true}, stdin, &stdout) if err != nil { t.Fatalf("runBrowserServerIO error: %v", err) } } func TestMCPServerBrowserCmd_Execute(t *testing.T) { + requireChrome(t) cmd := newMCPServerBrowserCmd() - cmd.SetArgs([]string{"--headless=true"}) + cmd.SetArgs([]string{"--headless=true", "--no-sandbox"}) cmd.SetIn(strings.NewReader("")) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd.SetContext(ctx) @@ -83,7 +167,7 @@ func TestMCPServerBrowserCmd_Execute(t *testing.T) { func TestMCPServerRegisterBrowserTools_ErrorCases(t *testing.T) { s := server.NewMCPServer("test-browser", "1.0.0") - allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), chromedp.Flag("headless", true), chromedp.Flag("no-sandbox", true)) + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), chromeExecOpts()...) defer cancelAlloc() ctx, cancel := chromedp.NewContext(allocCtx) cancel() // cancel immediately to trigger execution errors @@ -153,18 +237,26 @@ func callTool(t *testing.T, s *server.MCPServer, ctx context.Context, name strin } func TestMCPServerRegisterBrowserTools_LiveExecution(t *testing.T) { + requireChrome(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") _, _ = fmt.Fprint(w, `