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
270 changes: 268 additions & 2 deletions cmd/sin-code/chat_tools_extra.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: MIT
// Purpose: extended builtin tools — git operations (read-only allow,
// mutating ask), bounded HTTP fetch, and test runner. Closes the gap
// between "needs sin_bash" and "needs a real tool".
// mutating ask), bounded HTTP fetch, test runner, and browser CDP recording.
// Closes the gap between "needs sin_bash" and "needs a real tool".
//
// Browser tools (sin_browser_*) use pkg/browser/cdp to drive a headless
// Chrome instance via the Chrome DevTools Protocol. They capture the full
// event stream — network, console, exceptions, DevTools Audits (CORS/CSP),
// security state — and surface deterministic Findings so the agent loop can
// act on problems without reading raw JSONL.
package main

import (
Expand All @@ -15,6 +21,9 @@ import (
"path/filepath"
"strings"
"time"

"github.com/OpenSIN-Code/SIN-Code/pkg/browser/cdp"
"github.com/chromedp/chromedp"
)

const (
Expand All @@ -40,6 +49,26 @@ func extraSpecs() []agentloopToolSpecAlias {
InputSchema: obj(map[string]any{"url": str("http(s) URL")}, "url")},
{Name: "sin_test", Description: "Run the workspace test suite and return structured pass/fail output.",
InputSchema: obj(map[string]any{"target": str("optional package/file filter")})},
// Browser CDP tools — headless Chrome via Chrome DevTools Protocol.
// sin_browser_navigate starts a fresh recording session; subsequent
// sin_browser_findings / sin_browser_snapshot calls consume it.
{Name: "sin_browser_navigate", Description: "Navigate headless Chrome to a URL and record the full CDP event stream (network, console, exceptions, DevTools Audits, security, Web Vitals). Returns event counts. Call sin_browser_findings after to get the full Report. Set save_baseline=true before applying a fix so sin_browser_diff can compare before/after.",
InputSchema: obj(map[string]any{
"url": str("http(s) URL to navigate to"),
"step": str("optional label for correlation (e.g. 'login_submit')"),
"wait_sec": str("seconds to wait after navigation (default 3)"),
"save_baseline": str("set to 'true' to save this session's Report as the baseline for sin_browser_diff"),
}, "url")},
{Name: "sin_browser_findings", Description: "Return a full structured Report from the last sin_browser_navigate session: classified Findings (network/console/exception/audit/security/vital), root-cause Chains, FixSuggestions with FixClass routing tags, and a Summary (errors/warnings/has_fatal). Use this instead of reading raw JSONL.",
InputSchema: obj(map[string]any{})},
{Name: "sin_browser_snapshot", Description: "Return a compact JSON summary of the last sin_browser_navigate session: event counts by domain, first/last wall times, finding count, and the full Report. Useful for a quick health check before calling sin_browser_diff.",
InputSchema: obj(map[string]any{})},
{Name: "sin_browser_vitals_flush", Description: "Force a final Web Vitals metric flush in the current browser tab so that LCP/CLS/INP values are captured before calling sin_browser_findings. Call this right before sin_browser_findings when the page is already loaded.",
InputSchema: obj(map[string]any{})},
{Name: "sin_browser_diff", Description: "Compare two browser sessions — the stored baseline (saved by the last sin_browser_navigate with save_baseline=true) with the current session — and return a Diff: resolved, introduced, and persisted Findings plus an 'improved' flag. Use after applying a fix to verify it worked.",
InputSchema: obj(map[string]any{
"window": str("correlation window in sequence steps for the current session (default 25)"),
})},
}
}

Expand Down Expand Up @@ -74,6 +103,16 @@ func extraTool(ctx context.Context, name string, args map[string]any) (string, e
return toolHTTPGet(ctx, argStr(args, "url"))
case "sin_test":
return toolTest(ctx, argStr(args, "target"))
case "sin_browser_navigate":
return toolBrowserNavigate(ctx, argStr(args, "url"), argStr(args, "step"), argStr(args, "wait_sec"), argStr(args, "save_baseline"))
case "sin_browser_findings":
return toolBrowserFindings()
case "sin_browser_snapshot":
return toolBrowserSnapshot()
case "sin_browser_vitals_flush":
return toolBrowserVitalsFlush(ctx)
case "sin_browser_diff":
return toolBrowserDiff(argStr(args, "window"))
default:
return "", fmt.Errorf("unknown tool %q", name)
}
Expand Down Expand Up @@ -158,3 +197,230 @@ func fileExists(p string) bool {

// silence linter for unused json
var _ = json.Marshal

// ----------------------------------------------------------------------------
// Browser CDP session state — process-wide singleton for the agent loop.
// Only one recording session is active at a time; sin_browser_navigate
// replaces any previous session.
// ----------------------------------------------------------------------------

var (
browserSession *activeBrowserSession
)

type activeBrowserSession struct {
rec *cdp.Recorder
cancelCtx context.CancelFunc
jsonlPath string
// cdpCtx is kept alive so EvalVitalsNow can run after navigation completes.
cdpCtx context.Context
// baseline is the Report saved from a prior run for DiffReports comparison.
baseline *cdp.Report
}

// toolBrowserNavigate drives headless Chrome to url, records the full CDP
// event stream (including Web Vitals), and returns a short status string.
func toolBrowserNavigate(ctx context.Context, url, step, waitSecStr, saveBaselineStr string) (string, error) {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return "", fmt.Errorf("sin_browser_navigate: only http(s) URLs are supported")
}

// Parse optional wait duration; default 3 s.
waitSec := 3
if waitSecStr != "" {
var n int
if _, err := fmt.Sscanf(waitSecStr, "%d", &n); err == nil && n >= 0 && n <= 120 {
waitSec = n
}
}

// Tear down any previous session before starting a new one.
if browserSession != nil {
_ = browserSession.rec.Close()
browserSession.cancelCtx()
browserSession = nil
}

// Write the JSONL to a temp file so it survives the function call.
jsonlPath := filepath.Join(os.TempDir(), fmt.Sprintf("sin-browser-%d.jsonl", time.Now().UnixNano()))

cfg := cdp.DefaultConfig(jsonlPath)
rec, err := cdp.NewRecorder(cfg)
if err != nil {
return "", fmt.Errorf("sin_browser_navigate: failed to create recorder: %w", err)
}

allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx,
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
)...)

cdpCtx, cancelCDP := chromedp.NewContext(allocCtx)

rec.Attach(cdpCtx)
if err := rec.EnableDomains(cdpCtx); err != nil {
cancelCDP()
cancelAlloc()
_ = rec.Close()
return "", fmt.Errorf("sin_browser_navigate: enable domains: %w", err)
}
// Install PerformanceObserver script so LCP/CLS/INP/LongTask metrics are
// captured as "__SINCDP_VITAL__"-tagged events on every new document.
if err := rec.InstallVitals(cdpCtx); err != nil {
// Non-fatal: vitals injection failing should not abort the session.
_ = err
}

if step != "" {
rec.SetStep(step)
} else {
rec.SetStep("navigate")
}

navErr := chromedp.Run(cdpCtx,
chromedp.Navigate(url),
chromedp.Sleep(time.Duration(waitSec)*time.Second),
)

rec.SetStep("")

// Store session for subsequent findings/snapshot/diff calls.
// combinedCancel releases both the CDP context and the allocator.
combinedCancel := func() { cancelCDP(); cancelAlloc() }
var prevBaseline *cdp.Report
if browserSession != nil {
prevBaseline = browserSession.baseline // carry over baseline across navigations
}
browserSession = &activeBrowserSession{
rec: rec,
cancelCtx: combinedCancel,
jsonlPath: jsonlPath,
cdpCtx: cdpCtx,
baseline: prevBaseline,
}
// If save_baseline=true, immediately build and store the baseline Report.
if saveBaselineStr == "true" {
browserSession.baseline = cdp.BuildReport(rec.Events(), 25)
}

events := rec.Events()
if navErr != nil {
return fmt.Sprintf("navigation error: %v (captured %d events, JSONL: %s)", navErr, len(events), jsonlPath), nil
}

// Quick domain breakdown for immediate feedback.
counts := map[string]int{}
for _, e := range events {
counts[e.Domain]++
}
b, _ := json.MarshalIndent(counts, "", " ")
return fmt.Sprintf("navigated to %s — %d events captured\nJSONL: %s\ndomains: %s",
url, len(events), jsonlPath, string(b)), nil
}

// toolBrowserFindings runs the full deterministic analysis pipeline over the
// last recorded session and returns a structured Report as JSON. The Report
// includes Findings, root-cause Chains, FixSuggestions, and a Summary.
func toolBrowserFindings() (string, error) {
if browserSession == nil {
return "", fmt.Errorf("sin_browser_findings: no active browser session — call sin_browser_navigate first")
}
report := cdp.BuildReport(browserSession.rec.Events(), 25)
if report.Summary.Errors == 0 && report.Summary.Warnings == 0 {
b, _ := json.MarshalIndent(report, "", " ")
return fmt.Sprintf("no errors or warnings detected\n%s", string(b)), nil
}
b, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", fmt.Errorf("sin_browser_findings: marshal: %w", err)
}
return fmt.Sprintf("report: %d error(s), %d warning(s), %d suggestion(s), fatal=%v\n%s",
report.Summary.Errors, report.Summary.Warnings,
len(report.Suggestions), report.Summary.HasFatal,
string(b)), nil
}

// toolBrowserSnapshot returns a compact JSON summary of the last session using
// BuildReport so the agent gets findings, chains, and suggestions in one call.
func toolBrowserSnapshot() (string, error) {
if browserSession == nil {
return "", fmt.Errorf("sin_browser_snapshot: no active browser session — call sin_browser_navigate first")
}
events := browserSession.rec.Events()
if len(events) == 0 {
return `{"total_events":0}`, nil
}

report := cdp.BuildReport(events, 25)

// Add per-method event counts as extra context not in the report itself.
counts := map[string]int{}
for _, e := range events {
counts[e.Domain+"."+e.Method]++
}

snap := map[string]interface{}{
"total_events": len(events),
"first_wall": events[0].WallTime,
"last_wall": events[len(events)-1].WallTime,
"event_counts": counts,
"report": report,
"jsonl": browserSession.jsonlPath,
}
b, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return "", fmt.Errorf("sin_browser_snapshot: marshal: %w", err)
}
return string(b), nil
}

// toolBrowserVitalsFlush forces a final Web Vitals metric flush in the live
// browser tab. Call before toolBrowserFindings when the page is already loaded
// so that final CLS/LCP values are emitted before BuildReport runs.
func toolBrowserVitalsFlush(ctx context.Context) (string, error) {
if browserSession == nil {
return "", fmt.Errorf("sin_browser_vitals_flush: no active browser session — call sin_browser_navigate first")
}
browserSession.rec.EvalVitalsNow(browserSession.cdpCtx)
return "vitals flushed — call sin_browser_findings to get updated metrics", nil
}

// toolBrowserDiff compares the saved baseline Report with the current session's
// Report and returns a Diff showing resolved, introduced, and persisted findings.
func toolBrowserDiff(windowStr string) (string, error) {
if browserSession == nil {
return "", fmt.Errorf("sin_browser_diff: no active browser session — call sin_browser_navigate first")
}
if browserSession.baseline == nil {
return "", fmt.Errorf("sin_browser_diff: no baseline saved — navigate with save_baseline=true first")
}

window := uint64(25)
if windowStr != "" {
var n uint64
if _, err := fmt.Sscanf(windowStr, "%d", &n); err == nil && n > 0 && n <= 1000 {
window = n
}
}

after := cdp.BuildReport(browserSession.rec.Events(), window)
diff := cdp.DiffReports(browserSession.baseline, after)

b, err := json.MarshalIndent(diff, "", " ")
if err != nil {
return "", fmt.Errorf("sin_browser_diff: marshal: %w", err)
}
verdict := "no improvement (errors unchanged)"
if diff.Improved {
verdict = "improved"
} else if len(diff.Introduced) > 0 {
verdict = "regression introduced"
}
return fmt.Sprintf("diff: %s — resolved=%d introduced=%d persisted=%d before_errors=%d after_errors=%d\n%s",
verdict,
len(diff.Resolved), len(diff.Introduced), len(diff.Persisted),
diff.BeforeErr, diff.AfterErr,
string(b)), nil
}
10 changes: 10 additions & 0 deletions cmd/sin-code/internal/permission_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ func DefaultPermissionRules() []permission.Rule {
{Tool: "sin_bash", Policy: "ask"},
{Tool: "sin_sbom_generate", Policy: "allow"},
{Tool: "sin_security_scan", Policy: "allow"},
// v3.18+: Browser CDP tools.
// sin_browser_navigate drives headless Chrome — it can load arbitrary
// URLs so it requires explicit user confirmation (ask).
// sin_browser_findings and sin_browser_snapshot only read the already-
// captured in-memory event slice; no network calls, no side effects.
{Tool: "sin_browser_navigate", Policy: "ask"},
{Tool: "sin_browser_findings", Policy: "allow"},
{Tool: "sin_browser_snapshot", Policy: "allow"},
{Tool: "sin_browser_vitals_flush", Policy: "allow"},
{Tool: "sin_browser_diff", Policy: "allow"},

// v3.16.0: autodev-cli bridge (Bridged-External + autodev-mcp stdio MCP).
// Qualified name = server-name + "__" + tool-name (registry.go "autodev" + autodev-mcp tools).
Expand Down
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b
github.com/chromedp/chromedp v0.13.6
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/rogpeppe/go-internal v1.15.0
github.com/spf13/cobra v1.10.2
Expand All @@ -37,6 +39,12 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa // indirect
Expand Down
Loading
Loading