From 32cfed19621d8e0b5cefdf93575862816986fd6f Mon Sep 17 00:00:00 2001 From: verte Date: Mon, 6 Apr 2026 07:45:52 -0400 Subject: [PATCH] chore(release): prepare release v0.7.1 Made-with: Cursor --- VERSION | 2 +- cmd/localforge/config.example.yaml | 3 +- cmd/localforge/src/handlers_webhook.go | 16 ++- cmd/localforge/src/main.go | 2 +- cmd/localforge/src/providers/telegram.go | 97 ++++++++++----- .../src/providers/telegram_command_test.go | 45 +++++++ cmd/localforge/src/server.go | 2 +- scripts/install-release.sh | 63 ++++++++++ src/plugins/heartbeat/manager.go | 92 +++++++++++++++ src/plugins/heartbeat/manager_test.go | 88 ++++++++++++++ src/plugins/heartbeat/plugin.go | 12 +- src/plugins/heartbeat/tool.go | 82 +++++++++++++ src/plugins/heartbeat/tool_test.go | 111 ++++++++++++++++++ src/tools/telegram/actHealthStatus.go | 35 +++++- src/tools/telegram/tool_test.go | 1 + src/tools/web/actClick.go | 78 +++++++----- src/tools/web/scripts/click_element.js | 67 +++++++++++ src/tools/web/tool.go | 4 +- 18 files changed, 727 insertions(+), 73 deletions(-) create mode 100644 src/plugins/heartbeat/manager.go create mode 100644 src/plugins/heartbeat/manager_test.go create mode 100644 src/plugins/heartbeat/tool.go create mode 100644 src/plugins/heartbeat/tool_test.go create mode 100644 src/tools/web/scripts/click_element.js diff --git a/VERSION b/VERSION index faef31a..39e898a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 diff --git a/cmd/localforge/config.example.yaml b/cmd/localforge/config.example.yaml index 7409c70..bd9c891 100644 --- a/cmd/localforge/config.example.yaml +++ b/cmd/localforge/config.example.yaml @@ -18,7 +18,8 @@ agent: # Set WEBHOOK_SECRET_TELEGRAM in .env before start_ngrok/set_webhook; Localforge requires it for Telegram webhooks. # In Telegram, send /new_conversation to start a fresh JSON thread (map stored under data/telegram_thread_map.json). # Telegram access control: comma-separated chat/user IDs allowed to talk to the agent. - # Leave unset (or empty) to allow all senders. Set TELEGRAM_ALLOWED_CHAT_IDS in your .env. + # Leave unset (or empty) to allow all senders. TELEGRAM_ALLOWED_USER_IDS: comma-separated Telegram + # usernames (e.g. @yourname,@friend). Leading @ is optional; matching is case-insensitive. # To find your chat ID: send any message to the bot and check the Localforge debug logs. # Optional: ephemeral sub-tasks with spawn_subagent (subset of parent tools): # spawn_subagent: true diff --git a/cmd/localforge/src/handlers_webhook.go b/cmd/localforge/src/handlers_webhook.go index c002739..26c56ef 100644 --- a/cmd/localforge/src/handlers_webhook.go +++ b/cmd/localforge/src/handlers_webhook.go @@ -97,8 +97,13 @@ func (s *Server) handleWebhook(c *gin.Context) { return } - if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) { - agentforge.Debug("Blocked webhook from %s (chat ID %s not in allowlist)", provider, recipientID) + if tp, ok := providerInst.(*providers.TelegramProvider); ok { + if !tp.AllowlistPermits(payload) { + agentforge.Debug("Blocked webhook from telegram: set TELEGRAM_ALLOWED_USER_IDS or sender username not in allowlist") + return + } + } else if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) { + agentforge.Debug("Blocked webhook from %s (recipient %s not in allowlist)", provider, recipientID) return } @@ -635,7 +640,12 @@ func (s *Server) handleWebhookSync(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "could not extract recipient"}) return } - if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) { + if tp, ok := providerInst.(*providers.TelegramProvider); ok { + if !tp.AllowlistPermits(payload) { + c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) + return + } + } else if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) { c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) return } diff --git a/cmd/localforge/src/main.go b/cmd/localforge/src/main.go index dffb560..9b91c89 100644 --- a/cmd/localforge/src/main.go +++ b/cmd/localforge/src/main.go @@ -73,7 +73,7 @@ func main() { // After each heartbeat turn, send the full response to all known Telegram recipients. // Recipients are discovered from conversation files and the telegram thread store — - // no TELEGRAM_ALLOWED_CHAT_IDS env var required. + // no TELEGRAM_ALLOWED_USER_IDS env var required. agentMgr.SetTurnCompleteRouter(func(chatId, fullContent string) { if !strings.HasPrefix(chatId, "heartbeat-") { return diff --git a/cmd/localforge/src/providers/telegram.go b/cmd/localforge/src/providers/telegram.go index a3e7cd9..e3e07d7 100644 --- a/cmd/localforge/src/providers/telegram.go +++ b/cmd/localforge/src/providers/telegram.go @@ -12,44 +12,90 @@ import ( // TelegramProvider implements the Provider interface for Telegram messaging type TelegramProvider struct { - botToken string - client *http.Client - allowedChatIDs map[string]struct{} // empty = allow all + botToken string + client *http.Client + allowedUsernames map[string]struct{} // lowercase, no @; empty = deny all (TELEGRAM_ALLOWED_USER_IDS required) } // NewTelegramProvider creates a new Telegram provider. -// allowedChatIDs is an optional set of Telegram chat/user IDs that are -// permitted to interact with the agent. Pass nil or an empty slice to allow -// all senders (default / backward-compatible behaviour). -func NewTelegramProvider(botToken string, allowedChatIDs []string) *TelegramProvider { - allowed := make(map[string]struct{}, len(allowedChatIDs)) - for _, id := range allowedChatIDs { - if id != "" { - allowed[id] = struct{}{} +// allowlistEntries (TELEGRAM_ALLOWED_USER_IDS) must contain at least one Telegram username +// (with or without @), matched case-insensitively against message.from.username. +// An empty list blocks all incoming webhooks. +func NewTelegramProvider(botToken string, allowlistEntries []string) *TelegramProvider { + allowed := make(map[string]struct{}, len(allowlistEntries)) + for _, raw := range allowlistEntries { + u := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "@"))) + if u != "" { + allowed[u] = struct{}{} } } return &TelegramProvider{ - botToken: botToken, - client: &http.Client{Timeout: 30 * time.Second}, - allowedChatIDs: allowed, + botToken: botToken, + client: &http.Client{Timeout: 30 * time.Second}, + allowedUsernames: allowed, } } -// IsAllowed returns true when the given chat/user ID is in the allowlist, or -// when no allowlist is configured (len == 0 means allow all). -func (p *TelegramProvider) IsAllowed(chatID string) bool { - if len(p.allowedChatIDs) == 0 { - return true +// AllowlistPermits returns true when the sender's username (message.from.username / +// callback_query.from.username) is listed in TELEGRAM_ALLOWED_USER_IDS. +// Matching is case-insensitive; leading @ is ignored. +// Returns false when the allowlist is empty (TELEGRAM_ALLOWED_USER_IDS not set). +func (p *TelegramProvider) AllowlistPermits(payload map[string]interface{}) bool { + if len(p.allowedUsernames) == 0 { + return false + } + from, ok := telegramFromPayload(payload) + if !ok { + return false + } + username, _ := from["username"].(string) + if username == "" { + return false } - _, ok := p.allowedChatIDs[chatID] + _, ok = p.allowedUsernames[strings.ToLower(username)] return ok } +func telegramFromPayload(payload map[string]interface{}) (map[string]interface{}, bool) { + if cbq, ok := payload["callback_query"].(map[string]interface{}); ok { + if from, ok := cbq["from"].(map[string]interface{}); ok { + return from, true + } + } + var msg map[string]interface{} + if m, ok := payload["message"].(map[string]interface{}); ok { + msg = m + } else if m, ok := payload["edited_message"].(map[string]interface{}); ok { + msg = m + } + if msg == nil { + return nil, false + } + if from, ok := msg["from"].(map[string]interface{}); ok { + return from, true + } + return nil, false +} + // Name returns the provider name func (p *TelegramProvider) Name() string { return "telegram" } +// formatTelegramScalarID formats a user or chat id from JSON-decoded Update objects. +func formatTelegramScalarID(id interface{}) (string, error) { + switch v := id.(type) { + case float64: + return fmt.Sprintf("%.0f", v), nil + case int: + return fmt.Sprintf("%d", v), nil + case string: + return v, nil + default: + return "", fmt.Errorf("invalid id type: %T", id) + } +} + // extractChatID parses a chat.id value from a Telegram message map. func extractChatID(message map[string]interface{}) (string, error) { chat, ok := message["chat"].(map[string]interface{}) @@ -60,16 +106,7 @@ func extractChatID(message map[string]interface{}) (string, error) { if !ok { return "", fmt.Errorf("missing chat 'id'") } - switch v := chatID.(type) { - case float64: - return fmt.Sprintf("%.0f", v), nil - case int: - return fmt.Sprintf("%d", v), nil - case string: - return v, nil - default: - return "", fmt.Errorf("invalid chat id type: %T", chatID) - } + return formatTelegramScalarID(chatID) } // ExtractRecipient extracts the chat ID from a Telegram webhook payload. diff --git a/cmd/localforge/src/providers/telegram_command_test.go b/cmd/localforge/src/providers/telegram_command_test.go index 313ab27..7024d64 100644 --- a/cmd/localforge/src/providers/telegram_command_test.go +++ b/cmd/localforge/src/providers/telegram_command_test.go @@ -39,3 +39,48 @@ func TestTelegramMessageText(t *testing.T) { t.Fatal("expected false for empty payload") } } + +func TestTelegramAllowlistPermits(t *testing.T) { + tok := "x" + payload := func(username string) map[string]interface{} { + return map[string]interface{}{ + "message": map[string]interface{}{ + "from": map[string]interface{}{ + "id": float64(999), + "username": username, + }, + "chat": map[string]interface{}{"id": float64(999), "type": "private"}, + "text": "hi", + }, + } + } + + if NewTelegramProvider(tok, nil).AllowlistPermits(payload("alice")) { + t.Fatal("empty allowlist (TELEGRAM_ALLOWED_USER_IDS not set) should deny all") + } + if !NewTelegramProvider(tok, []string{"alice"}).AllowlistPermits(payload("alice")) { + t.Fatal("exact username match should permit") + } + if !NewTelegramProvider(tok, []string{"@Alice"}).AllowlistPermits(payload("alice")) { + t.Fatal("@ prefix and different case should permit") + } + if NewTelegramProvider(tok, []string{"bob"}).AllowlistPermits(payload("alice")) { + t.Fatal("wrong username should deny") + } + if NewTelegramProvider(tok, []string{"alice"}).AllowlistPermits(payload("")) { + t.Fatal("no username in payload should deny") + } + + cbqPayload := map[string]interface{}{ + "callback_query": map[string]interface{}{ + "id": "cb1", + "from": map[string]interface{}{"id": float64(99), "username": "Alice"}, + "message": map[string]interface{}{ + "chat": map[string]interface{}{"id": float64(99)}, + }, + }, + } + if !NewTelegramProvider(tok, []string{"alice"}).AllowlistPermits(cbqPayload) { + t.Fatal("callback_query from username should permit") + } +} diff --git a/cmd/localforge/src/server.go b/cmd/localforge/src/server.go index 2cf568d..c523383 100644 --- a/cmd/localforge/src/server.go +++ b/cmd/localforge/src/server.go @@ -55,7 +55,7 @@ func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoMa // Register Telegram provider if token exists if token := os.Getenv("TELEGRAM_BOT_TOKEN"); token != "" { var allowedIDs []string - if raw := os.Getenv("TELEGRAM_ALLOWED_CHAT_IDS"); raw != "" { + if raw := os.Getenv("TELEGRAM_ALLOWED_USER_IDS"); raw != "" { for _, id := range strings.Split(raw, ",") { allowedIDs = append(allowedIDs, strings.TrimSpace(id)) } diff --git a/scripts/install-release.sh b/scripts/install-release.sh index bc61d71..0b8af70 100755 --- a/scripts/install-release.sh +++ b/scripts/install-release.sh @@ -68,6 +68,43 @@ for a in data.get('assets', []): esac } +# Download all files under docs/ from the main branch (raw.githubusercontent.com). +download_docs_from_github() { + local dest="$1" + local api_url="https://api.github.com/repos/${REPO}/git/trees/main?recursive=1" + local tree_json paths + tree_json="$(curl -fsSL "$api_url")" || return 1 + case "$JSON_TOOL" in + jq) + paths="$(echo "$tree_json" | jq -r '.tree[] | select(.type == "blob") | select(.path | startswith("docs/")) | .path')" + ;; + python3|python) + paths="$(echo "$tree_json" | "$JSON_TOOL" -c " +import sys, json +d = json.load(sys.stdin) +for t in d.get('tree', []): + if t.get('type') == 'blob': + p = t.get('path', '') + if p.startswith('docs/'): + print(p) +")" + ;; + esac + if [ -z "$paths" ]; then + return 1 + fi + mkdir -p "$dest" + while IFS= read -r path; do + [ -z "$path" ] && continue + local rel="${path#docs/}" + local outfile="$dest/$rel" + mkdir -p "$(dirname "$outfile")" + local raw_url="https://raw.githubusercontent.com/${REPO}/main/${path}" + curl -fsSL "$raw_url" -o "$outfile" || return 1 + done <<< "$paths" + return 0 +} + # ─── OS / arch detection ───────────────────────────────────────────────────── RAW_OS="$(uname -s 2>/dev/null || echo "unknown")" @@ -179,6 +216,31 @@ else fi fi +# ─── docs/ (framework reference; full tree from repo or GitHub main) ───────── + +DOCS_DEST="$INSTALL_DIR/docs" +if [ -d "$DOCS_DEST" ] && [ -n "$(ls -A "$DOCS_DEST" 2>/dev/null)" ]; then + echo "docs/ already present — skipping." +else + DOCS_OK=false + if [ -n "${BASH_SOURCE[0]:-}" ]; then + _INSTALL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [ -d "$_INSTALL_SCRIPT_DIR/../docs" ]; then + mkdir -p "$DOCS_DEST" + cp -a "$_INSTALL_SCRIPT_DIR/../docs/." "$DOCS_DEST/" + DOCS_OK=true + echo "docs/ copied into workspace." + fi + fi + if [ "$DOCS_OK" = false ]; then + if download_docs_from_github "$DOCS_DEST"; then + echo "docs/ downloaded from GitHub (main)." + else + echo "WARNING: Could not copy or download docs/ into workspace (skipped)." + fi + fi +fi + # ─── download binary ───────────────────────────────────────────────────────── echo "Downloading $ASSET_NAME..." @@ -305,6 +367,7 @@ echo "" echo "Install complete ($TAG):" echo " $BINARY_PATH" echo " $INSTALL_DIR/README.md — framework capabilities (agent workspace entrypoint)" +echo " $INSTALL_DIR/docs/ — framework reference (from install-release.sh)" echo " $INSTALL_DIR/config.yaml — edit model, system_prompt, tools" echo " $INSTALL_DIR/.env — add your API keys" echo " $UPDATE_SCRIPT — run to update to latest version" diff --git a/src/plugins/heartbeat/manager.go b/src/plugins/heartbeat/manager.go new file mode 100644 index 0000000..7109ab6 --- /dev/null +++ b/src/plugins/heartbeat/manager.go @@ -0,0 +1,92 @@ +package heartbeat + +import ( + "fmt" + "sync" +) + +// HeartbeatManager maintains a named set of instructions that are injected +// into the heartbeat prompt. Each instruction is stored under a unique title. +type HeartbeatManager struct { + mu sync.Mutex + instructions map[string]string // title → body +} + +// NewHeartbeatManager returns an empty manager. +func NewHeartbeatManager() *HeartbeatManager { + return &HeartbeatManager{ + instructions: make(map[string]string), + } +} + +// AddInstruction inserts or replaces an instruction under the given title. +// The title must be non-empty. +func (m *HeartbeatManager) AddInstruction(title, instruction string) error { + if title == "" { + return fmt.Errorf("heartbeat manager: title must not be empty") + } + m.mu.Lock() + defer m.mu.Unlock() + m.instructions[title] = instruction + return nil +} + +// RemoveInstruction deletes the instruction with the given title. +// Returns an error when the title does not exist. +func (m *HeartbeatManager) RemoveInstruction(title string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.instructions[title]; !ok { + return fmt.Errorf("heartbeat manager: instruction %q not found", title) + } + delete(m.instructions, title) + return nil +} + +// ListInstructions returns all instruction titles in insertion-stable order +// (sorted alphabetically for determinism). +func (m *HeartbeatManager) ListInstructions() []string { + m.mu.Lock() + defer m.mu.Unlock() + titles := make([]string, 0, len(m.instructions)) + for t := range m.instructions { + titles = append(titles, t) + } + sortStrings(titles) + return titles +} + +// renderInstructions formats all instructions as markdown sections +// (## Title\nbody) for inclusion in the heartbeat prompt. +func (m *HeartbeatManager) renderInstructions() string { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.instructions) == 0 { + return "" + } + titles := make([]string, 0, len(m.instructions)) + for t := range m.instructions { + titles = append(titles, t) + } + sortStrings(titles) + + var out string + for _, t := range titles { + out += "## " + t + "\n" + m.instructions[t] + "\n\n" + } + return out +} + +// sortStrings sorts a string slice in place (stdlib sort is not imported to +// avoid an extra dependency — use a simple insertion sort for small slices). +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + key := s[i] + j := i - 1 + for j >= 0 && s[j] > key { + s[j+1] = s[j] + j-- + } + s[j+1] = key + } +} diff --git a/src/plugins/heartbeat/manager_test.go b/src/plugins/heartbeat/manager_test.go new file mode 100644 index 0000000..72cd574 --- /dev/null +++ b/src/plugins/heartbeat/manager_test.go @@ -0,0 +1,88 @@ +package heartbeat + +import ( + "testing" +) + +func TestAddInstruction_Basic(t *testing.T) { + m := NewHeartbeatManager() + if err := m.AddInstruction("Check logs", "Review error logs daily."); err != nil { + t.Fatalf("unexpected error: %v", err) + } + titles := m.ListInstructions() + if len(titles) != 1 || titles[0] != "Check logs" { + t.Fatalf("expected [Check logs], got %v", titles) + } +} + +func TestAddInstruction_EmptyTitle(t *testing.T) { + m := NewHeartbeatManager() + if err := m.AddInstruction("", "body"); err == nil { + t.Fatal("expected error for empty title") + } +} + +func TestAddInstruction_Overwrite(t *testing.T) { + m := NewHeartbeatManager() + _ = m.AddInstruction("Title", "first") + _ = m.AddInstruction("Title", "second") + titles := m.ListInstructions() + if len(titles) != 1 { + t.Fatalf("expected 1 entry after overwrite, got %d", len(titles)) + } +} + +func TestRemoveInstruction_Exists(t *testing.T) { + m := NewHeartbeatManager() + _ = m.AddInstruction("Task A", "do A") + if err := m.RemoveInstruction("Task A"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(m.ListInstructions()) != 0 { + t.Fatal("expected empty list after removal") + } +} + +func TestRemoveInstruction_NotFound(t *testing.T) { + m := NewHeartbeatManager() + if err := m.RemoveInstruction("Missing"); err == nil { + t.Fatal("expected error for missing title") + } +} + +func TestListInstructions_Sorted(t *testing.T) { + m := NewHeartbeatManager() + _ = m.AddInstruction("Zebra", "z") + _ = m.AddInstruction("Alpha", "a") + _ = m.AddInstruction("Mango", "m") + got := m.ListInstructions() + want := []string{"Alpha", "Mango", "Zebra"} + for i, v := range want { + if got[i] != v { + t.Fatalf("expected %v, got %v", want, got) + } + } +} + +func TestListInstructions_Empty(t *testing.T) { + m := NewHeartbeatManager() + if titles := m.ListInstructions(); len(titles) != 0 { + t.Fatalf("expected empty list, got %v", titles) + } +} + +func TestRenderInstructions_Format(t *testing.T) { + m := NewHeartbeatManager() + _ = m.AddInstruction("Daily", "Check inbox.") + out := m.renderInstructions() + if out != "## Daily\nCheck inbox.\n\n" { + t.Fatalf("unexpected render output: %q", out) + } +} + +func TestRenderInstructions_Empty(t *testing.T) { + m := NewHeartbeatManager() + if out := m.renderInstructions(); out != "" { + t.Fatalf("expected empty render, got %q", out) + } +} diff --git a/src/plugins/heartbeat/plugin.go b/src/plugins/heartbeat/plugin.go index 7a6ceb2..928fd78 100644 --- a/src/plugins/heartbeat/plugin.go +++ b/src/plugins/heartbeat/plugin.go @@ -8,6 +8,7 @@ import ( "github.com/thinktwiceco/agent-forge/src/agents" "github.com/thinktwiceco/agent-forge/src/core" "github.com/thinktwiceco/agent-forge/src/heartbeatack" + "github.com/thinktwiceco/agent-forge/src/llms" "github.com/thinktwiceco/agent-forge/src/plugins/registry" "github.com/thinktwiceco/agent-forge/src/queue" ) @@ -21,11 +22,12 @@ type HeartbeatPlugin struct { workingDir string inbox *queue.Queue cancel context.CancelFunc + manager *HeartbeatManager } // NewHeartbeatPlugin creates a plugin instance with the given configuration. func NewHeartbeatPlugin(cfg HeartbeatConfig) *HeartbeatPlugin { - return &HeartbeatPlugin{cfg: cfg} + return &HeartbeatPlugin{cfg: cfg, manager: NewHeartbeatManager()} } // Name implements core.Plugin. @@ -52,13 +54,19 @@ func (p *HeartbeatPlugin) SetInbox(q *queue.Queue) { p.inbox = q } +// Tools implements core.ToolProvider. +func (p *HeartbeatPlugin) Tools() []llms.Tool { + return []llms.Tool{newHeartbeatManagerTool(p.manager)} +} + // SystemPrompt implements core.PromptProvider. func (p *HeartbeatPlugin) SystemPrompt() string { return `[HEARTBEAT] - Heartbeat messages come from sender=heartbeat. - Read HEARTBEAT.md for the current checklist. - If nothing needs attention, reply exactly HEARTBEAT_OK. -- Do not repeat old tasks unless HEARTBEAT.md still lists them.` +- Do not repeat old tasks unless HEARTBEAT.md still lists them. +- Use the heartbeat_manager tool to add, remove, or list named recurring instructions.` } // Hooks implements core.HookProvider. diff --git a/src/plugins/heartbeat/tool.go b/src/plugins/heartbeat/tool.go new file mode 100644 index 0000000..3c38ed1 --- /dev/null +++ b/src/plugins/heartbeat/tool.go @@ -0,0 +1,82 @@ +package heartbeat + +import ( + "encoding/json" + "fmt" + + "github.com/thinktwiceco/agent-forge/src/core" + "github.com/thinktwiceco/agent-forge/src/llms" +) + +const heartbeatManagerTool = "heartbeat_manager" + +func newHeartbeatManagerTool(m *HeartbeatManager) llms.Tool { + return core.NewTool(core.ToolConfig{ + Name: heartbeatManagerTool, + Description: "Manage recurring instructions injected into every heartbeat prompt. Use to add, remove, or list named instructions.", + AdvanceDesc: `Actions: +- add_instruction: Registers a named instruction block (## Title) in the heartbeat context. + Required params: title (string), instruction (string). +- remove_instruction: Removes the instruction with the given title. + Required params: title (string). +- list_instructions: Returns all registered instruction titles as a JSON array. + No extra params required.`, + TroubleshootingInfo: `Troubleshooting: +- add_instruction fails when title is empty. +- remove_instruction fails when the title does not exist — call list_instructions first to confirm.`, + Parameters: []core.Parameter{ + { + Name: "action", + Type: "string", + Description: "Action to perform: 'add_instruction', 'remove_instruction', or 'list_instructions'", + Required: true, + }, + { + Name: "title", + Type: "string", + Description: "Instruction title used as a ## heading. Required for add_instruction and remove_instruction.", + Required: false, + }, + { + Name: "instruction", + Type: "string", + Description: "Instruction body placed under the title heading. Required for add_instruction.", + Required: false, + }, + }, + Handler: func(_ map[string]any, args map[string]any) llms.ToolReturn { + action, ok := args["action"].(string) + if !ok || action == "" { + return core.NewErrorResponse("action parameter is required and must be a string") + } + + switch action { + case "add_instruction": + title, _ := args["title"].(string) + instruction, _ := args["instruction"].(string) + if err := m.AddInstruction(title, instruction); err != nil { + return core.NewErrorResponse(fmt.Sprintf("add_instruction failed: %v", err)) + } + return core.NewSuccessResponse(fmt.Sprintf("instruction %q added", title)) + + case "remove_instruction": + title, _ := args["title"].(string) + if err := m.RemoveInstruction(title); err != nil { + return core.NewErrorResponse(fmt.Sprintf("remove_instruction failed: %v", err)) + } + return core.NewSuccessResponse(fmt.Sprintf("instruction %q removed", title)) + + case "list_instructions": + titles := m.ListInstructions() + b, err := json.Marshal(titles) + if err != nil { + return core.NewErrorResponse(fmt.Sprintf("list_instructions failed: %v", err)) + } + return core.NewEphemeralResponse(string(b)) + + default: + return core.NewErrorResponse(fmt.Sprintf("unknown action %q — valid actions: add_instruction, remove_instruction, list_instructions", action)) + } + }, + }) +} diff --git a/src/plugins/heartbeat/tool_test.go b/src/plugins/heartbeat/tool_test.go new file mode 100644 index 0000000..e53c7b2 --- /dev/null +++ b/src/plugins/heartbeat/tool_test.go @@ -0,0 +1,111 @@ +package heartbeat + +import ( + "encoding/json" + "strings" + "testing" +) + +func invoke(m *HeartbeatManager, action string, extra map[string]any) (string, bool) { + tool := newHeartbeatManagerTool(m) + args := map[string]any{"action": action} + for k, v := range extra { + args[k] = v + } + result := tool.Call(nil, args) + if result.Success() { + return result.Data(), false + } + return result.Error(), true +} + +func TestTool_AddInstruction_OK(t *testing.T) { + m := NewHeartbeatManager() + data, isErr := invoke(m, "add_instruction", map[string]any{ + "title": "Daily check", + "instruction": "Review the logs.", + }) + if isErr { + t.Fatalf("expected success, got error: %s", data) + } + if !strings.Contains(data, "Daily check") { + t.Fatalf("unexpected response: %s", data) + } +} + +func TestTool_AddInstruction_EmptyTitle(t *testing.T) { + m := NewHeartbeatManager() + _, isErr := invoke(m, "add_instruction", map[string]any{ + "title": "", + "instruction": "body", + }) + if !isErr { + t.Fatal("expected error for empty title") + } +} + +func TestTool_RemoveInstruction_OK(t *testing.T) { + m := NewHeartbeatManager() + _ = m.AddInstruction("Temp", "body") + data, isErr := invoke(m, "remove_instruction", map[string]any{"title": "Temp"}) + if isErr { + t.Fatalf("expected success, got error: %s", data) + } +} + +func TestTool_RemoveInstruction_NotFound(t *testing.T) { + m := NewHeartbeatManager() + _, isErr := invoke(m, "remove_instruction", map[string]any{"title": "Ghost"}) + if !isErr { + t.Fatal("expected error for unknown title") + } +} + +func TestTool_ListInstructions_Empty(t *testing.T) { + m := NewHeartbeatManager() + data, isErr := invoke(m, "list_instructions", nil) + if isErr { + t.Fatalf("expected success, got error: %s", data) + } + var titles []string + if err := json.Unmarshal([]byte(data), &titles); err != nil { + t.Fatalf("response is not valid JSON: %v — data: %s", err, data) + } + if len(titles) != 0 { + t.Fatalf("expected empty array, got %v", titles) + } +} + +func TestTool_ListInstructions_WithItems(t *testing.T) { + m := NewHeartbeatManager() + _ = m.AddInstruction("Beta", "b") + _ = m.AddInstruction("Alpha", "a") + data, isErr := invoke(m, "list_instructions", nil) + if isErr { + t.Fatalf("expected success, got error: %s", data) + } + var titles []string + if err := json.Unmarshal([]byte(data), &titles); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if len(titles) != 2 || titles[0] != "Alpha" || titles[1] != "Beta" { + t.Fatalf("unexpected titles: %v", titles) + } +} + +func TestTool_UnknownAction(t *testing.T) { + m := NewHeartbeatManager() + _, isErr := invoke(m, "fly_to_moon", nil) + if !isErr { + t.Fatal("expected error for unknown action") + } +} + +func TestTool_MissingAction(t *testing.T) { + m := NewHeartbeatManager() + tool := newHeartbeatManagerTool(m) + result := tool.Call(nil, map[string]any{}) + if result.Success() { + t.Fatal("expected error when action is missing") + } +} diff --git a/src/tools/telegram/actHealthStatus.go b/src/tools/telegram/actHealthStatus.go index 1d533ba..f0318d0 100644 --- a/src/tools/telegram/actHealthStatus.go +++ b/src/tools/telegram/actHealthStatus.go @@ -19,10 +19,11 @@ func actHealthStatus() llms.ToolReturn { ngrokLine, hasHTTPS := probeNgrokHealth() tokenLine := describeTelegramTokenEnv() secretLine := describeWebhookSecretEnv() + allowlistLine := describeAllowlistEnv() note := "\n(Env checks reflect the process environment; if you rely on a .env file, the host must load it — e.g. Localforge loads appDir/.env at startup.)" - out := ngrokLine + "\n" + tokenLine + "\n" + secretLine + note - if n := brainNudgesForTelegramHealth(ngrokLine, tokenLine, secretLine, hasHTTPS); n != "" { + out := ngrokLine + "\n" + tokenLine + "\n" + secretLine + "\n" + allowlistLine + note + if n := brainNudgesForTelegramHealth(ngrokLine, tokenLine, secretLine, allowlistLine, hasHTTPS); n != "" { out += n } @@ -61,8 +62,8 @@ func probeNgrokHealth() (line string, hasHTTPS bool) { return "ngrok local API: up (no HTTPS tunnel)", false } -func brainNudgesForTelegramHealth(ngrokLine, tokenLine, secretLine string, hasHTTPS bool) string { - if telegramWebhookHealthOK(ngrokLine, tokenLine, secretLine, hasHTTPS) { +func brainNudgesForTelegramHealth(ngrokLine, tokenLine, secretLine, allowlistLine string, hasHTTPS bool) string { + if telegramWebhookHealthOK(ngrokLine, tokenLine, secretLine, allowlistLine, hasHTTPS) { return "" } var b strings.Builder @@ -72,13 +73,19 @@ func brainNudgesForTelegramHealth(ngrokLine, tokenLine, secretLine string, hasHT if secretsEnvSet(tokenLine, secretLine) && !hasHTTPS { b.WriteString("[BRAIN]: TELEGRAM_BOT_TOKEN and WEBHOOK_SECRET_TELEGRAM are set but the ngrok HTTPS tunnel is not available. Ask the user whether you may run telegram action start_ngrok without asking for permission each time; if they answer, record that in short-term memory with save_short_term_memory.\n") } + if strings.Contains(allowlistLine, "not set") { + b.WriteString("[BRAIN]: TELEGRAM_ALLOWED_USER_IDS is not set — all incoming Telegram webhooks are blocked. Ask the user to set it to their Telegram username (e.g. TELEGRAM_ALLOWED_USER_IDS=@yourname) in .env.\n") + } return b.String() } -func telegramWebhookHealthOK(ngrokLine, tokenLine, secretLine string, hasHTTPS bool) bool { +func telegramWebhookHealthOK(ngrokLine, tokenLine, secretLine, allowlistLine string, hasHTTPS bool) bool { if strings.Contains(tokenLine, "missing") || strings.Contains(secretLine, "missing") { return false } + if strings.Contains(allowlistLine, "not set") { + return false + } if strings.Contains(ngrokLine, "down") { return false } @@ -102,3 +109,21 @@ func describeWebhookSecretEnv() string { } return "WEBHOOK_SECRET_TELEGRAM: missing" } + +func describeAllowlistEnv() string { + raw := strings.TrimSpace(os.Getenv("TELEGRAM_ALLOWED_USER_IDS")) + if raw == "" { + return "TELEGRAM_ALLOWED_USER_IDS: not set (all webhooks blocked)" + } + var usernames []string + for _, entry := range strings.Split(raw, ",") { + u := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(entry), "@")) + if u != "" { + usernames = append(usernames, "@"+u) + } + } + if len(usernames) == 0 { + return "TELEGRAM_ALLOWED_USER_IDS: not set (all webhooks blocked)" + } + return fmt.Sprintf("TELEGRAM_ALLOWED_USER_IDS: %s", strings.Join(usernames, ", ")) +} diff --git a/src/tools/telegram/tool_test.go b/src/tools/telegram/tool_test.go index 1a2e865..d8cdd85 100644 --- a/src/tools/telegram/tool_test.go +++ b/src/tools/telegram/tool_test.go @@ -307,6 +307,7 @@ func TestHealthStatus_AllGreen_NoBrainNudge(t *testing.T) { resetEnv(t) _ = os.Setenv("TELEGRAM_BOT_TOKEN", "t") _ = os.Setenv("WEBHOOK_SECRET_TELEGRAM", "s") + _ = os.Setenv("TELEGRAM_ALLOWED_USER_IDS", "@testuser") ngrokSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/src/tools/web/actClick.go b/src/tools/web/actClick.go index f7ce2a3..ef4c906 100644 --- a/src/tools/web/actClick.go +++ b/src/tools/web/actClick.go @@ -3,28 +3,33 @@ package web import ( "context" "fmt" + "time" "github.com/chromedp/chromedp" "github.com/thinktwiceco/agent-forge/src/core" "github.com/thinktwiceco/agent-forge/src/llms" ) +const clickPollInterval = 150 * time.Millisecond + +type clickElementResult struct { + OK bool `json:"ok"` + Reason string `json:"reason"` +} + // click handles the click action for the web browser tool. func (w *WebBrowser) click(agentContext map[string]any, args map[string]any) llms.ToolReturn { - // Extract selector (required) selector, ok := args["selector"].(string) if !ok || selector == "" { w.sessionManager.RecordOperation(false) return core.NewErrorResponse("selector parameter is required for click action and must be a non-empty string") } - // Validate selector if err := validateSelector(selector); err != nil { w.sessionManager.RecordOperation(false) return core.NewErrorResponse(fmt.Sprintf("invalid selector: %v", err)) } - // Extract wait_visible (optional, default: true) waitVisible := true if wv, ok := args["wait_visible"]; ok { if b, ok := wv.(bool); ok { @@ -32,42 +37,59 @@ func (w *WebBrowser) click(agentContext map[string]any, args map[string]any) llm } } - // Get browser context + timeoutDuration, err := parseTimeout(args, "timeout", defaultTimeout) + if err != nil { + w.sessionManager.RecordOperation(false) + return core.NewErrorResponse(err.Error()) + } + ctx, err := w.getOrCreateBrowser(agentContext) if err != nil { w.sessionManager.RecordOperation(false) return core.NewErrorResponse(fmt.Sprintf("failed to get browser context: %v", err)) } - // Create timeout context - timeoutCtx, timeoutCancel := context.WithTimeout(ctx, defaultTimeout) + timeoutCtx, timeoutCancel := context.WithTimeout(ctx, timeoutDuration) defer timeoutCancel() - // Build actions - actions := []chromedp.Action{ - chromedp.Click(selector, chromedp.ByQuery), - } + script := fmt.Sprintf(getScript("click_element"), selector, waitVisible) - // Optionally wait for element to be visible first - if waitVisible { - actions = append([]chromedp.Action{ - chromedp.WaitVisible(selector, chromedp.ByQuery), - }, actions...) - } + var last clickElementResult + for { + if err := timeoutCtx.Err(); err != nil { + w.sessionManager.RecordOperation(false) + msg := fmt.Sprintf("failed to click element '%s': %v", selector, err) + if last.Reason != "" { + msg = fmt.Sprintf("failed to click element '%s': %v (last probe: %s)", selector, err, last.Reason) + } + return core.NewErrorResponse(msg) + } - err = chromedp.Run(timeoutCtx, actions...) + var res clickElementResult + if err := chromedp.Run(timeoutCtx, chromedp.Evaluate(script, &res)); err != nil { + w.sessionManager.RecordOperation(false) + return core.NewErrorResponse(fmt.Sprintf("failed to click element '%s': %v", selector, err)) + } + last = res - if err != nil { - w.sessionManager.RecordOperation(false) - return core.NewErrorResponse(fmt.Sprintf("failed to click element '%s': %v", selector, err)) - } + if res.OK { + response := &clickResponse{ + Operation: "click", + Selector: selector, + Success: true, + } + w.sessionManager.RecordOperation(true) + return core.NewSuccessResponse(response.String()) + } - response := &clickResponse{ - Operation: "click", - Selector: selector, - Success: true, - } + if !waitVisible { + w.sessionManager.RecordOperation(false) + return core.NewErrorResponse(fmt.Sprintf("failed to click element '%s': %s (set wait_visible=true to wait, or fix selector)", selector, res.Reason)) + } - w.sessionManager.RecordOperation(true) - return core.NewSuccessResponse(response.String()) + if err := chromedp.Run(timeoutCtx, chromedp.Sleep(clickPollInterval)); err != nil { + w.sessionManager.RecordOperation(false) + return core.NewErrorResponse(fmt.Sprintf("failed to click element '%s': %v", selector, err)) + } + } } diff --git a/src/tools/web/scripts/click_element.js b/src/tools/web/scripts/click_element.js new file mode 100644 index 0000000..6e83f33 --- /dev/null +++ b/src/tools/web/scripts/click_element.js @@ -0,0 +1,67 @@ +(function (selector, requireVisible) { + function findDeep(root, sel) { + if (!root) return null; + try { + if (root.nodeType === 1 && root.matches && root.matches(sel)) return root; + } catch (e) {} + if (root.querySelector) { + var el = root.querySelector(sel); + if (el) return el; + } + var kids = root.children || root.childNodes; + if (!kids) return null; + for (var i = 0; i < kids.length; i++) { + var ch = kids[i]; + if (ch.nodeType !== 1) continue; + if (ch.shadowRoot) { + var f = findDeep(ch.shadowRoot, sel); + if (f) return f; + } + f = findDeep(ch, sel); + if (f) return f; + } + return null; + } + + function findEverywhere(doc, sel) { + var el = findDeep(doc, sel); + if (el) return el; + var iframes = doc.querySelectorAll("iframe"); + for (var j = 0; j < iframes.length; j++) { + try { + var idoc = iframes[j].contentDocument; + if (idoc) { + var g = findEverywhere(idoc, sel); + if (g) return g; + } + } catch (e) {} + } + return null; + } + + function visible(el) { + if (!el) return false; + var win = el.ownerDocument.defaultView || window; + var r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return false; + var st = win.getComputedStyle(el); + if (st.visibility === "hidden" || st.display === "none") return false; + if (parseFloat(st.opacity) === 0) return false; + return true; + } + + var el = findEverywhere(document, selector); + if (!el) return { ok: false, reason: "not_found" }; + if (requireVisible && !visible(el)) return { ok: false, reason: "not_visible" }; + + var win = el.ownerDocument.defaultView || window; + win.focus && win.focus(); + el.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" }); + + try { + el.click(); + } catch (e) { + return { ok: false, reason: "click_error" }; + } + return { ok: true, reason: "" }; +})(%q, %t); diff --git a/src/tools/web/tool.go b/src/tools/web/tool.go index 92a1aaf..3ea266a 100644 --- a/src/tools/web/tool.go +++ b/src/tools/web/tool.go @@ -47,7 +47,9 @@ func NewWebTool(dir string, defaultHeadless *bool) llms.Tool { case "click": return `click: Click an element by CSS selector. - Required: selector (string) — CSS selector for the element to click -- Optional: wait_visible (boolean, default true) — wait for element to be visible before clicking +- Optional: wait_visible (boolean, default true) — poll until the element exists and is visible (or timeout) +- Optional: timeout (number, default 60) — seconds; increase if the page is slow or the element appears late +- Implementation searches the main document, open shadow roots, and same-origin iframes (not cross-origin frames). - Optional: session (string) — browser session name` case "fill": return `fill: Clear and type text into a form input by CSS selector.