detached 51621a9

fix(web): address review findings

commit
51621a908692febed52b986e360877d5a96a92ef
author
brice · · 2026-08-19 07:41 UTC
parent
cb40c2f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +82 -28
M docs/extensions.md +5 -7
@@ -226,13 +226,11 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228228 `write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`,
229-`web_open`, `web_find`, `web_click`, `skill`). Conflicts with active built-ins
230-are silently shadowed by the built-in. Every native public-web capability name
231-is reserved even when its setting disables the capability, so an extension
232-cannot claim or replace it. The `grep` name is
233-reserved even when `Args.NoTools`/SDK `Config.NoTools` or an explicit
234-`Args.Tools`/SDK `Config.Tools` allowlist disables the native search tool, so
235-an extension cannot claim or replace it.
229+`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `skill`).
230+Conflicts with active built-ins are silently shadowed by the built-in. Every
231+native public-web capability name, plus `grep`, `schedule`, and `update_goal`,
232+is reserved even when it is unavailable, so an extension cannot claim or
233+replace it.
236234
237235 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
238236
@@ -226,13 +226,11 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
226 226
227 Tool names live in the same namespace as built-in tools (`read`,227 Tool names live in the same namespace as built-in tools (`read`,
228 `write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`,228 `write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`,
229-`web_open`, `web_find`, `web_click`, `skill`). Conflicts with active built-ins229+`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `skill`).
230-are silently shadowed by the built-in. Every native public-web capability name230+Conflicts with active built-ins are silently shadowed by the built-in. Every
231-is reserved even when its setting disables the capability, so an extension231+native public-web capability name, plus `grep`, `schedule`, and `update_goal`,
232-cannot claim or replace it. The `grep` name is232+is reserved even when it is unavailable, so an extension cannot claim or
233-reserved even when `Args.NoTools`/SDK `Config.NoTools` or an explicit233+replace it.
234-`Args.Tools`/SDK `Config.Tools` allowlist disables the native search tool, so
235-an extension cannot claim or replace it.
236 234
237 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:235 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
238 236
M packages/agent/build.go +1 -1
@@ -100,7 +100,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
100100 // Web capability and other native names remain reserved even when their
101101 // policy excludes the current session. An extension must not turn a
102102 // normal CLI opt-out into a differently implemented capability.
103- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "update_goal" {
103+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "schedule" || info.Name == "update_goal" {
104104 continue
105105 }
106106 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -100,7 +100,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
100 // Web capability and other native names remain reserved even when their100 // Web capability and other native names remain reserved even when their
101 // policy excludes the current session. An extension must not turn a101 // policy excludes the current session. An extension must not turn a
102 // normal CLI opt-out into a differently implemented capability.102 // normal CLI opt-out into a differently implemented capability.
103- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "update_goal" {103+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "schedule" || info.Name == "update_goal" {
104 continue104 continue
105 }105 }
106 if _, exists := r.ToolRegistry[info.Name]; exists {106 if _, exists := r.ToolRegistry[info.Name]; exists {
M packages/agent/ext/ext.go +4 -4
@@ -421,10 +421,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// create_worktree, grep, lsp, web_search, web_open, web_find, web_click, and
425-// skill) are silently shadowed by the built-in. Every native public-web name
426-// and grep are reserved even when those built-ins are disabled, so an
427-// extension cannot claim or replace them.
424+// create_worktree, grep, lsp, web_search, web_open, web_find, web_click,
425+// schedule, update_goal, and skill) are silently shadowed by the built-in.
426+// Every native public-web name, plus grep, schedule, and update_goal, is
427+// reserved even when unavailable, so an extension cannot claim or replace it.
428428 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
429429 e.registerTool(name, description, schema, false, fn)
430430 }
@@ -421,10 +421,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421 // into the agent's registry once the extension's ready frame fires.421 // into the agent's registry once the extension's ready frame fires.
422 //422 //
423 // Conflicts with active built-in tool names (read, write, edit, bash,423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// create_worktree, grep, lsp, web_search, web_open, web_find, web_click, and424+// create_worktree, grep, lsp, web_search, web_open, web_find, web_click,
425-// skill) are silently shadowed by the built-in. Every native public-web name425+// schedule, update_goal, and skill) are silently shadowed by the built-in.
426-// and grep are reserved even when those built-ins are disabled, so an426+// Every native public-web name, plus grep, schedule, and update_goal, is
427-// extension cannot claim or replace them.427+// reserved even when unavailable, so an extension cannot claim or replace it.
428 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {428 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
429 e.registerTool(name, description, schema, false, fn)429 e.registerTool(name, description, schema, false, fn)
430 }430 }
M packages/agent/modes/interactive_settings.go +8 -1
@@ -717,7 +717,14 @@ func (i *Interactive) stripWebSearchTool() {
717717 return
718718 }
719719 tools := i.agent.ToolsSnapshot()
720- if _, ok := tools["web_search"]; !ok {
720+ hasWebCapability := false
721+ for _, name := range toolspkg.WebCapabilityNames {
722+ if _, ok := tools[name]; ok {
723+ hasWebCapability = true
724+ break
725+ }
726+ }
727+ if !hasWebCapability {
721728 return
722729 }
723730 toolspkg.RemoveWebCapabilities(tools)
@@ -717,7 +717,14 @@ func (i *Interactive) stripWebSearchTool() {
717 return717 return
718 }718 }
719 tools := i.agent.ToolsSnapshot()719 tools := i.agent.ToolsSnapshot()
720- if _, ok := tools["web_search"]; !ok {720+ hasWebCapability := false
721+ for _, name := range toolspkg.WebCapabilityNames {
722+ if _, ok := tools[name]; ok {
723+ hasWebCapability = true
724+ break
725+ }
726+ }
727+ if !hasWebCapability {
721 return728 return
722 }729 }
723 toolspkg.RemoveWebCapabilities(tools)730 toolspkg.RemoveWebCapabilities(tools)
M packages/agent/tools/web_page.go +37 -4
@@ -137,6 +137,18 @@ func (s *BrowsingStore) addPage(page *webDocument, generation uint64) (string, b
137137 return id, id != ""
138138 }
139139
140+func (s *BrowsingStore) remove(id string) {
141+ if s == nil {
142+ return
143+ }
144+ s.mu.Lock()
145+ if doc, ok := s.docs[id]; ok {
146+ delete(s.docs, id)
147+ s.used -= uint64(doc.size)
148+ }
149+ s.mu.Unlock()
150+}
151+
140152 func (s *BrowsingStore) add(doc *webDocument, generation uint64, checkGeneration bool) string {
141153 if s == nil || doc == nil {
142154 return ""
@@ -288,7 +300,10 @@ func (t *WebOpenTool) Preview(_ context.Context, raw json.RawMessage) (core.Tool
288300 if !found {
289301 return webPageError(webPageUnknown), nil
290302 }
291- return webPageValue(fmt.Sprintf("Open public web source %s. No request has been made.", doc.url), false), nil
303+ if doc.source {
304+ return webPageValue(fmt.Sprintf("Open public web source %s. No request has been made.", doc.url), false), nil
305+ }
306+ return webPageValue(fmt.Sprintf("Open stored web page %s. No request has been made.", doc.url), false), nil
292307 }
293308 func (t *WebClickTool) Preview(_ context.Context, raw json.RawMessage) (core.ToolResult, error) {
294309 args, ok := parseWebClickArgs(raw)
@@ -559,7 +574,7 @@ func parsePlainWebPage(body []byte, finalURL string) *webDocument {
559574 }
560575 func extractHTMLWebPage(root *html.Node, finalURL string) (*webDocument, string) {
561576 var title string
562- var content *html.Node
577+ var article, main, body *html.Node
563578 stack := []struct {
564579 n *html.Node
565580 depth int
@@ -576,8 +591,19 @@ func extractHTMLWebPage(root *html.Node, finalURL string) (*webDocument, string)
576591 if item.n.Data == "title" && title == "" {
577592 title = boundPageText(nodePageText(item.n), 512)
578593 }
579- if content == nil && (item.n.Data == "article" || item.n.Data == "main" || item.n.Data == "body") {
580- content = item.n
594+ switch item.n.Data {
595+ case "article":
596+ if article == nil {
597+ article = item.n
598+ }
599+ case "main":
600+ if main == nil {
601+ main = item.n
602+ }
603+ case "body":
604+ if body == nil {
605+ body = item.n
606+ }
581607 }
582608 }
583609 for child := item.n.LastChild; child != nil; child = child.PrevSibling {
@@ -587,6 +613,13 @@ func extractHTMLWebPage(root *html.Node, finalURL string) (*webDocument, string)
587613 }{child, item.depth + 1})
588614 }
589615 }
616+ content := article
617+ if content == nil {
618+ content = main
619+ }
620+ if content == nil {
621+ content = body
622+ }
590623 if content == nil {
591624 content = root
592625 }
@@ -137,6 +137,18 @@ func (s *BrowsingStore) addPage(page *webDocument, generation uint64) (string, b
137 return id, id != ""137 return id, id != ""
138 }138 }
139 139
140+func (s *BrowsingStore) remove(id string) {
141+ if s == nil {
142+ return
143+ }
144+ s.mu.Lock()
145+ if doc, ok := s.docs[id]; ok {
146+ delete(s.docs, id)
147+ s.used -= uint64(doc.size)
148+ }
149+ s.mu.Unlock()
150+}
151+
140 func (s *BrowsingStore) add(doc *webDocument, generation uint64, checkGeneration bool) string {152 func (s *BrowsingStore) add(doc *webDocument, generation uint64, checkGeneration bool) string {
141 if s == nil || doc == nil {153 if s == nil || doc == nil {
142 return ""154 return ""
@@ -288,7 +300,10 @@ func (t *WebOpenTool) Preview(_ context.Context, raw json.RawMessage) (core.Tool
288 if !found {300 if !found {
289 return webPageError(webPageUnknown), nil301 return webPageError(webPageUnknown), nil
290 }302 }
291- return webPageValue(fmt.Sprintf("Open public web source %s. No request has been made.", doc.url), false), nil303+ if doc.source {
304+ return webPageValue(fmt.Sprintf("Open public web source %s. No request has been made.", doc.url), false), nil
305+ }
306+ return webPageValue(fmt.Sprintf("Open stored web page %s. No request has been made.", doc.url), false), nil
292 }307 }
293 func (t *WebClickTool) Preview(_ context.Context, raw json.RawMessage) (core.ToolResult, error) {308 func (t *WebClickTool) Preview(_ context.Context, raw json.RawMessage) (core.ToolResult, error) {
294 args, ok := parseWebClickArgs(raw)309 args, ok := parseWebClickArgs(raw)
@@ -559,7 +574,7 @@ func parsePlainWebPage(body []byte, finalURL string) *webDocument {
559 }574 }
560 func extractHTMLWebPage(root *html.Node, finalURL string) (*webDocument, string) {575 func extractHTMLWebPage(root *html.Node, finalURL string) (*webDocument, string) {
561 var title string576 var title string
562- var content *html.Node577+ var article, main, body *html.Node
563 stack := []struct {578 stack := []struct {
564 n *html.Node579 n *html.Node
565 depth int580 depth int
@@ -576,8 +591,19 @@ func extractHTMLWebPage(root *html.Node, finalURL string) (*webDocument, string)
576 if item.n.Data == "title" && title == "" {591 if item.n.Data == "title" && title == "" {
577 title = boundPageText(nodePageText(item.n), 512)592 title = boundPageText(nodePageText(item.n), 512)
578 }593 }
579- if content == nil && (item.n.Data == "article" || item.n.Data == "main" || item.n.Data == "body") {594+ switch item.n.Data {
580- content = item.n595+ case "article":
596+ if article == nil {
597+ article = item.n
598+ }
599+ case "main":
600+ if main == nil {
601+ main = item.n
602+ }
603+ case "body":
604+ if body == nil {
605+ body = item.n
606+ }
581 }607 }
582 }608 }
583 for child := item.n.LastChild; child != nil; child = child.PrevSibling {609 for child := item.n.LastChild; child != nil; child = child.PrevSibling {
@@ -587,6 +613,13 @@ func extractHTMLWebPage(root *html.Node, finalURL string) (*webDocument, string)
587 }{child, item.depth + 1})613 }{child, item.depth + 1})
588 }614 }
589 }615 }
616+ content := article
617+ if content == nil {
618+ content = main
619+ }
620+ if content == nil {
621+ content = body
622+ }
590 if content == nil {623 if content == nil {
591 content = root624 content = root
592 }625 }
M packages/agent/tools/web_page_test.go +11 -10
@@ -3,6 +3,7 @@ package tools
33 import (
44 "context"
55 "crypto/tls"
6+ "crypto/x509"
67 "encoding/json"
78 "net"
89 "net/http"
@@ -148,13 +149,12 @@ func TestWebPageFetcherPreservesHTTPSHostnameForSNI(t *testing.T) {
148149 server.StartTLS()
149150 defer server.Close()
150151 fetcher := testWebFetcher(t, server)
151- // httptest uses its own generated certificate. The unexported seam is used
152- // only to trust it for this local SNI assertion; production never supplies
153- // InsecureSkipVerify.
154- fetcher.tlsConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test-only local certificate
155- body, _, _, message := fetcher.fetch(context.Background(), "https://public.test/")
152+ roots := x509.NewCertPool()
153+ roots.AddCert(server.Certificate())
154+ fetcher.tlsConfig = &tls.Config{RootCAs: roots}
155+ body, _, _, message := fetcher.fetch(context.Background(), "https://example.com/")
156156 got, _ := serverName.Load().(string)
157- if message != "" || string(body) != "secure" || got != "public.test" {
157+ if message != "" || string(body) != "secure" || got != "example.com" {
158158 t.Fatalf("HTTPS body=%q message=%q SNI=%q", body, message, got)
159159 }
160160 }
@@ -165,16 +165,17 @@ func TestWebPageFetcherPinsTheValidatedAddress(t *testing.T) {
165165 _, _ = w.Write([]byte("pinned"))
166166 }))
167167 defer server.Close()
168- var dialed string
168+ var dialed atomic.Value
169169 fetcher := testWebFetcher(t, server)
170170 originalDial := fetcher.dial
171171 fetcher.dial = func(ctx context.Context, network, address string) (net.Conn, error) {
172- dialed = address
172+ dialed.Store(address)
173173 return originalDial(ctx, network, address)
174174 }
175175 body, _, _, message := fetcher.fetch(context.Background(), "http://public.test/")
176- if message != "" || string(body) != "pinned" || dialed != "8.8.8.8:80" {
177- t.Fatalf("pinned fetch body=%q message=%q dialed=%q", body, message, dialed)
176+ got, _ := dialed.Load().(string)
177+ if message != "" || string(body) != "pinned" || got != "8.8.8.8:80" {
178+ t.Fatalf("pinned fetch body=%q message=%q dialed=%q", body, message, got)
178179 }
179180 }
180181
@@ -3,6 +3,7 @@ package tools
3 import (3 import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6+ "crypto/x509"
6 "encoding/json"7 "encoding/json"
7 "net"8 "net"
8 "net/http"9 "net/http"
@@ -148,13 +149,12 @@ func TestWebPageFetcherPreservesHTTPSHostnameForSNI(t *testing.T) {
148 server.StartTLS()149 server.StartTLS()
149 defer server.Close()150 defer server.Close()
150 fetcher := testWebFetcher(t, server)151 fetcher := testWebFetcher(t, server)
151- // httptest uses its own generated certificate. The unexported seam is used152+ roots := x509.NewCertPool()
152- // only to trust it for this local SNI assertion; production never supplies153+ roots.AddCert(server.Certificate())
153- // InsecureSkipVerify.154+ fetcher.tlsConfig = &tls.Config{RootCAs: roots}
154- fetcher.tlsConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test-only local certificate155+ body, _, _, message := fetcher.fetch(context.Background(), "https://example.com/")
155- body, _, _, message := fetcher.fetch(context.Background(), "https://public.test/")
156 got, _ := serverName.Load().(string)156 got, _ := serverName.Load().(string)
157- if message != "" || string(body) != "secure" || got != "public.test" {157+ if message != "" || string(body) != "secure" || got != "example.com" {
158 t.Fatalf("HTTPS body=%q message=%q SNI=%q", body, message, got)158 t.Fatalf("HTTPS body=%q message=%q SNI=%q", body, message, got)
159 }159 }
160 }160 }
@@ -165,16 +165,17 @@ func TestWebPageFetcherPinsTheValidatedAddress(t *testing.T) {
165 _, _ = w.Write([]byte("pinned"))165 _, _ = w.Write([]byte("pinned"))
166 }))166 }))
167 defer server.Close()167 defer server.Close()
168- var dialed string168+ var dialed atomic.Value
169 fetcher := testWebFetcher(t, server)169 fetcher := testWebFetcher(t, server)
170 originalDial := fetcher.dial170 originalDial := fetcher.dial
171 fetcher.dial = func(ctx context.Context, network, address string) (net.Conn, error) {171 fetcher.dial = func(ctx context.Context, network, address string) (net.Conn, error) {
172- dialed = address172+ dialed.Store(address)
173 return originalDial(ctx, network, address)173 return originalDial(ctx, network, address)
174 }174 }
175 body, _, _, message := fetcher.fetch(context.Background(), "http://public.test/")175 body, _, _, message := fetcher.fetch(context.Background(), "http://public.test/")
176- if message != "" || string(body) != "pinned" || dialed != "8.8.8.8:80" {176+ got, _ := dialed.Load().(string)
177- t.Fatalf("pinned fetch body=%q message=%q dialed=%q", body, message, dialed)177+ if message != "" || string(body) != "pinned" || got != "8.8.8.8:80" {
178+ t.Fatalf("pinned fetch body=%q message=%q dialed=%q", body, message, got)
178 }179 }
179 }180 }
180 181
M packages/agent/tools/web_search.go +16 -1
@@ -192,14 +192,29 @@ func (t *WebSearchTool) Execute(ctx context.Context, raw json.RawMessage, progre
192192 }
193193 visible := results[:visibleCount]
194194 if t.store != nil {
195+ refs := make([]string, len(visible))
195196 for index := range visible {
196197 refID, committed := t.store.addSourceAtGeneration(visible[index].URL, storeGeneration)
197198 if !committed {
199+ for _, id := range refs {
200+ t.store.remove(id)
201+ }
198202 return webSearchError("web search: unavailable in this session"), nil
199203 }
204+ refs[index] = refID
200205 visible[index].RefID = refID
201206 }
202- text, _, _ = formatWebSearchResults(visible)
207+ initialTruncated := outputTruncated
208+ var finalTruncated bool
209+ text, visibleCount, finalTruncated = formatWebSearchResults(visible)
210+ outputTruncated = initialTruncated || finalTruncated
211+ for _, id := range refs[visibleCount:] {
212+ t.store.remove(id)
213+ }
214+ visible = visible[:visibleCount]
215+ if visibleCount == 0 {
216+ return webSearchError("web search: no usable HTTP(S) results"), nil
217+ }
203218 }
204219 return webSearchResultValue(text, false, map[string]any{
205220 "backend": "DuckDuckGo HTML",
@@ -192,14 +192,29 @@ func (t *WebSearchTool) Execute(ctx context.Context, raw json.RawMessage, progre
192 }192 }
193 visible := results[:visibleCount]193 visible := results[:visibleCount]
194 if t.store != nil {194 if t.store != nil {
195+ refs := make([]string, len(visible))
195 for index := range visible {196 for index := range visible {
196 refID, committed := t.store.addSourceAtGeneration(visible[index].URL, storeGeneration)197 refID, committed := t.store.addSourceAtGeneration(visible[index].URL, storeGeneration)
197 if !committed {198 if !committed {
199+ for _, id := range refs {
200+ t.store.remove(id)
201+ }
198 return webSearchError("web search: unavailable in this session"), nil202 return webSearchError("web search: unavailable in this session"), nil
199 }203 }
204+ refs[index] = refID
200 visible[index].RefID = refID205 visible[index].RefID = refID
201 }206 }
202- text, _, _ = formatWebSearchResults(visible)207+ initialTruncated := outputTruncated
208+ var finalTruncated bool
209+ text, visibleCount, finalTruncated = formatWebSearchResults(visible)
210+ outputTruncated = initialTruncated || finalTruncated
211+ for _, id := range refs[visibleCount:] {
212+ t.store.remove(id)
213+ }
214+ visible = visible[:visibleCount]
215+ if visibleCount == 0 {
216+ return webSearchError("web search: no usable HTTP(S) results"), nil
217+ }
203 }218 }
204 return webSearchResultValue(text, false, map[string]any{219 return webSearchResultValue(text, false, map[string]any{
205 "backend": "DuckDuckGo HTML",220 "backend": "DuckDuckGo HTML",

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close