Skip to content

Commit 92a3cbb

Browse files
committed
hot reload sandbox and improve web fetch
1 parent 568544c commit 92a3cbb

10 files changed

Lines changed: 202 additions & 51 deletions

File tree

cmd/fastclaw/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ func (a *apiResolver) EnsureAgent(ctx context.Context, userID, agentID string) e
6464
// chat turn.
6565
func (a *apiResolver) ReloadAgents() error { return a.gw.ReloadAgents() }
6666

67+
// ReloadSandbox rebuilds the gateway-owned sandbox executor pool after
68+
// system-scope sandbox settings change, avoiding a manual process restart.
69+
func (a *apiResolver) ReloadSandbox() error { return a.gw.ReloadSandbox() }
70+
6771
// RegisterChannelFromConfig hot-starts a freshly-saved channel row.
6872
// Called by setup handlers after they persist a new bot config so the
6973
// adapter starts polling without a process restart.

internal/agent/tools/web_fetch.go

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import (
88
"net"
99
"net/http"
1010
"net/url"
11-
"regexp"
1211
"strings"
1312
"time"
1413

1514
"github.com/fastclaw-ai/fastclaw/internal/toolproviders"
15+
webfetchprovider "github.com/fastclaw-ai/fastclaw/internal/toolproviders/webfetch"
1616
)
1717

1818
type webFetchArgs struct {
@@ -26,8 +26,6 @@ const (
2626
fetchUserAgent = "FastClaw/1.0 (AI Agent Web Fetcher)"
2727
)
2828

29-
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
30-
3129
// safeFetchClient is an http.Client whose dialer rejects private,
3230
// loopback, link-local, multicast, and CGNAT addresses — the SSRF
3331
// defense for web_fetch. The check runs at DIAL time, after DNS has
@@ -275,15 +273,17 @@ func webFetchTool(ctx context.Context, r *Registry, rawArgs json.RawMessage) (st
275273
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
276274
}
277275

278-
// Read body with a limit to prevent memory issues
279-
limitReader := io.LimitReader(resp.Body, int64(maxLen*3)) // read more than needed since HTML is verbose
276+
// Read body with a limit to prevent memory issues. WeChat articles
277+
// need a bounded larger window because the readable #js_content node
278+
// can appear after a long script/config prelude.
279+
limitReader := io.LimitReader(resp.Body, webfetchprovider.FetchReadLimit(args.URL, maxLen))
280280
body, err := io.ReadAll(limitReader)
281281
if err != nil {
282282
return "", fmt.Errorf("read body: %w", err)
283283
}
284284

285-
// Strip HTML tags
286-
text := stripHTML(string(body))
285+
// Strip HTML tags, using site-specific article extraction when needed.
286+
text := webfetchprovider.HTMLToText(args.URL, string(body))
287287

288288
// Truncate to max length (UTF-8 safe: back up to a valid rune boundary).
289289
if len(text) > maxLen {
@@ -312,33 +312,3 @@ func assertHTTPScheme(rawURL string) error {
312312
}
313313
return nil
314314
}
315-
316-
// stripHTML removes HTML tags and cleans up whitespace.
317-
func stripHTML(html string) string {
318-
// Remove script and style elements entirely
319-
scriptRe := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
320-
html = scriptRe.ReplaceAllString(html, "")
321-
styleRe := regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
322-
html = styleRe.ReplaceAllString(html, "")
323-
324-
// Remove HTML tags
325-
text := htmlTagRe.ReplaceAllString(html, " ")
326-
327-
// Decode common HTML entities
328-
text = strings.ReplaceAll(text, "&amp;", "&")
329-
text = strings.ReplaceAll(text, "&lt;", "<")
330-
text = strings.ReplaceAll(text, "&gt;", ">")
331-
text = strings.ReplaceAll(text, "&quot;", "\"")
332-
text = strings.ReplaceAll(text, "&#39;", "'")
333-
text = strings.ReplaceAll(text, "&nbsp;", " ")
334-
335-
// Collapse whitespace
336-
spaceRe := regexp.MustCompile(`[ \t]+`)
337-
text = spaceRe.ReplaceAllString(text, " ")
338-
339-
// Collapse multiple newlines
340-
nlRe := regexp.MustCompile(`\n{3,}`)
341-
text = nlRe.ReplaceAllString(text, "\n\n")
342-
343-
return strings.TrimSpace(text)
344-
}

internal/gateway/reload.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log/slog"
77

88
"github.com/fastclaw-ai/fastclaw/internal/channels"
9+
"github.com/fastclaw-ai/fastclaw/internal/sandbox"
910
"github.com/fastclaw-ai/fastclaw/internal/store"
1011
)
1112

@@ -55,6 +56,45 @@ func (g *Gateway) ReloadAgents() error {
5556
return nil
5657
}
5758

59+
// ReloadSandbox rebuilds the gateway-wide sandbox executor pool from the
60+
// current system-scope sandbox config. It is called after the admin/settings
61+
// UI saves sandbox changes so exec tools can pick them up without a process
62+
// restart.
63+
func (g *Gateway) ReloadSandbox() error {
64+
if g.store == nil {
65+
return nil
66+
}
67+
cfg := readSystemSandboxCfg(g.store)
68+
next := buildSystemSandboxPool(cfg, g.workspace)
69+
70+
g.mu.Lock()
71+
prev := g.sandboxPool
72+
g.sandboxPool = next
73+
if g.projectRuntime != nil {
74+
g.projectRuntime.SetSandboxPool(cfg.Backend, next)
75+
}
76+
evicted := 0
77+
if g.users != nil {
78+
evicted = g.users.setSystemSandboxPool(next)
79+
}
80+
g.mu.Unlock()
81+
82+
if prev != nil && prev != next {
83+
prev.CloseAll()
84+
}
85+
slog.Info("hot-reload: sandbox executor pool rebuilt",
86+
"backend", sandboxPoolBackend(next),
87+
"evictedUserSpaces", evicted)
88+
return nil
89+
}
90+
91+
func sandboxPoolBackend(p sandbox.ExecutorPool) string {
92+
if p == nil {
93+
return ""
94+
}
95+
return p.Backend()
96+
}
97+
5898
// reloadAgentForUser is a finer-grained invalidate used by setup handlers
5999
// after a single user mutates their own agents.
60100
func (g *Gateway) reloadAgentForUser(_ context.Context, userID string) {

internal/gateway/userspace.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,18 @@ func (r *userSpaceRegistry) setProjectRuntime(m *coderuntime.Manager) {
959959
r.mu.Unlock()
960960
}
961961

962+
// setSystemSandboxPool swaps the gateway-owned pool reference used for
963+
// future UserSpace loads, then drops already-loaded spaces so active agents
964+
// reattach to the new pool on their next request.
965+
func (r *userSpaceRegistry) setSystemSandboxPool(p sandbox.ExecutorPool) int {
966+
r.mu.Lock()
967+
defer r.mu.Unlock()
968+
r.systemSandboxPool = p
969+
evicted := len(r.spaces)
970+
r.spaces = make(map[string]*userSpaceEntry)
971+
return evicted
972+
}
973+
962974
type userSpaceEntry struct {
963975
space *UserSpace
964976
lastUsed time.Time

internal/runtime/runtime.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,16 @@ func NewManager(st store.Store, workspaceRoot, image string, policy *sandbox.Pol
179179
}
180180
}
181181

182+
// SetSandboxPool updates the borrowed turn-sandbox pool after system
183+
// sandbox settings are saved. Existing live runtimes keep running; new
184+
// pooled preview starts use the refreshed backend/pool.
185+
func (m *Manager) SetSandboxPool(backend string, pool sandbox.ExecutorPool) {
186+
m.mu.Lock()
187+
m.backend = backend
188+
m.pool = pool
189+
m.mu.Unlock()
190+
}
191+
182192
// usesPool reports whether the preview should run through the shared
183193
// turn-sandbox pool (non-docker backend) rather than a dedicated docker
184194
// container. Requires a pool to borrow; without one we fall back to the

internal/setup/handlers.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,9 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
540540
return
541541
}
542542
var raw struct {
543-
Prefs *config.PrefsCfg `json:"prefs"`
544-
Skills *struct {
543+
Prefs *config.PrefsCfg `json:"prefs"`
544+
Sandbox *json.RawMessage `json:"sandbox"`
545+
Skills *struct {
545546
AgentEntries map[string]map[string]config.SkillEntryCfg `json:"agentEntries"`
546547
} `json:"skills"`
547548
}
@@ -610,10 +611,27 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
610611
// agent loaded before the change keeps seeing the stale model and
611612
// surfaces "no usable LLM provider" in chat.
612613
sc, scopeID := s.scopeForSave(r)
614+
if sc == scope.System && raw.Sandbox != nil {
615+
if err := s.reloadSystemSandbox(); err != nil {
616+
jsonResponse(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
617+
return
618+
}
619+
}
613620
s.invalidateScope(sc, scopeID)
614621
jsonResponse(w, http.StatusOK, map[string]any{"ok": true})
615622
}
616623

624+
func (s *Server) reloadSystemSandbox() error {
625+
type sandboxReloader interface{ ReloadSandbox() error }
626+
if s.userResolver == nil {
627+
return nil
628+
}
629+
if r, ok := s.userResolver.(sandboxReloader); ok {
630+
return r.ReloadSandbox()
631+
}
632+
return nil
633+
}
634+
617635
// scopeForSave mirrors the scope-resolution logic in saveUserConfig so
618636
// callers can invalidate exactly the UserSpaces that were just touched.
619637
func (s *Server) scopeForSave(r *http.Request) (string, string) {

internal/setup/handlers_admin.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,12 @@ type onboardRequest struct {
187187
Password string `json:"password"`
188188
DisplayName string `json:"displayName,omitempty"`
189189

190-
Provider string `json:"provider"`
191-
APIBase string `json:"apiBase"`
192-
APIKey string `json:"apiKey"`
193-
APIType string `json:"apiType,omitempty"`
194-
AuthType string `json:"authType,omitempty"`
195-
Model string `json:"model"`
190+
Provider string `json:"provider"`
191+
APIBase string `json:"apiBase"`
192+
APIKey string `json:"apiKey"`
193+
APIType string `json:"apiType,omitempty"`
194+
AuthType string `json:"authType,omitempty"`
195+
Model string `json:"model"`
196196

197197
AgentName string `json:"agentName,omitempty"`
198198

@@ -317,6 +317,10 @@ func (s *Server) handleOnboard(w http.ResponseWriter, r *http.Request) {
317317
jsonResponse(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
318318
return
319319
}
320+
if err := s.reloadSystemSandbox(); err != nil {
321+
jsonResponse(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
322+
return
323+
}
320324
}
321325
cookie, err := s.authResolver.IssueSession(r.Context(), acct.ID)
322326
if err == nil {

internal/toolproviders/webfetch/direct.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import (
1616
// pick any other provider.
1717
type Direct struct{}
1818

19-
func (Direct) Category() string { return Category }
20-
func (Direct) Name() string { return "direct" }
21-
func (Direct) CredentialFree() bool { return true }
19+
func (Direct) Category() string { return Category }
20+
func (Direct) Name() string { return "direct" }
21+
func (Direct) CredentialFree() bool { return true }
2222

2323
const (
2424
directTimeout = 30 * time.Second
@@ -57,11 +57,13 @@ func (d *Direct) Execute(ctx context.Context, req toolproviders.Request) (toolpr
5757
}
5858

5959
// Read 3× the cap because HTML is verbose and stripping tags shrinks
60-
// it substantially — same heuristic the legacy direct fetcher used.
61-
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(a.MaxLen*3)))
60+
// it substantially. WeChat articles are much larger before the
61+
// #js_content article body, so give those a bounded larger window and
62+
// extract the article node before truncating.
63+
body, err := io.ReadAll(io.LimitReader(resp.Body, FetchReadLimit(a.URL, a.MaxLen)))
6264
if err != nil {
6365
return toolproviders.Response{}, toolproviders.Retry(fmt.Errorf("direct read: %w", err))
6466
}
65-
text := truncate(stripHTML(string(body)), a.MaxLen)
67+
text := truncate(HTMLToText(a.URL, string(body)), a.MaxLen)
6668
return toolproviders.Response{Text: text}, nil
6769
}

internal/toolproviders/webfetch/webfetch.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package webfetch
66

77
import (
88
"fmt"
9+
stdhtml "html"
10+
"net/url"
911
"regexp"
1012
"strings"
1113

@@ -62,6 +64,61 @@ func truncate(text string, maxLen int) string {
6264
return text[:maxLen] + "\n[...truncated]"
6365
}
6466

67+
func isWeChatArticle(rawURL string) bool {
68+
u, err := url.Parse(rawURL)
69+
if err != nil {
70+
return false
71+
}
72+
host := strings.ToLower(u.Hostname())
73+
return host == "mp.weixin.qq.com" || strings.HasSuffix(host, ".mp.weixin.qq.com")
74+
}
75+
76+
func FetchReadLimit(rawURL string, maxLen int) int64 {
77+
if isWeChatArticle(rawURL) {
78+
return 2 << 20
79+
}
80+
return int64(maxLen * 3)
81+
}
82+
83+
func HTMLToText(rawURL, htmlBody string) string {
84+
if isWeChatArticle(rawURL) {
85+
if article := extractElementByID(htmlBody, "js_content"); article != "" {
86+
return stripHTML(article)
87+
}
88+
}
89+
return stripHTML(htmlBody)
90+
}
91+
92+
func extractElementByID(htmlBody, id string) string {
93+
re := regexp.MustCompile(`(?is)<([a-z0-9]+)\b[^>]*\bid\s*=\s*['"]` + regexp.QuoteMeta(id) + `['"][^>]*>`)
94+
loc := re.FindStringSubmatchIndex(htmlBody)
95+
if loc == nil {
96+
return ""
97+
}
98+
tagName := strings.ToLower(htmlBody[loc[2]:loc[3]])
99+
start := loc[0]
100+
searchFrom := loc[1]
101+
tagRe := regexp.MustCompile(`(?is)<\s*(/?)\s*` + regexp.QuoteMeta(tagName) + `\b[^>]*>`)
102+
depth := 1
103+
for _, m := range tagRe.FindAllStringSubmatchIndex(htmlBody[searchFrom:], -1) {
104+
tagStart := searchFrom + m[0]
105+
tagEnd := searchFrom + m[1]
106+
closing := m[2] >= 0 && htmlBody[searchFrom+m[2]:searchFrom+m[3]] == "/"
107+
selfClosing := strings.HasSuffix(strings.TrimSpace(htmlBody[tagStart:tagEnd]), "/>")
108+
if closing {
109+
depth--
110+
if depth == 0 {
111+
return htmlBody[start:tagEnd]
112+
}
113+
continue
114+
}
115+
if !selfClosing {
116+
depth++
117+
}
118+
}
119+
return ""
120+
}
121+
65122
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
66123

67124
// stripHTML removes script/style blocks, drops remaining HTML tags, and
@@ -81,6 +138,7 @@ func stripHTML(html string) string {
81138
text = strings.ReplaceAll(text, "&quot;", "\"")
82139
text = strings.ReplaceAll(text, "&#39;", "'")
83140
text = strings.ReplaceAll(text, "&nbsp;", " ")
141+
text = stdhtml.UnescapeString(text)
84142

85143
spaceRe := regexp.MustCompile(`[ \t]+`)
86144
text = spaceRe.ReplaceAllString(text, " ")
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package webfetch
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestHTMLToTextExtractsWeChatArticleBody(t *testing.T) {
9+
html := `<html><head><script>window.cgiData = { noisy: "` + strings.Repeat("x", 1024) + `" }</script></head>
10+
<body>
11+
<div id="js_content" class="rich_media_content">
12+
<section><p>第一段&nbsp;内容</p><div><p>第二段 &amp; 更多</p></div></section>
13+
</div>
14+
<script>window.after = "ignore me"</script>
15+
</body></html>`
16+
17+
got := HTMLToText("https://mp.weixin.qq.com/s/example", html)
18+
if !strings.Contains(got, "第一段 内容") || !strings.Contains(got, "第二段 & 更多") {
19+
t.Fatalf("expected article text, got %q", got)
20+
}
21+
if strings.Contains(got, "window.cgiData") || strings.Contains(got, "ignore me") {
22+
t.Fatalf("expected scripts to be ignored, got %q", got)
23+
}
24+
}
25+
26+
func TestFetchReadLimitUsesLargerWindowForWeChat(t *testing.T) {
27+
if got := FetchReadLimit("https://mp.weixin.qq.com/s/example", 10000); got < 1<<20 {
28+
t.Fatalf("FetchReadLimit for WeChat = %d, want a large bounded window", got)
29+
}
30+
if got := FetchReadLimit("https://example.com/post", 10000); got != 30000 {
31+
t.Fatalf("FetchReadLimit for regular URL = %d, want 30000", got)
32+
}
33+
}

0 commit comments

Comments
 (0)