mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* Improve harness context efficiency
* Address harness efficiency review findings
* Make recaps idle and cancellable
Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.
Also strengthen the formatter safety regression to exercise formatter-modified edit output.
* Track every replace-all edit span
Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.
* Make review regressions cross-platform
Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.
* fix(tools): derive SeenWhole from the recorded ranges
SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.
That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:
the intended change depends on content that has not been read exactly
in this session. Read the affected lines with read_file, then retry.
Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.
SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.
Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): key the large-file test on the resolved path
read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.
Also switches the line generator to fmt.Fprintf (staticcheck QF1012).
Both from CodeRabbit on #842.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): actually switch the line generator to fmt.Fprintf
The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.
Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.
* fix: keep exact-read workflows recoverable
* test: cover permission normalization wiring
* fix: commit file observations after output budgeting
* fix: defer file observations across retries
* test: normalize patch line endings
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
977 lines
33 KiB
Go
977 lines
33 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/localcontrol"
|
|
)
|
|
|
|
const localBrowserOutputBudgetBytes = 128 * 1024
|
|
|
|
type localControlRunner interface {
|
|
Run(ctx context.Context, args ...string) (localcontrol.CommandResult, error)
|
|
}
|
|
|
|
type browserAppLauncher interface {
|
|
LaunchBrowserApp(ctx context.Context, request localcontrol.BrowserAppLaunchRequest) (localcontrol.BrowserAppLaunchResult, error)
|
|
}
|
|
|
|
func NewLocalBrowserTools(options localcontrol.BrowserOptions) []Tool {
|
|
return []Tool{
|
|
newBrowserInstallTool(options),
|
|
newBrowserLaunchTool(options),
|
|
newBrowserConnectTool(options),
|
|
newBrowserOpenTool(options),
|
|
newBrowserSnapshotTool(options),
|
|
newBrowserClickTool(options),
|
|
newBrowserTypeTool(options),
|
|
newBrowserPressTool(options),
|
|
newBrowserActionTool(options),
|
|
}
|
|
}
|
|
|
|
type browserInstallTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func newBrowserInstallTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserInstallTool{
|
|
baseTool: baseTool{
|
|
name: "browser_install",
|
|
deferred: true,
|
|
description: "Install the local browser runtime used by browser automation.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"with_deps": {Type: "boolean", Description: "Also install Linux system dependencies when supported by the helper.", Default: false},
|
|
},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Downloads browser runtime files for local browser automation."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserInstallTool) Run(ctx context.Context, args map[string]any) Result {
|
|
withDeps, err := boolArg(args, "with_deps", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_install: " + err.Error())
|
|
}
|
|
commandArgs := []string{"install"}
|
|
if withDeps {
|
|
commandArgs = append(commandArgs, "--with-deps")
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, "install", commandArgs)
|
|
}
|
|
|
|
type browserLaunchTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
launcher browserAppLauncher
|
|
}
|
|
|
|
func newBrowserLaunchTool(options localcontrol.BrowserOptions) Tool {
|
|
return newBrowserLaunchToolWithLauncher(options, localcontrol.NewBrowserAppLauncher(localcontrol.BrowserAppLaunchOptions{}))
|
|
}
|
|
|
|
func newBrowserLaunchToolWithLauncher(options localcontrol.BrowserOptions, launcher browserAppLauncher) Tool {
|
|
return browserLaunchTool{
|
|
baseTool: baseTool{
|
|
name: "browser_launch",
|
|
deferred: true,
|
|
description: "Launch a supported local Chromium/Electron app with Chrome DevTools enabled and attach browser automation to it. Use this for installed Electron apps such as Discord instead of launching GUI apps through shell commands.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"app": {Type: "string", Description: "Supported local app to launch.", Enum: []string{"discord"}},
|
|
"debug_port": {Type: "integer", Description: "Local Chrome DevTools port to expose.", Default: localcontrol.DefaultDevToolsPort, Minimum: intPtr(1024), Maximum: intPtr(65535)},
|
|
"stop_existing": {Type: "boolean", Description: "Stop the supported app before relaunching it with DevTools enabled.", Default: true},
|
|
"wait": {Type: "boolean", Description: "Wait until the DevTools endpoint responds before returning.", Default: true},
|
|
"connect": {Type: "boolean", Description: "Attach browser automation to the DevTools endpoint after launch.", Default: true},
|
|
},
|
|
Required: []string{"app"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Launches a supported local Chromium/Electron app with DevTools enabled and attaches local browser automation."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
launcher: launcher,
|
|
}
|
|
}
|
|
|
|
func (tool browserLaunchTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, err := browserLaunchRequestArg(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_launch: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
func (tool browserLaunchTool) Run(ctx context.Context, args map[string]any) Result {
|
|
request, err := browserLaunchRequestArg(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_launch: " + err.Error())
|
|
}
|
|
result, err := tool.launcher.LaunchBrowserApp(ctx, request)
|
|
if err != nil {
|
|
return errorResult("Error launching browser app: " + err.Error())
|
|
}
|
|
output := fmt.Sprintf("Launched %s with DevTools at %s (pid %d).", result.App, result.DevToolsURL, result.PID)
|
|
connect, err := boolArg(args, "connect", true)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_launch: " + err.Error())
|
|
}
|
|
if !connect {
|
|
return Result{
|
|
Status: StatusOK,
|
|
Output: output + "\n\nNext call browser_connect with target " + strconv.Itoa(result.DebugPort) + ".",
|
|
Meta: map[string]string{
|
|
"browser_command": "launch",
|
|
"app": result.App,
|
|
"debug_port": strconv.Itoa(result.DebugPort),
|
|
"pid": strconv.Itoa(result.PID),
|
|
},
|
|
Display: Display{Summary: "launch completed", Kind: "browser"},
|
|
}
|
|
}
|
|
connectResult := browserCommandResult(ctx, tool.browser, "connect", []string{"connect", strconv.Itoa(result.DebugPort)})
|
|
if connectResult.Status == StatusOK {
|
|
connectResult.Output = output + "\n\nBrowser automation is already connected; use browser_snapshot next.\n\n" + connectResult.Output
|
|
} else {
|
|
connectResult.Output = output + "\n\n" + connectResult.Output
|
|
}
|
|
if connectResult.Meta == nil {
|
|
connectResult.Meta = map[string]string{}
|
|
}
|
|
connectResult.Meta["app"] = result.App
|
|
connectResult.Meta["debug_port"] = strconv.Itoa(result.DebugPort)
|
|
connectResult.Meta["pid"] = strconv.Itoa(result.PID)
|
|
return connectResult
|
|
}
|
|
|
|
type browserConnectTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func newBrowserConnectTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserConnectTool{
|
|
baseTool: baseTool{
|
|
name: "browser_connect",
|
|
deferred: true,
|
|
description: "Attach local browser automation to an existing Chrome/Electron DevTools endpoint. For Electron apps, first launch the app with --remote-debugging-port=<port>, then connect to that port instead of using desktop control.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"target": {Type: "string", Description: "CDP target: a port such as 9222, host:port, http(s) URL, ws(s) URL, or DevTools websocket URL."},
|
|
},
|
|
Required: []string{"target"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Attaches to a local browser or Electron app exposed over the Chrome DevTools Protocol."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserConnectTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, err := browserConnectTargetArg(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_connect: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
func (tool browserConnectTool) Run(ctx context.Context, args map[string]any) Result {
|
|
target, err := browserConnectTargetArg(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_connect: " + err.Error())
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, "connect", []string{"connect", target})
|
|
}
|
|
|
|
type browserOpenTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func newBrowserOpenTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserOpenTool{
|
|
baseTool: baseTool{
|
|
name: "browser_open",
|
|
deferred: true,
|
|
description: "Open a URL in the local browser automation session.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"url": {Type: "string", Description: "HTTP or HTTPS URL to open. Bare hosts are treated as https URLs."},
|
|
},
|
|
Required: []string{"url"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectNetwork, "Opens a URL through the local browser automation helper. For already-running Chrome/Electron apps, use browser_connect instead. If first use reports a missing browser runtime, run browser_install once."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserOpenTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, err := browserOpenURLArg(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_open: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
func (tool browserOpenTool) Run(ctx context.Context, args map[string]any) Result {
|
|
rawURL, err := browserOpenURLArg(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_open: " + err.Error())
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, "open", []string{"open", rawURL})
|
|
}
|
|
|
|
type browserSnapshotTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func newBrowserSnapshotTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserSnapshotTool{
|
|
baseTool: baseTool{
|
|
name: "browser_snapshot",
|
|
deferred: true,
|
|
description: "Return an accessibility snapshot from the current local browser automation session.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"interactive": {Type: "boolean", Description: "Return interactive elements only.", Default: true},
|
|
"include_cursor_interactive": {Type: "boolean", Description: "Include cursor-interactive elements such as onclick containers.", Default: false},
|
|
"compact": {Type: "boolean", Description: "Use compact snapshot output.", Default: false},
|
|
"depth": {Type: "integer", Description: "Optional tree depth limit.", Minimum: intPtr(0), Maximum: intPtr(20)},
|
|
"selector": {Type: "string", Description: "Optional CSS selector to scope the snapshot."},
|
|
},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Reads the current local browser automation session state."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserSnapshotTool) Run(ctx context.Context, args map[string]any) Result {
|
|
commandArgs, err := browserSnapshotCommandArgs(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_snapshot: " + err.Error())
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, "snapshot", commandArgs)
|
|
}
|
|
|
|
func (tool browserSnapshotTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, err := browserSnapshotCommandArgs(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_snapshot: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
type browserClickTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func newBrowserClickTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserClickTool{
|
|
baseTool: baseTool{
|
|
name: "browser_click",
|
|
deferred: true,
|
|
description: "Click a ref from browser_snapshot in the current local browser automation session.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"ref": {Type: "string", Description: "Element ref from browser_snapshot, for example e114."},
|
|
},
|
|
Required: []string{"ref"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Clicks an element in the local browser automation session."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserClickTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, err := browserRefArg(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_click: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
func (tool browserClickTool) Run(ctx context.Context, args map[string]any) Result {
|
|
ref, err := browserRefArg(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_click: " + err.Error())
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, "click", []string{"click", ref})
|
|
}
|
|
|
|
type browserTypeTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func newBrowserTypeTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserTypeTool{
|
|
baseTool: baseTool{
|
|
name: "browser_type",
|
|
deferred: true,
|
|
description: "Type text into a ref from browser_snapshot in the current local browser automation session.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"ref": {Type: "string", Description: "Element ref from browser_snapshot, for example e114."},
|
|
"text": {Type: "string", Description: "Text to type into the target element."},
|
|
},
|
|
Required: []string{"ref", "text"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Types text into an element in the local browser automation session."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserTypeTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, _, err := browserTypeArgs(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_type: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
func (tool browserTypeTool) Run(ctx context.Context, args map[string]any) Result {
|
|
ref, text, err := browserTypeArgs(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_type: " + err.Error())
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, "type", []string{"type", ref, text})
|
|
}
|
|
|
|
type browserPressTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func newBrowserPressTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserPressTool{
|
|
baseTool: baseTool{
|
|
name: "browser_press",
|
|
deferred: true,
|
|
description: "Press a keyboard key in the current local browser automation session.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"key": {Type: "string", Description: "Key to press, for example Enter, Escape, or Control+L."},
|
|
},
|
|
Required: []string{"key"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Presses a key in the local browser automation session."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserPressTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, err := browserKeyArg(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_press: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
func (tool browserPressTool) Run(ctx context.Context, args map[string]any) Result {
|
|
key, err := browserKeyArg(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_press: " + err.Error())
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, "press", []string{"press", key})
|
|
}
|
|
|
|
type browserActionTool struct {
|
|
baseTool
|
|
browser localcontrol.Browser
|
|
}
|
|
|
|
func browserSnapshotCommandArgs(args map[string]any) ([]string, error) {
|
|
interactive, err := boolArg(args, "interactive", true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
includeCursorInteractive, err := boolArg(args, "include_cursor_interactive", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
compact, err := boolArg(args, "compact", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
depth, err := intArg(args, "depth", 0, 0, 20)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
selector, err := stringArgWithEmpty(args, "selector", "", false, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if err := validateBrowserSnapshotSelector(selector); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
commandArgs := []string{"snapshot"}
|
|
if interactive {
|
|
commandArgs = append(commandArgs, "-i")
|
|
}
|
|
if includeCursorInteractive {
|
|
commandArgs = append(commandArgs, "-C")
|
|
}
|
|
if compact {
|
|
commandArgs = append(commandArgs, "-c")
|
|
}
|
|
if depth > 0 {
|
|
commandArgs = append(commandArgs, "-d", strconv.Itoa(depth))
|
|
}
|
|
if selector != "" {
|
|
commandArgs = append(commandArgs, "-s", selector)
|
|
}
|
|
return commandArgs, nil
|
|
}
|
|
|
|
func newBrowserActionTool(options localcontrol.BrowserOptions) Tool {
|
|
return browserActionTool{
|
|
baseTool: baseTool{
|
|
name: "browser_action",
|
|
deferred: true,
|
|
description: "Run an allowed action against the current local browser automation session.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"command": {Type: "string", Description: "Allowed browser command to run.", Enum: browserActionCommandNames()},
|
|
"args": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Command arguments. Use refs from browser_snapshot where required."},
|
|
},
|
|
Required: []string{"command"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: localControlSafety(options.Enabled, SideEffectLocalBrowser, "Interacts with the local browser automation session."),
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
|
|
},
|
|
browser: localcontrol.NewBrowser(options),
|
|
}
|
|
}
|
|
|
|
func (tool browserActionTool) RejectBeforePermission(args map[string]any) (Result, bool) {
|
|
if _, _, err := browserActionArgs(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_action: " + err.Error()), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
func (tool browserActionTool) Run(ctx context.Context, args map[string]any) Result {
|
|
command, commandArgs, err := browserActionArgs(args)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for browser_action: " + err.Error())
|
|
}
|
|
return browserCommandResult(ctx, tool.browser, command, commandArgs)
|
|
}
|
|
|
|
type browserActionSpec struct {
|
|
argv []string
|
|
min int
|
|
max int
|
|
}
|
|
|
|
var browserActionSpecs = map[string]browserActionSpec{
|
|
"back": {argv: []string{"back"}, min: 0, max: 0},
|
|
"forward": {argv: []string{"forward"}, min: 0, max: 0},
|
|
"reload": {argv: []string{"reload"}, min: 0, max: 0},
|
|
"close": {argv: []string{"close"}, min: 0, max: 0},
|
|
"connect": {argv: []string{"connect"}, min: 1, max: 1},
|
|
"click": {argv: []string{"click"}, min: 1, max: 1},
|
|
"dblclick": {argv: []string{"dblclick"}, min: 1, max: 1},
|
|
"fill": {argv: []string{"fill"}, min: 2, max: 2},
|
|
"type": {argv: []string{"type"}, min: 2, max: 2},
|
|
"press": {argv: []string{"press"}, min: 1, max: 1},
|
|
"keyboard_type": {argv: []string{"keyboard", "type"}, min: 1, max: 1},
|
|
"keyboard_insert_text": {argv: []string{"keyboard", "inserttext"}, min: 1, max: 1},
|
|
"hover": {argv: []string{"hover"}, min: 1, max: 1},
|
|
"check": {argv: []string{"check"}, min: 1, max: 1},
|
|
"uncheck": {argv: []string{"uncheck"}, min: 1, max: 1},
|
|
"select": {argv: []string{"select"}, min: 2, max: 2},
|
|
"scroll": {argv: []string{"scroll"}, min: 2, max: 2},
|
|
"scroll_into_view": {argv: []string{"scrollintoview"}, min: 1, max: 1},
|
|
"drag": {argv: []string{"drag"}, min: 2, max: 2},
|
|
"wait": {argv: []string{"wait"}, min: 1, max: 3},
|
|
"get_text": {argv: []string{"get", "text"}, min: 1, max: 1},
|
|
"get_html": {argv: []string{"get", "html"}, min: 1, max: 1},
|
|
"get_value": {argv: []string{"get", "value"}, min: 1, max: 1},
|
|
"get_attr": {argv: []string{"get", "attr"}, min: 2, max: 2},
|
|
"get_title": {argv: []string{"get", "title"}, min: 0, max: 0},
|
|
"get_url": {argv: []string{"get", "url"}, min: 0, max: 0},
|
|
"tab": {argv: []string{"tab"}, min: 0, max: 3},
|
|
"eval": {argv: []string{"eval"}, min: 1, max: 1},
|
|
"dialog_accept": {argv: []string{"dialog", "accept"}, min: 0, max: 1},
|
|
"dialog_dismiss": {argv: []string{"dialog", "dismiss"}, min: 0, max: 0},
|
|
}
|
|
|
|
func browserActionCommandNames() []string {
|
|
names := make([]string, 0, len(browserActionSpecs))
|
|
for name := range browserActionSpecs {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func browserActionArgs(args map[string]any) (string, []string, error) {
|
|
command, err := stringArg(args, "command", "", true)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
command = strings.ToLower(strings.TrimSpace(command))
|
|
spec, ok := browserActionSpecs[command]
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("command must be one of: %s", strings.Join(browserActionCommandNames(), ", "))
|
|
}
|
|
values, err := stringArrayArg(args, "args")
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if len(values) < spec.min || len(values) > spec.max {
|
|
if spec.min == spec.max {
|
|
return "", nil, fmt.Errorf("%s requires %d args", command, spec.min)
|
|
}
|
|
return "", nil, fmt.Errorf("%s requires between %d and %d args", command, spec.min, spec.max)
|
|
}
|
|
commandArgs, err := browserActionCommandArgs(command, spec, values)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return command, commandArgs, nil
|
|
}
|
|
|
|
func browserActionCommandArgs(command string, spec browserActionSpec, values []string) ([]string, error) {
|
|
switch command {
|
|
case "connect":
|
|
target, err := browserConnectTargetFromString(values[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
return append(commandArgs, target), nil
|
|
case "click", "dblclick", "hover", "check", "uncheck", "scroll_into_view", "get_text", "get_html", "get_value":
|
|
ref, err := browserRefFromString(values[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
return append(commandArgs, ref), nil
|
|
case "fill", "type", "select":
|
|
ref, err := browserRefFromString(values[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
commandArgs = append(commandArgs, ref)
|
|
commandArgs = append(commandArgs, values[1:]...)
|
|
return commandArgs, nil
|
|
case "drag":
|
|
fromRef, err := browserRefFromString(values[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
toRef, err := browserRefFromString(values[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
return append(commandArgs, fromRef, toRef), nil
|
|
case "press":
|
|
key, err := browserKeyFromString(values[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
return append(commandArgs, key), nil
|
|
case "get_attr":
|
|
ref, err := browserRefFromString(values[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attr, err := browserSingleToken("attr", values[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
return append(commandArgs, ref, attr), nil
|
|
case "scroll":
|
|
x, err := browserIntegerToken("x", values[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
y, err := browserIntegerToken("y", values[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
return append(commandArgs, x, y), nil
|
|
case "tab":
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
for _, value := range values {
|
|
arg, err := browserSingleToken("tab argument", value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commandArgs = append(commandArgs, arg)
|
|
}
|
|
return commandArgs, nil
|
|
default:
|
|
commandArgs := append([]string{}, spec.argv...)
|
|
commandArgs = append(commandArgs, values...)
|
|
return commandArgs, nil
|
|
}
|
|
}
|
|
|
|
func browserOpenURLArg(args map[string]any) (string, error) {
|
|
rawURL, err := stringArg(args, "url", "", true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
normalized := strings.TrimSpace(rawURL)
|
|
if !strings.Contains(normalized, "://") {
|
|
normalized = "https://" + normalized
|
|
}
|
|
parsed, err := url.Parse(normalized)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return "", fmt.Errorf("url must use http or https")
|
|
}
|
|
if strings.TrimSpace(parsed.Host) == "" {
|
|
return "", fmt.Errorf("url must include a host")
|
|
}
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func browserConnectTargetArg(args map[string]any) (string, error) {
|
|
target, err := stringArg(args, "target", "", true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return browserConnectTargetFromString(target)
|
|
}
|
|
|
|
func browserConnectTargetFromString(target string) (string, error) {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" {
|
|
return "", fmt.Errorf("target is required")
|
|
}
|
|
if strings.ContainsAny(target, " \t\r\n;") {
|
|
return "", fmt.Errorf("target must not contain whitespace or semicolons")
|
|
}
|
|
if strings.HasPrefix(target, "-") {
|
|
return "", fmt.Errorf("target must be a CDP port or URL, not an option")
|
|
}
|
|
if port, err := strconv.Atoi(target); err == nil && port >= 1 && port <= 65535 {
|
|
return target, nil
|
|
}
|
|
host, err := browserConnectTargetHost(target)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !isLoopbackHost(host) {
|
|
return "", fmt.Errorf("target must be a bare port or loopback host")
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func browserConnectTargetHost(target string) (string, error) {
|
|
parsed, err := url.Parse(target)
|
|
if err == nil && parsed.Host != "" {
|
|
if !validBrowserPort(parsed.Port()) {
|
|
return "", fmt.Errorf("target URL must include a valid port")
|
|
}
|
|
return parsed.Hostname(), nil
|
|
}
|
|
if !strings.Contains(target, "://") {
|
|
parsed, err = url.Parse("http://" + target)
|
|
if err == nil && parsed.Host != "" {
|
|
if !validBrowserPort(parsed.Port()) {
|
|
return "", fmt.Errorf("target must include a valid port")
|
|
}
|
|
return parsed.Hostname(), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("target must be a bare port or loopback host:port/URL")
|
|
}
|
|
|
|
func validBrowserPort(raw string) bool {
|
|
if raw == "" {
|
|
return false
|
|
}
|
|
port, err := strconv.Atoi(raw)
|
|
return err == nil && port >= 1 && port <= 65535
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
|
|
if host == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
func browserRefArg(args map[string]any) (string, error) {
|
|
ref, err := stringArg(args, "ref", "", true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return browserRefFromString(ref)
|
|
}
|
|
|
|
func browserRefFromString(ref string) (string, error) {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
return "", fmt.Errorf("ref is required")
|
|
}
|
|
if strings.ContainsAny(ref, " \t\r\n;") {
|
|
return "", fmt.Errorf("ref must be a single browser_snapshot ref")
|
|
}
|
|
if strings.HasPrefix(ref, "-") {
|
|
return "", fmt.Errorf("ref must be a browser_snapshot ref, not an option")
|
|
}
|
|
return ref, nil
|
|
}
|
|
|
|
func browserTypeArgs(args map[string]any) (string, string, error) {
|
|
ref, err := browserRefArg(args)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
text, err := stringArg(args, "text", "", true)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if text == "" {
|
|
return "", "", fmt.Errorf("text must be a non-empty string")
|
|
}
|
|
return ref, text, nil
|
|
}
|
|
|
|
func browserKeyArg(args map[string]any) (string, error) {
|
|
key, err := stringArg(args, "key", "", true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return browserKeyFromString(key)
|
|
}
|
|
|
|
func browserKeyFromString(key string) (string, error) {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return "", fmt.Errorf("key is required")
|
|
}
|
|
if strings.ContainsAny(key, " \t\r\n;") {
|
|
return "", fmt.Errorf("key must be a single key name")
|
|
}
|
|
if strings.HasPrefix(key, "-") {
|
|
return "", fmt.Errorf("key must be a key name, not an option")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func browserSingleToken(name string, value string) (string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "", fmt.Errorf("%s is required", name)
|
|
}
|
|
if strings.ContainsAny(value, " \t\r\n;") {
|
|
return "", fmt.Errorf("%s must be a single token", name)
|
|
}
|
|
if strings.HasPrefix(value, "-") {
|
|
return "", fmt.Errorf("%s must not be an option", name)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func browserIntegerToken(name string, value string) (string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "", fmt.Errorf("%s is required", name)
|
|
}
|
|
if _, err := strconv.Atoi(value); err != nil {
|
|
return "", fmt.Errorf("%s must be an integer", name)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func validateBrowserSnapshotSelector(selector string) error {
|
|
if selector == "" {
|
|
return nil
|
|
}
|
|
if strings.Contains(selector, "]*") {
|
|
return fmt.Errorf("selector appears invalid: remove the trailing wildcard after the attribute selector")
|
|
}
|
|
if strings.ContainsAny(selector, "\r\n") {
|
|
return fmt.Errorf("selector must be a single line")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func browserLaunchRequestArg(args map[string]any) (localcontrol.BrowserAppLaunchRequest, error) {
|
|
app, err := stringArg(args, "app", "", true)
|
|
if err != nil {
|
|
return localcontrol.BrowserAppLaunchRequest{}, err
|
|
}
|
|
app = strings.ToLower(strings.TrimSpace(app))
|
|
switch app {
|
|
case "discord":
|
|
default:
|
|
return localcontrol.BrowserAppLaunchRequest{}, fmt.Errorf("app must be one of: discord")
|
|
}
|
|
port, err := intArg(args, "debug_port", localcontrol.DefaultDevToolsPort, 1024, 65535)
|
|
if err != nil {
|
|
return localcontrol.BrowserAppLaunchRequest{}, err
|
|
}
|
|
stopExisting, err := boolArg(args, "stop_existing", true)
|
|
if err != nil {
|
|
return localcontrol.BrowserAppLaunchRequest{}, err
|
|
}
|
|
wait, err := boolArg(args, "wait", true)
|
|
if err != nil {
|
|
return localcontrol.BrowserAppLaunchRequest{}, err
|
|
}
|
|
if _, err := boolArg(args, "connect", true); err != nil {
|
|
return localcontrol.BrowserAppLaunchRequest{}, err
|
|
}
|
|
return localcontrol.BrowserAppLaunchRequest{
|
|
App: app,
|
|
DebugPort: port,
|
|
StopExisting: stopExisting,
|
|
Wait: wait,
|
|
}, nil
|
|
}
|
|
|
|
func stringArrayArg(args map[string]any, key string) ([]string, error) {
|
|
value, ok := args[key]
|
|
if !ok || value == nil {
|
|
return nil, nil
|
|
}
|
|
raw, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s must be an array of strings", key)
|
|
}
|
|
values := make([]string, 0, len(raw))
|
|
for index, item := range raw {
|
|
text, ok := item.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s[%d] must be a string", key, index)
|
|
}
|
|
values = append(values, text)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func localControlSafety(enabled bool, sideEffect SideEffect, reason string) Safety {
|
|
if !enabled {
|
|
return Safety{
|
|
SideEffect: sideEffect,
|
|
Permission: PermissionDeny,
|
|
Reason: "Local control is disabled.",
|
|
}
|
|
}
|
|
return promptSafety(sideEffect, reason)
|
|
}
|
|
|
|
func browserCommandResult(ctx context.Context, browser localcontrol.Browser, command string, args []string) Result {
|
|
return localControlCommandResult(ctx, browser, "browser", command, args)
|
|
}
|
|
|
|
func localControlCommandResult(ctx context.Context, runner localControlRunner, kind string, command string, args []string) Result {
|
|
result, err := runner.Run(ctx, args...)
|
|
output := result.Output()
|
|
budget := applyOutputBudget(output, localBrowserOutputBudgetBytes, "narrow the local-control query or request a scoped snapshot")
|
|
meta := outputBudgetMeta(budget)
|
|
meta[kind+"_command"] = command
|
|
if result.ExitCode != 0 {
|
|
meta["exit_code"] = strconv.Itoa(result.ExitCode)
|
|
}
|
|
if err != nil {
|
|
message := "Error running " + kind + " helper: " + err.Error()
|
|
if strings.TrimSpace(budget.Output) != "" {
|
|
message += "\n\n" + budget.Output
|
|
}
|
|
if kind == "browser" && command != "install" && browserInstallHintApplies(err.Error()+"\n"+budget.Output) {
|
|
message += "\n\nIf this is a first-use browser runtime error, call browser_install once, then retry the browser command."
|
|
}
|
|
return Result{
|
|
Status: StatusError,
|
|
Output: message,
|
|
Truncated: budget.Truncated,
|
|
Meta: meta,
|
|
Display: Display{
|
|
Summary: command + " failed",
|
|
Kind: kind,
|
|
},
|
|
}
|
|
}
|
|
if strings.TrimSpace(budget.Output) == "" {
|
|
budget.Output = kind + " command completed."
|
|
}
|
|
return Result{
|
|
Status: StatusOK,
|
|
Output: budget.Output,
|
|
Truncated: budget.Truncated,
|
|
Meta: meta,
|
|
Display: Display{
|
|
Summary: command + " completed",
|
|
Kind: kind,
|
|
},
|
|
}
|
|
}
|
|
|
|
func browserInstallHintApplies(message string) bool {
|
|
lower := strings.ToLower(message)
|
|
if strings.Contains(lower, "cdp error") {
|
|
return false
|
|
}
|
|
if strings.Contains(lower, "was not found on path") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "executable doesn't exist") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "browser runtime") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "please run") && strings.Contains(lower, "install") && strings.Contains(lower, "browser") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "could not find") && (strings.Contains(lower, "chromium") || strings.Contains(lower, "chrome") || strings.Contains(lower, "browser")) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|