Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion internal/acp/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,17 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
// session/request_permission request from a ZERO permission request.
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
args := marshalArgs(req.Args)
return ToolCallUpdate{
upd := ToolCallUpdate{
ToolCallID: req.ToolCallID,
Title: toolTitle(req.ToolName, string(args)),
Kind: toolKindFor(req.ToolName),
Status: ToolStatusPending,
RawInput: rawInputBytes(args),
}
if browser, ok := browserToolDetails(req.ToolName); ok {
upd.Browser = browser
}
return upd
}

func marshalArgs(args map[string]any) []byte {
Expand Down
14 changes: 14 additions & 0 deletions internal/acp/permission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,17 @@ func TestPermissionToolCall(t *testing.T) {
t.Error("expected rawInput from args")
}
}

func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
call := permissionToolCall(agent.PermissionRequest{
ToolCallID: "browser-1",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
})
if call.Browser == nil || call.Browser.Version != 1 || call.Browser.Command != "connect" {
t.Fatalf("browser descriptor = %#v", call.Browser)
}
if call.Title != "browser connect" {
t.Fatalf("title = %q", call.Title)
}
}
53 changes: 52 additions & 1 deletion internal/acp/translate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package acp

import (
"encoding/json"
"net/url"
"strings"
"unicode/utf8"

Expand Down Expand Up @@ -44,12 +45,55 @@ func toolKindFor(name string) string {

// toolTitle builds a concise human title, e.g. "read_file src/main.go".
func toolTitle(name, rawArgs string) string {
if browser, ok := browserToolDetails(name); ok {
return browserToolTitle(browser.Command, rawArgs)
}
if hint := primaryArgHint(rawArgs); hint != "" {
return name + " " + hint
}
return name
}

// browserToolDetails identifies ZERO's local browser helpers without treating
// similarly named MCP tools as browser automation. The descriptor intentionally
// contains no request data: ACP tool input is already protocol-visible, but a
// durable UI must not need to retain text, local CDP targets, or full URLs just
// to recognise the browser operation.
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
const prefix = "browser_"
command, ok := strings.CutPrefix(name, prefix)
if !ok {
return nil, false
}
switch command {
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
return &BrowserToolDetails{Version: 1, Command: command}, true
default:
return nil, false
}
}

// browserToolTitle avoids putting browser_type text, an attached DevTools
// endpoint, or a URL query/fragment in a tool-card title. Those values can
// carry credentials or session data; the UI only needs the operation and, for
// navigation, a human-recognisable origin.
func browserToolTitle(command, rawArgs string) string {
if command != "open" {
return "browser " + command
}
var args struct {
URL string `json:"url"`
}
if json.Unmarshal([]byte(rawArgs), &args) != nil {
return "browser open"
}
u, err := url.Parse(strings.TrimSpace(args.URL))
if err != nil || u.Scheme == "" || u.Host == "" {
return "browser open"
}
return "browser open " + u.Scheme + "://" + u.Host
}

// primaryArgHint extracts the most relevant argument (path/pattern/command) from
// raw JSON arguments. Best-effort; returns "" when it can't parse.
func primaryArgHint(rawArgs string) string {
Expand Down Expand Up @@ -89,14 +133,18 @@ func rawInput(args string) json.RawMessage {
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
// update (status in_progress — ZERO executes immediately after advertising).
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
return ToolCallUpdate{
upd := ToolCallUpdate{
SessionUpdate: UpdateToolCall,
ToolCallID: call.ID,
Title: toolTitle(call.Name, call.Arguments),
Kind: toolKindFor(call.Name),
Status: ToolStatusInProgress,
RawInput: rawInput(call.Arguments),
}
if browser, ok := browserToolDetails(call.Name); ok {
upd.Browser = browser
}
return upd
}

// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
Expand All @@ -116,6 +164,9 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
if locs := toolResultLocations(result); len(locs) > 0 {
upd.Locations = locs
}
if browser, ok := browserToolDetails(result.Name); ok {
upd.Browser = browser
}
return upd
}

Expand Down
66 changes: 66 additions & 0 deletions internal/acp/translate_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package acp

import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
Expand Down Expand Up @@ -56,6 +57,71 @@ func TestToolTitleAndHint(t *testing.T) {
}
}

func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
start := toolCallStart(agent.ToolCall{
ID: "browser-1",
Name: "browser_open",
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
})
if start.Browser == nil || start.Browser.Version != 1 || start.Browser.Command != "open" {
t.Fatalf("browser descriptor = %#v, want open", start.Browser)
}
if start.Title != "browser open https://example.com" {
t.Fatalf("browser title = %q", start.Title)
}
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
var wire struct {
Browser BrowserToolDetails `json:"browser"`
}
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if wire.Browser != (BrowserToolDetails{Version: 1, Command: "open"}) {
t.Fatalf("browser wire descriptor = %#v", wire.Browser)
}

typed := toolCallStart(agent.ToolCall{
ID: "browser-2",
Name: "browser_type",
Arguments: `{"ref":"email","text":"secret@example.test"}`,
})
if typed.Browser == nil || typed.Browser.Command != "type" {
t.Fatalf("browser type descriptor = %#v", typed.Browser)
}
if typed.Title != "browser type" || strings.Contains(typed.Title, "secret@example.test") {
t.Fatalf("browser type title = %q", typed.Title)
}

result := toolCallResult(agent.ToolResult{
ToolCallID: "browser-2",
Name: "browser_type",
Status: tools.StatusOK,
})
if result.Browser == nil || result.Browser.Command != "type" {
t.Fatalf("browser result descriptor = %#v", result.Browser)
}
}

func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
if start.Browser != nil {
t.Fatalf("MCP-like tool received built-in browser descriptor: %#v", start.Browser)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"browser"`) {
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
}
}

func TestToolCallStart(t *testing.T) {
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
if upd.SessionUpdate != UpdateToolCall {
Expand Down
16 changes: 16 additions & 0 deletions internal/acp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,22 @@ type ToolCallUpdate struct {
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
// Browser is present only for ZERO's built-in browser helper tools. It is a
// deliberately narrow presentation descriptor for ACP clients: the raw
// request may contain typed text, a full URL, or a local DevTools endpoint,
// none of which belongs in a durable browser-status surface.
Browser *BrowserToolDetails `json:"browser,omitempty"`
}

// BrowserToolDetails identifies the browser helper operation behind a tool
// call. Version is the schema version for this optional ZERO extension;
// Command is one of install, launch, connect, open, snapshot, click, type,
// press, or action. Future fields must remain display-safe and must not
// include browser profile data, cookies, typed text, URL paths/queries, or
// DevTools endpoints.
type BrowserToolDetails struct {
Version int `json:"version"`
Command string `json:"command"`
}

// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
Expand Down
Loading