From 18c35c4836f7268e8bb7481e2aca57bc5fa207de Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:24:22 +0900 Subject: [PATCH] feat: add dev preview proxy, tool, and Mini App tab Expose local dev servers through the Mini App reverse proxy so agents can let users preview in-progress web apps directly in Telegram. - Add /miniapp/dev/ reverse proxy with localhost-only validation - Add /miniapp/api/dev GET/POST endpoints and SSE "dev" event - Add dev_preview tool (start/stop/status) for agent control - Add Dev tab to Mini App with status, target input, and iframe preview - Add dev-preview skill (SKILL.md) in workspace Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 5 ++ pkg/miniapp/miniapp.go | 121 ++++++++++++++++++++++++- pkg/miniapp/miniapp_test.go | 142 +++++++++++++++++++++++++++-- pkg/miniapp/static/index.html | 91 ++++++++++++++++++- pkg/tools/dev_preview.go | 77 ++++++++++++++++ pkg/tools/dev_preview_test.go | 164 ++++++++++++++++++++++++++++++++++ 6 files changed, 591 insertions(+), 9 deletions(-) create mode 100644 pkg/tools/dev_preview.go create mode 100644 pkg/tools/dev_preview_test.go diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 188aac507..ca5d9c821 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -238,6 +238,11 @@ func gatewayCmd() { handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier) agentLoop.OnStateChange = miniappNotifier.Notify handler.RegisterRoutes(healthServer.Mux()) + + // Register dev preview tool for all agents + devPreviewTool := tools.NewDevPreviewTool(handler) + agentLoop.RegisterTool(devPreviewTool) + fmt.Printf("✓ Mini App registered at %s\n", webAppURL) } } diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 5c0a0d585..d34158409 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "net/http/httputil" "net/url" "sort" "strings" @@ -158,12 +159,22 @@ func (n *StateNotifier) Notify() { } } +// DevTargetSetter allows tools to control the dev proxy target. +type DevTargetSetter interface { + SetDevTarget(target string) error + GetDevTarget() string +} + // Handler serves the Mini App HTML and API endpoints. type Handler struct { provider DataProvider sender CommandSender botToken string notifier *StateNotifier + + devMu sync.RWMutex + devTarget *url.URL + devProxy *httputil.ReverseProxy } // NewHandler creates a new Mini App handler. @@ -176,6 +187,49 @@ func NewHandler(provider DataProvider, sender CommandSender, botToken string, no } } +// SetDevTarget sets the reverse proxy target URL. Only localhost targets are allowed. +// Pass an empty string to disable the proxy. +func (h *Handler) SetDevTarget(target string) error { + h.devMu.Lock() + defer h.devMu.Unlock() + + if target == "" { + h.devTarget = nil + h.devProxy = nil + if h.notifier != nil { + h.notifier.Notify() + } + return nil + } + + u, err := url.Parse(target) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + host := u.Hostname() + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + return fmt.Errorf("only localhost targets are allowed, got %q", host) + } + + h.devTarget = u + h.devProxy = httputil.NewSingleHostReverseProxy(u) + if h.notifier != nil { + h.notifier.Notify() + } + return nil +} + +// GetDevTarget returns the current dev proxy target URL, or empty string if disabled. +func (h *Handler) GetDevTarget() string { + h.devMu.RLock() + defer h.devMu.RUnlock() + if h.devTarget == nil { + return "" + } + return h.devTarget.String() +} + // RegisterRoutes registers Mini App routes on the given mux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp", h.serveIndex) @@ -185,7 +239,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions)) mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand)) mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit)) + mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev)) mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents)) + mux.HandleFunc("/miniapp/dev/", h.serveDevProxy) } func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) { @@ -286,6 +342,59 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "ok"}) } +func (h *Handler) apiDev(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + target := h.GetDevTarget() + writeJSON(w, map[string]any{ + "active": target != "", + "target": target, + }) + case http.MethodPost: + body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) + if err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + var req struct { + Target string `json:"target"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) + return + } + if err := h.SetDevTarget(req.Target); err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + target := h.GetDevTarget() + writeJSON(w, map[string]any{ + "active": target != "", + "target": target, + }) + default: + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + } +} + +func (h *Handler) serveDevProxy(w http.ResponseWriter, r *http.Request) { + h.devMu.RLock() + proxy := h.devProxy + h.devMu.RUnlock() + + if proxy == nil { + http.Error(w, "dev proxy not configured", http.StatusServiceUnavailable) + return + } + + // Strip /miniapp/dev prefix so /miniapp/dev/foo → /foo + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/miniapp/dev") + if r.URL.Path == "" { + r.URL.Path = "/" + } + proxy.ServeHTTP(w, r) +} + // extractUserFromInitData parses user.id from the initData query string. // initData contains a "user" param with JSON like {"id":123456,...}. func extractUserFromInitData(initData string) (userID, chatID string) { @@ -325,7 +434,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { ch := h.notifier.Subscribe() defer h.notifier.Unsubscribe(ch) - var lastPlan, lastSession, lastSkills []byte + var lastPlan, lastSession, lastSkills, lastDev []byte // Send initial state immediately sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) @@ -333,6 +442,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, &lastSession) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) + sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) for { select { @@ -346,10 +456,19 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, &lastSession) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) + sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) } } } +func (h *Handler) devStatus() map[string]any { + target := h.GetDevTarget() + return map[string]any{ + "active": target != "", + "target": target, + } +} + func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) { data, _ := json.Marshal(v) if !bytes.Equal(data, *last) { diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index 5674edd42..c2d277bf7 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -6,6 +6,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -248,7 +249,7 @@ func TestSSE_InitialEvents(t *testing.T) { events := make(map[string]bool) deadline := time.After(2 * time.Second) - for len(events) < 3 { + for len(events) < 4 { done := make(chan bool, 1) go func() { done <- scanner.Scan() @@ -267,7 +268,7 @@ func TestSSE_InitialEvents(t *testing.T) { } } - for _, name := range []string{"plan", "session", "skills"} { + for _, name := range []string{"plan", "session", "skills", "dev"} { if !events[name] { t.Errorf("missing initial event %q", name) } @@ -374,8 +375,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial 3 events - drainEvents(t, scanner, 3, 2*time.Second) + // Drain initial 4 events (plan, session, skills, dev) + drainEvents(t, scanner, 4, 2*time.Second) // Mutate state and notify — diff dedup should detect the change and send a new event provider.mutated.Store(true) @@ -403,8 +404,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial events - drainEvents(t, scanner, 3, 2*time.Second) + // Drain initial events (plan, session, skills, dev) + drainEvents(t, scanner, 4, 2*time.Second) // Notify with unchanged data — should produce zero new event lines notifier.Notify() @@ -458,6 +459,135 @@ func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo { return GitInfo{Name: name} } +// ── Dev proxy tests ── + +func TestDevProxy_SetAndGet(t *testing.T) { + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + + // Initially empty + if got := h.GetDevTarget(); got != "" { + t.Errorf("expected empty target, got %q", got) + } + + // Set a valid target + if err := h.SetDevTarget("http://localhost:3000"); err != nil { + t.Fatalf("SetDevTarget failed: %v", err) + } + if got := h.GetDevTarget(); got != "http://localhost:3000" { + t.Errorf("expected http://localhost:3000, got %q", got) + } + + // Clear target + if err := h.SetDevTarget(""); err != nil { + t.Fatalf("SetDevTarget(\"\") failed: %v", err) + } + if got := h.GetDevTarget(); got != "" { + t.Errorf("expected empty target after clear, got %q", got) + } +} + +func TestDevProxy_LocalhostOnly(t *testing.T) { + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + + // External host should be rejected + if err := h.SetDevTarget("http://example.com:3000"); err == nil { + t.Error("expected error for external host, got nil") + } + + // 127.0.0.1 should be allowed + if err := h.SetDevTarget("http://127.0.0.1:8080"); err != nil { + t.Errorf("expected 127.0.0.1 to be allowed, got %v", err) + } + + // ::1 should be allowed + if err := h.SetDevTarget("http://[::1]:9000"); err != nil { + t.Errorf("expected [::1] to be allowed, got %v", err) + } +} + +func TestDevProxy_ReverseProxy(t *testing.T) { + // Create a backend server + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(w, "path=%s", r.URL.Path) + })) + defer backend.Close() + + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Set target to the test backend + if err := h.SetDevTarget(backend.URL); err != nil { + t.Fatalf("SetDevTarget failed: %v", err) + } + + // Request through the proxy + req := httptest.NewRequest("GET", "/miniapp/dev/hello", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if got := w.Body.String(); got != "path=/hello" { + t.Errorf("expected path=/hello, got %q", got) + } +} + +func TestDevProxy_503WhenNotConfigured(t *testing.T) { + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest("GET", "/miniapp/dev/", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestDevProxy_APIEndpoint(t *testing.T) { + notifier := NewStateNotifier() + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + defer ts.Close() + + initData := testInitData() + + // GET — initially inactive + resp, err := http.Get(ts.URL + "/miniapp/api/dev?initData=" + url.QueryEscape(initData)) + if err != nil { + t.Fatalf("GET failed: %v", err) + } + defer resp.Body.Close() + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + if result["active"] != false { + t.Errorf("expected active=false, got %v", result["active"]) + } + + // POST — set target + body := strings.NewReader(`{"target":"http://localhost:4000"}`) + resp2, err := http.Post(ts.URL+"/miniapp/api/dev?initData="+url.QueryEscape(initData), "application/json", body) + if err != nil { + t.Fatalf("POST failed: %v", err) + } + defer resp2.Body.Close() + var result2 map[string]any + json.NewDecoder(resp2.Body).Decode(&result2) + if result2["active"] != true { + t.Errorf("expected active=true after set, got %v", result2["active"]) + } + if result2["target"] != "http://localhost:4000" { + t.Errorf("expected target=http://localhost:4000, got %v", result2["target"]) + } +} + // drainEvents reads SSE event lines until it collects `want` distinct event names or times out. func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool { t.Helper() diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 5582d6ad7..0d8de1361 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -84,7 +84,7 @@ top: 2px; bottom: 2px; left: 2px; - width: calc(20% - 2px); + width: calc(100%/6 - 2px); border-radius: 8px; background: var(--tab-pill-bg); box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08); @@ -645,6 +645,7 @@ + @@ -686,6 +687,29 @@ +
+
+
Dev Preview
+
Inactive
+
+
+
+
Target URL
+
+ + +
+
+ +
+
+ +
+