Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion agenticcli/claude_live.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"io"
"os/exec"
"strings"
"syscall"
"time"

"github.com/cowdogmoo/squad/logging"
Expand Down Expand Up @@ -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')
Expand Down
27 changes: 27 additions & 0 deletions agenticcli/claude_live_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agenticcli

import (
"bufio"
"bytes"
"context"
"encoding/json"
Expand Down Expand Up @@ -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"})

Expand Down
2 changes: 1 addition & 1 deletion browser/active.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
19 changes: 14 additions & 5 deletions browser/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions browser/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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)
}
Expand Down
22 changes: 19 additions & 3 deletions cmd/squad/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
104 changes: 89 additions & 15 deletions cmd/squad/browser_eval.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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))
}
19 changes: 14 additions & 5 deletions cmd/squad/browser_eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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,<body>HELLO-EVAL-PROBE</body>")); err != nil {
t.Skipf("skipping live browser test: %v", err)
}

Expand All @@ -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())
}
}
Loading
Loading