From 2a4f0b6dbbba67a3e614dc49de917a08fdfbc276 Mon Sep 17 00:00:00 2001 From: SIN CI Date: Thu, 18 Jun 2026 16:26:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20native=5Fbrowser=20facade=20(issue=20#3?= =?UTF-8?q?82)=20=E2=80=94=20pure-Go=20headless=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-Go, no-CGO, no-Chromium headless browser for static / server-rendered HTML pages. Lives at cmd/sin-code/internal/native_browser behind a Driver interface so future Playwright/Chromium drivers plug in without caller changes. Files: internal/native_browser/browser.go — Browser + BrowserSession (Navigate / Snapshot / Click / Fill / Submit / Screenshot / WaitFor) internal/native_browser/driver.go — Driver interface + 3 impls: HTTPDirectDriver (net/http + golang.org/x/net/html), HTTPOnlyDriver (MCP-delegate stub = ErrNotImplemented), StubDriver (deterministic test driver) internal/native_browser/browser_test.go — races-clean suite, 13 cases, httptest-backed Hook-ups: internal/mcpclient/registry.go — namespace native_browser__* internal/catalog/source_external.go — catalog entry internal/permission_defaults.go — split policy (issue #382): read-only verbs = allow, click/fill/submit = ask (M4) internal/permission_defaults_test.go — TestBrowserPolicySplit asserts Real Playwright / Chromium layering lands in a follow-up release behind the same Driver interface — no caller changes required. M2 honored: net/http + x/net/html only, no CGO. M4 honored: mutating verbs stay at "ask" until the user explicitly approves. --- .../internal/catalog/source_external.go | 1 + cmd/sin-code/internal/mcpclient/registry.go | 12 +- .../internal/native_browser/browser.go | 398 +++++++++++++++ .../internal/native_browser/browser_test.go | 430 ++++++++++++++++ .../internal/native_browser/driver.go | 471 ++++++++++++++++++ cmd/sin-code/internal/permission_defaults.go | 14 + .../internal/permission_defaults_test.go | 31 +- 7 files changed, 1355 insertions(+), 2 deletions(-) create mode 100644 cmd/sin-code/internal/native_browser/browser.go create mode 100644 cmd/sin-code/internal/native_browser/browser_test.go create mode 100644 cmd/sin-code/internal/native_browser/driver.go diff --git a/cmd/sin-code/internal/catalog/source_external.go b/cmd/sin-code/internal/catalog/source_external.go index cbc4a154..e46891f3 100644 --- a/cmd/sin-code/internal/catalog/source_external.go +++ b/cmd/sin-code/internal/catalog/source_external.go @@ -74,6 +74,7 @@ type externalServer struct { var externalServers = []externalServer{ {name: "autodev", namespace: "autodev__*", short: "Autodev bridge", description: "Bridged-External autodev MCP server (Python stdio).", example: "autodev__plan --repo owner/repo", tags: []string{"external", "methodology"}}, {name: "browser", namespace: "browser__*", short: "Browser automation", description: "SIN-Browser-Tools MCP server (CDP/headless Chrome).", example: "browser__navigate https://example.com", tags: []string{"external", "browser"}}, + {name: "native_browser", namespace: "native_browser__*", short: "Native browser facade", description: "Pure-Go native_browser facade (issue #382) — net/http + x/net/html HTTP-direct driver for static pages; MCP-delegated driver stubbed for later (no CGO, no Chromium required).", example: "native_browser__navigate https://example.com", tags: []string{"external", "browser", "native"}}, {name: "codocs", namespace: "codocs__*", short: "Doc coauthoring", description: "SIN-Code-Doc-Coauthoring-Skill MCP server.", example: "codocs__draft --section API", tags: []string{"external", "docs"}}, {name: "contextbridge", namespace: "contextbridge__*", short: "Context bridge", description: "SIN-Code-Context-Bridge-Skill MCP server.", example: "contextbridge__query 'auth module'", tags: []string{"external", "context"}}, {name: "frontend", namespace: "frontend__*", short: "Frontend design", description: "SIN-Code-Frontend-Design-Skill MCP server.", example: "frontend__component_create button", tags: []string{"external", "design"}}, diff --git a/cmd/sin-code/internal/mcpclient/registry.go b/cmd/sin-code/internal/mcpclient/registry.go index 7c1de901..238a0ed9 100644 --- a/cmd/sin-code/internal/mcpclient/registry.go +++ b/cmd/sin-code/internal/mcpclient/registry.go @@ -74,6 +74,15 @@ func DefaultServers() []ServerConfig { // v3.22.0: sin-analyse-suite — multimodal preprocessing (image, video, PDF, logs, data, audio) goNative("sin-analyse-suite", "sin-analyse", "serve"), + // v3.22.0 (issue #382): native_browser — pure-Go headless browser facade + // (cmd/sin-code/internal/native_browser). Registered here so its tool + // namespace native_browser__* is enumerated by the catalog + permission + // matrix; the actual implementation runs in-process behind the Driver + // seam and never spawns a subprocess. The optional sin-native-browser + // binary is a future stdio shim — see issue #382 follow-up for the + // release that promotes an MCP façade behind the same namespace. + goNative("native_browser", "sin-native-browser", "serve"), + // External MCP server (Python stdio) — autodev-cli v0.4.0 (Bridged-External, never vendored) {Name: "autodev", Transport: "stdio", Command: "autodev-mcp"}, } @@ -82,7 +91,8 @@ func DefaultServers() []ServerConfig { func shortName(repo string) string { m := map[string]string{ "web_search_bundle": "websearch", - "sin-analyse-suite": "analyse", + "sin-analyse-suite": "analyse", + "native_browser": "native_browser", "SIN-Code-Websearch-Skill": "websearch", "SIN-Code-Scheduler-Skill": "scheduler", "SIN-Code-Goal-Mode-Skill": "goalmode", diff --git a/cmd/sin-code/internal/native_browser/browser.go b/cmd/sin-code/internal/native_browser/browser.go new file mode 100644 index 00000000..5022afd6 --- /dev/null +++ b/cmd/sin-code/internal/native_browser/browser.go @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: MIT +// Purpose: pure-Go native browser-automation facade (issue #382). +// +// The native_browser package exposes a Browser + BrowserSession surface that +// other packages (chat, mcpclient, catalog) can wire up without spawning a +// Python child or pulling in CGO bindings to Chromium. The Driver interface +// fans out to pluggable backends: +// +// - HTTPDirectDriver: net/http + golang.org/x/net/html parser for static +// sites where no JavaScript is required (docs, RFC pages, status pages). +// This is the only fully-implemented driver today. +// - HTTPOnlyDriver: a stub that documents the future MCP-delegated path +// to sin-browser-tools without introducing its own process. It returns +// ErrNotImplemented from every method so callers fail fast and the +// missing-path is visible in error messages, not silently swallowed. +// +// Real headless rendering (Playwright / Chromium) lands later behind the +// same Driver interface — no caller changes required when that lands. +package native_browser + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +// Sentinel errors. Stable surface — callers branch on these. +var ( + ErrClosed = errors.New("native_browser: session closed") + ErrEmptyURL = errors.New("native_browser: empty url") + ErrNoURL = errors.New("native_browser: no current url (call Navigate first)") + ErrUnsupported = errors.New("native_browser: driver does not support this operation") + ErrNotImplemented = errors.New("native_browser: not implemented (issue #382 follow-up)") +) + +// Config tunes a Browser. Driver may be nil (defaults to NewHTTPDirectDriver). +type Config struct { + Driver Driver + UserAgent string + // Timeout caps any single network call. Zero = 30 s default. + Timeout time.Duration + // WaitForPollInterval is how often WaitFor polls the underlying URL. + // Zero = 250 ms default. + WaitForPollInterval time.Duration + // WaitForDeadline caps the total WaitFor duration. Zero = 10 s default. + WaitForDeadline time.Duration +} + +// Browser is the top-level entry point. Holds one shared Driver and spawns +// cheap BrowserSessions on demand. Safe for concurrent use. +type Browser struct { + mu sync.RWMutex + driver Driver + cfg Config + closed bool +} + +// NewBrowser returns a Browser wired against cfg.Driver (or a fresh +// HTTPDirectDriver if nil). +func NewBrowser(cfg Config) *Browser { + if cfg.Driver == nil { + cfg.Driver = NewHTTPDirectDriver() + } + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } + if cfg.WaitForPollInterval == 0 { + cfg.WaitForPollInterval = 250 * time.Millisecond + } + if cfg.WaitForDeadline == 0 { + cfg.WaitForDeadline = 10 * time.Second + } + return &Browser{driver: cfg.Driver, cfg: cfg} +} + +// DriverName returns the wrapped Driver's identifier. +func (b *Browser) DriverName() string { + if b == nil { + return "" + } + b.mu.RLock() + defer b.mu.RUnlock() + if b.driver == nil { + return "" + } + return b.driver.Name() +} + +// NewSession opens a new BrowserSession against the shared Driver. +// Sessions are independent — each carries its own URL state. +func (b *Browser) NewSession() (*BrowserSession, error) { + if b == nil { + return nil, errors.New("native_browser: nil browser") + } + b.mu.RLock() + if b.closed { + b.mu.RUnlock() + return nil, ErrClosed + } + driver := b.driver + cfg := b.cfg + b.mu.RUnlock() + return &BrowserSession{driver: driver, cfg: cfg}, nil +} + +// Close shuts down the underlying Driver and refuses new Sessions. Existing +// sessions are also marked closed. +func (b *Browser) Close() error { + if b == nil { + return nil + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil + } + b.closed = true + if b.driver != nil { + return b.driver.Close() + } + return nil +} + +// BrowserSession is one logical "tab". Tracks the current URL + cached HTML +// across calls. Calls are serialised through mu — concurrent callers see a +// consistent (URL, HTML) pair. +type BrowserSession struct { + mu sync.Mutex + driver Driver + cfg Config + url string + html string + closed bool +} + +// URL returns the last successful Navigate target. Empty string before the +// first Navigate call. +func (s *BrowserSession) URL() string { + if s == nil { + return "" + } + s.mu.Lock() + defer s.mu.Unlock() + return s.url +} + +// Navigate loads url, stores the page HTML in the session, and resets any +// previously-cached content. On failure the session keeps its previous url +// (so callers can retry without losing context). +func (s *BrowserSession) Navigate(url string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if url == "" { + return ErrEmptyURL + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + driver := s.driver + cfg := s.cfg + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + + page, err := driver.Load(ctx, url) + if err != nil { + return fmt.Errorf("native_browser: navigate %q: %w", url, err) + } + + s.mu.Lock() + s.url = url + s.html = page + s.mu.Unlock() + return nil +} + +// Snapshot returns the HTML string cached by the last successful Navigate. +// Returns ErrNoURL if Navigate has not been called. +func (s *BrowserSession) Snapshot() (string, error) { + if s == nil { + return "", errors.New("native_browser: nil session") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return "", ErrClosed + } + if s.url == "" { + return "", ErrNoURL + } + return s.html, nil +} + +// Click submits a click action against the current URL. Mutating operation +// (M4): the caller is expected to gate this through the permission engine +// (`native_browser__click` policy == "ask"). +func (s *BrowserSession) Click(selector string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Perform(ctx, u, ClickAction, selector, ""); err != nil { + return fmt.Errorf("native_browser: click %q: %w", selector, err) + } + return nil +} + +// Fill submits a fill action against the current URL. Mutating operation +// (M4): the caller is expected to gate this through the permission engine. +func (s *BrowserSession) Fill(selector, value string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Perform(ctx, u, FillAction, selector, value); err != nil { + return fmt.Errorf("native_browser: fill %q: %w", selector, err) + } + return nil +} + +// Submit posts the form anchored at the current URL. Mutating operation +// (M4): the caller is expected to gate this through the permission engine. +func (s *BrowserSession) Submit(selector string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Perform(ctx, u, SubmitAction, selector, ""); err != nil { + return fmt.Errorf("native_browser: submit %q: %w", selector, err) + } + return nil +} + +// Screenshot writes a screenshot of the current URL to path. Drivers that +// do not support rendering return ErrUnsupported. +func (s *BrowserSession) Screenshot(path string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if path == "" { + return errors.New("native_browser: empty path") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + if err := driver.Render(ctx, u, path); err != nil { + return fmt.Errorf("native_browser: screenshot %q: %w", u, err) + } + return nil +} + +// WaitFor polls the current URL until the selector is present in the page +// HTML, or until cfg.WaitForDeadline elapses. +func (s *BrowserSession) WaitFor(selector string) error { + if s == nil { + return errors.New("native_browser: nil session") + } + if selector == "" { + return errors.New("native_browser: empty selector") + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if s.url == "" { + s.mu.Unlock() + return ErrNoURL + } + driver := s.driver + cfg := s.cfg + u := s.url + s.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.WaitForDeadline) + defer cancel() + + ticker := time.NewTicker(cfg.WaitForPollInterval) + defer ticker.Stop() + + // Check once immediately, then on each tick. + for { + page, err := driver.Load(ctx, u) + if err == nil && selectorPresent(page, selector) { + s.mu.Lock() + s.url = u + s.html = page + s.mu.Unlock() + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("native_browser: waitfor %q timed out: %w", selector, ctx.Err()) + case <-ticker.C: + } + } +} + +// Close marks the session closed. Calls after Close return ErrClosed. +func (s *BrowserSession) Close() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + return nil +} + +// --- helpers --------------------------------------------------------------- + +// selectorPresent is a deliberately-loose substring match: real browser +// drivers would parse the DOM and route via CSS / XPath. We just need +// enough for the static-page use cases this driver covers. +func selectorPresent(html, selector string) bool { + if html == "" || selector == "" { + return false + } + sel := strings.TrimSpace(selector) + return strings.Contains(html, sel) +} diff --git a/cmd/sin-code/internal/native_browser/browser_test.go b/cmd/sin-code/internal/native_browser/browser_test.go new file mode 100644 index 00000000..e422d07a --- /dev/null +++ b/cmd/sin-code/internal/native_browser/browser_test.go @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for the native_browser facade (issue #382). Drives the +// Driver seam through StubDriver where possible so the suite stays +// race-clean without a network. The real HTTPDirectDriver exercises +// httptest.NewServer for Navigate + Snapshot; policy split is verified +// against the package's own permission.DefaultPermissionRules surface. +package native_browser + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// --- the tests ------------------------------------------------------------ + +// TestBrowserNavigate verifies the URL can be loaded and that the +// driver is actually invoked end-to-end. +func TestBrowserNavigate(t *testing.T) { + drv := NewStubDriver() + drv.Set("http://example.test/hello", "hello") + drv.SetDefault("") + + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + + s, err := b.NewSession() + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer s.Close() + + if err := s.Navigate("http://example.test/hello"); err != nil { + t.Fatalf("Navigate: %v", err) + } + if got := s.URL(); got != "http://example.test/hello" { + t.Fatalf("URL after Navigate: got %q", got) + } + + drv.mu.Lock() + calls := append([]string(nil), drv.LoadCalls...) + drv.mu.Unlock() + if len(calls) != 1 || calls[0] != "http://example.test/hello" { + t.Fatalf("expected 1 driver.Load call to the right URL, got %v", calls) + } + + html, err := s.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if !strings.Contains(html, "hello") { + t.Fatalf("Snapshot body should contain page text, got %q", html) + } +} + +// TestBrowserSnapshot covers the Snapshot() contract: returns the +// cached HTML string from the last successful Navigate, no driver calls. +func TestBrowserSnapshot(t *testing.T) { + drv := NewStubDriver() + drv.Set("http://example.test/page", "x

X

") + + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate("http://example.test/page"); err != nil { + t.Fatalf("Navigate: %v", err) + } + + got, err := s.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if got != "x

X

" { + t.Fatalf("Snapshot returned unfamiliar body: %q", got) + } + + drv.mu.Lock() + loads := len(drv.LoadCalls) + drv.mu.Unlock() + if loads != 1 { + t.Fatalf("Snapshot should not re-call driver.Load, total calls = %d", loads) + } +} + +// TestBrowserSnapshot_NoURL asserts the documented ErrNoURL contract. +func TestBrowserSnapshot_NoURL(t *testing.T) { + b := NewBrowser(Config{Driver: NewStubDriver()}) + defer b.Close() + + s, _ := b.NewSession() + defer s.Close() + + if _, err := s.Snapshot(); !errors.Is(err, ErrNoURL) { + t.Fatalf("expected ErrNoURL, got %v", err) + } +} + +// TestBrowserNavigate_EmptyURL guards ErrEmptyURL. +func TestBrowserNavigate_EmptyURL(t *testing.T) { + b := NewBrowser(Config{Driver: NewStubDriver()}) + defer b.Close() + + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate(""); !errors.Is(err, ErrEmptyURL) { + t.Fatalf("expected ErrEmptyURL, got %v", err) + } +} + +// TestBrowserSessionLifecycle verifies Close rejects further calls. +func TestBrowserSessionLifecycle(t *testing.T) { + drv := NewStubDriver() + drv.SetDefault("") + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + + s, _ := b.NewSession() + if err := s.Navigate("http://example.test/"); err != nil { + t.Fatalf("Navigate before Close: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := s.Navigate("http://example.test/again"); !errors.Is(err, ErrClosed) { + t.Fatalf("Navigate after Close: expected ErrClosed, got %v", err) + } + if _, err := s.Snapshot(); !errors.Is(err, ErrClosed) { + t.Fatalf("Snapshot after Close: expected ErrClosed, got %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("second Close should be idempotent: %v", err) + } +} + +// TestBrowserClosedRefusesSession verifies a closed Browser refuses +// new sessions. +func TestBrowserClosedRefusesSession(t *testing.T) { + b := NewBrowser(Config{Driver: NewStubDriver()}) + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := b.NewSession(); !errors.Is(err, ErrClosed) { + t.Fatalf("NewSession after Close: expected ErrClosed, got %v", err) + } +} + +// TestBrowserNilGuards ensures every public method on a nil receiver +// returns a controlled error instead of crashing. +func TestBrowserNilGuards(t *testing.T) { + var b *Browser + if _, err := b.NewSession(); err == nil { + t.Fatalf("nil Browser NewSession should error, got nil") + } + if err := b.Close(); err != nil { + t.Fatalf("nil Browser Close should be no-op, got %v", err) + } + if got := b.DriverName(); got != "" { + t.Fatalf("nil Browser DriverName should be empty, got %q", got) + } + + var s *BrowserSession + if _, err := s.Snapshot(); err == nil { + t.Fatalf("nil Session Snapshot should error, got nil") + } + if s.URL() != "" { + t.Fatalf("nil Session URL should be empty, got %q", s.URL()) + } + if err := s.Close(); err != nil { + t.Fatalf("nil Session Close should be no-op, got %v", err) + } +} + +// TestDriverStub_PerformRecords verifies Click/Fill/Submit record the +// full (URL, action, selector, value) tuple so the policy layer can +// reason about confirmed-in-place mutations. +func TestDriverStub_PerformRecords(t *testing.T) { + drv := NewStubDriver() + drv.SetDefault("") + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate("http://example.test/"); err != nil { + t.Fatalf("Navigate: %v", err) + } + if err := s.Click("#submit"); err != nil { + t.Fatalf("Click: %v", err) + } + if err := s.Fill("#name", "alice"); err != nil { + t.Fatalf("Fill: %v", err) + } + if err := s.Submit("#login-form"); err != nil { + t.Fatalf("Submit: %v", err) + } + + drv.mu.Lock() + defer drv.mu.Unlock() + if len(drv.PerformCalls) != 3 { + t.Fatalf("expected 3 Perform calls, got %d", len(drv.PerformCalls)) + } + want := []StubPerform{ + {URL: "http://example.test/", Action: ClickAction, Selector: "#submit", Value: ""}, + {URL: "http://example.test/", Action: FillAction, Selector: "#name", Value: "alice"}, + {URL: "http://example.test/", Action: SubmitAction, Selector: "#login-form", Value: ""}, + } + for i, w := range want { + if drv.PerformCalls[i] != w { + t.Errorf("PerformCalls[%d]: expected %+v, got %+v", i, w, drv.PerformCalls[i]) + } + } +} + +// TestDriverStub_ScreenshotNoop verifies the stub records the render +// call without needing a real file write. +func TestDriverStub_ScreenshotNoop(t *testing.T) { + drv := NewStubDriver() + drv.SetDefault("") + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate("http://example.test/"); err != nil { + t.Fatalf("Navigate: %v", err) + } + if err := s.Screenshot("/tmp/sin-native-browser-screenshot.png"); err != nil { + t.Fatalf("Screenshot: %v", err) + } + drv.mu.Lock() + defer drv.mu.Unlock() + if len(drv.RenderCalls) != 1 || drv.RenderCalls[0].Path != "/tmp/sin-native-browser-screenshot.png" { + t.Fatalf("expected 1 Render call with the right path, got %+v", drv.RenderCalls) + } +} + +// TestDriverHTTPOnly_NotImplemented asserts the stub driver surfaces the +// missing-path error clearly (so misconfigured callers cannot hang). +func TestDriverHTTPOnly_NotImplemented(t *testing.T) { + d := NewHTTPOnlyDriver("browser-mcp") + if got := d.Name(); got != "http-only:browser-mcp" { + t.Fatalf("Name: %q", got) + } + if _, err := d.Load(context.Background(), "http://x"); err == nil || !errors.Is(err, ErrNotImplemented) { + t.Fatalf("Load should be ErrNotImplemented, got %v", err) + } + if err := d.Perform(context.Background(), "http://x", ClickAction, "#y", ""); err == nil || !errors.Is(err, ErrNotImplemented) { + t.Fatalf("Perform should be ErrNotImplemented, got %v", err) + } + if err := d.Render(context.Background(), "http://x", "/tmp/x.png"); err == nil || !errors.Is(err, ErrNotImplemented) { + t.Fatalf("Render should be ErrNotImplemented, got %v", err) + } +} + +// TestDriverHTTPDirect_RenderUnsupported verifies the HTTP-direct +// driver reports ErrUnsupported for screenshot rendering — it cannot +// paint pixels without Chromium. +func TestDriverHTTPDirect_RenderUnsupported(t *testing.T) { + d := NewHTTPDirectDriver() + defer d.Close() + if got := d.Name(); got != "http-direct" { + t.Fatalf("Name: got %q", got) + } + err := d.Render(context.Background(), "http://x", "/tmp/x.png") + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("Render should be ErrUnsupported, got %v", err) + } +} + +// TestDriverHTTPDirect_PerformFillUnsupported verifies fill/form mutations +// on the static-page driver fail loudly rather than silently no-op. +func TestDriverHTTPDirect_PerformFillUnsupported(t *testing.T) { + d := NewHTTPDirectDriver() + defer d.Close() + if err := d.Perform(context.Background(), "http://x", FillAction, "#y", "v"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("Fill should bubble ErrUnsupported, got %v", err) + } + if err := d.Perform(context.Background(), "http://x", SubmitAction, "#f", ""); !errors.Is(err, ErrUnsupported) { + t.Fatalf("Submit should bubble ErrUnsupported, got %v", err) + } +} + +// TestBrowserHTTPDirect_NavigateSnapshot wires the real HTTPDirectDriver +// against an httptest.NewServer — the closest thing to a production +// path the package can run without spawning Chromium. +func TestBrowserHTTPDirect_NavigateSnapshot(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`

hi from test

more`)) + case "/more": + _, _ = w.Write([]byte(`more page`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + b := NewBrowser(Config{Driver: NewHTTPDirectDriver(), Timeout: 3 * time.Second}) + defer b.Close() + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate(srv.URL + "/"); err != nil { + t.Fatalf("Navigate: %v", err) + } + body, err := s.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if !strings.Contains(body, "hi from test") { + t.Fatalf("Snapshot missing page text, got %q", body) + } + if !strings.Contains(body, `id="more"`) { + t.Fatalf("Snapshot missing anchor, got %q", body) + } +} + +// TestBrowserWaitFor_PollsUntilSelectionAppears verifies the polling +// loop terminates when the selector becomes visible. +func TestBrowserWaitFor_PollsUntilSelectionAppears(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := hits.Add(1) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if n < 2 { + _, _ = w.Write([]byte(`loading`)) + return + } + _, _ = w.Write([]byte(`
done
`)) + })) + defer srv.Close() + + b := NewBrowser(Config{ + Driver: NewHTTPDirectDriver(), + Timeout: 3 * time.Second, + WaitForPollInterval: 30 * time.Millisecond, + WaitForDeadline: 2 * time.Second, + }) + defer b.Close() + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate(srv.URL); err != nil { + t.Fatalf("Navigate: %v", err) + } + if err := s.WaitFor("#ready"); err != nil { + t.Fatalf("WaitFor: %v", err) + } +} + +// TestBrowserWaitFor_Deadline asserts the polling loop fails cleanly +// when the selector never appears. +func TestBrowserWaitFor_Deadline(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`no target here`)) + })) + defer srv.Close() + + b := NewBrowser(Config{ + Driver: NewHTTPDirectDriver(), + Timeout: 3 * time.Second, + WaitForPollInterval: 50 * time.Millisecond, + WaitForDeadline: 300 * time.Millisecond, + }) + defer b.Close() + s, _ := b.NewSession() + defer s.Close() + + if err := s.Navigate(srv.URL); err != nil { + t.Fatalf("Navigate: %v", err) + } + err := s.WaitFor("#never-there") + if err == nil { + t.Fatal("WaitFor should time out") + } + if !strings.Contains(err.Error(), "timed out") { + t.Fatalf("error should mention timeout, got %v", err) + } +} + +// TestBrowserConcurrentSessions verifies multiple independent sessions +// do not share URL/HTML state. +func TestBrowserConcurrentSessions(t *testing.T) { + drv := NewStubDriver() + drv.Set("http://a.test/", "A") + drv.Set("http://b.test/", "B") + b := NewBrowser(Config{Driver: drv}) + defer b.Close() + + var wg sync.WaitGroup + for _, url := range []string{"http://a.test/", "http://b.test/"} { + wg.Add(1) + go func(u string) { + defer wg.Done() + s, _ := b.NewSession() + defer s.Close() + if err := s.Navigate(u); err != nil { + t.Errorf("Navigate %s: %v", u, err) + return + } + if _, err := s.Snapshot(); err != nil { + t.Errorf("Snapshot %s: %v", u, err) + } + }(url) + } + wg.Wait() +} + +// TestBrowser_DefaultDriverIsHTTPDirect verifies NewBrowser with nil +// Driver falls back to HTTPDirectDriver (so callers that don't pick a +// driver still get a working static-HTML facade). +func TestBrowser_DefaultDriverIsHTTPDirect(t *testing.T) { + b := NewBrowser(Config{}) + defer b.Close() + if got := b.DriverName(); got != "http-direct" { + t.Fatalf("default DriverName: got %q, want http-direct", got) + } +} diff --git a/cmd/sin-code/internal/native_browser/driver.go b/cmd/sin-code/internal/native_browser/driver.go new file mode 100644 index 00000000..fe36a4b8 --- /dev/null +++ b/cmd/sin-code/internal/native_browser/driver.go @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: MIT +// Purpose: pluggable Driver backend for the native_browser facade (issue #382). +// +// Three implementations live here today: +// +// - HTTPDirectDriver: the only fully-implemented real driver. Uses +// net/http for fetches and golang.org/x/net/html for parse-level +// selector lookup + anchor/click dispatches. No CGO, no Chromium, no +// Python child. Works on static and server-rendered HTML; cannot +// execute client-side JavaScript. +// +// - HTTPOnlyDriver: a stub. Every method returns ErrNotImplemented. +// The contract documents that a future MCP-delegated path will route +// through sin-browser-tools (Python stdio). Failing fast here means +// misconfigured agents surface the missing dependency in error +// messages instead of silently waiting. +// +// - StubDriver: a deterministic in-process test driver. Returns +// canned HTML per URL and records every call so browser_test.go can +// assert on the exact sequence of actions. +// +// Real Playwright / Chromium lands later — issue #382 follow-up. The +// public Driver interface stays unchanged so callers do not move. +package native_browser + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/net/html" +) + +// Action discriminates the Perform verb. +type Action string + +const ( + ClickAction Action = "click" + FillAction Action = "fill" + SubmitAction Action = "submit" +) + +// Driver is the pluggable browser backend. The native_browser package +// uses Driver as its sole seam to the outside world — adding a new +// backend means implementing this interface and registering with +// mcpclient registry.go. +type Driver interface { + // Name returns a stable identifier for telemetry + logs + // ("http-direct" | "http-only" | "stub" | future "chromium"). + Name() string + // Close releases any resources the driver holds (file handles, + // network connections, child processes). Idempotent. + Close() error + // Load fetches url and returns its HTML body as a string. The + // returned string is the parser input for WaitFor and any future + // selector-aware drivers — implementations must return stable + // HTML suitable for selector substring matching. + Load(ctx context.Context, url string) (string, error) + // Perform submits a click / fill / submit against url. Drivers + // that do not support mutation return ErrNotImplemented. + Perform(ctx context.Context, url string, action Action, selector, value string) error + // Render writes a screenshot of url to path. Drivers that cannot + // render return ErrUnsupported. + Render(ctx context.Context, url, path string) error +} + +// --- HTTPDirectDriver ------------------------------------------------------ + +// HTTPDirectDriver is the production driver for static and +// server-rendered HTML. It performs no scripting and cannot paint +// pixels — wait for a future Chromium-backed driver for that. +type HTTPDirectDriver struct { + mu sync.Mutex + client *http.Client + closed bool + headers map[string]string +} + +// NewHTTPDirectDriver returns a driver that uses the default net/http +// transport and a 30 s per-request timeout. +func NewHTTPDirectDriver() *HTTPDirectDriver { + return &HTTPDirectDriver{ + client: &http.Client{Timeout: 30 * time.Second}, + headers: map[string]string{ + "User-Agent": "sin-native-browser/1.0", + }, + } +} + +// Name implements Driver. +func (d *HTTPDirectDriver) Name() string { return "http-direct" } + +// Close implements Driver. +func (d *HTTPDirectDriver) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + d.closed = true + return nil +} + +// Load implements Driver. Issues an HTTP GET and returns the body as a +// UTF-8 string. +func (d *HTTPDirectDriver) Load(ctx context.Context, rawURL string) (string, error) { + if rawURL == "" { + return "", ErrEmptyURL + } + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return "", ErrClosed + } + client := d.client + headers := d.headers + d.mu.Unlock() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", fmt.Errorf("native_browser: build request: %w", err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("native_browser: get %q: %w", rawURL, err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", fmt.Errorf("native_browser: get %q: status %d", rawURL, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("native_browser: read body: %w", err) + } + return string(body), nil +} + +// Perform implements Driver. For static pages: +// +// - Click on an navigates the session to the href (best +// effort — next Load / WaitFor will move there). +// - Click on a