From 6ef568cea001f401a20d23d14f7b867694fce02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=98=8A?= Date: Fri, 24 Apr 2026 22:14:43 +0800 Subject: [PATCH 1/3] fix(gateway): make npm install timeout configurable via WHATSAPP_NPM_INSTALL_TIMEOUT Increase the default npm install timeout for WhatsApp bridge from 60s to 300s (5 minutes) to accommodate slower systems like Unraid NAS. Make it configurable via WHATSAPP_NPM_INSTALL_TIMEOUT environment variable for users who need even longer timeouts. Closes #14980 --- gateway/platforms/whatsapp.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index a82417a6015c5..7a163c71dee02 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -379,12 +379,15 @@ async def connect(self) -> bool: if not (bridge_dir / "node_modules").exists(): print(f"[{self.name}] Installing WhatsApp bridge dependencies...") try: + # Read timeout from environment variable, default to 300 seconds (5 minutes) + # to accommodate slower systems like Unraid NAS + npm_install_timeout = int(os.environ.get("WHATSAPP_NPM_INSTALL_TIMEOUT", "300")) install_result = subprocess.run( ["npm", "install", "--silent"], cwd=str(bridge_dir), capture_output=True, text=True, - timeout=60, + timeout=npm_install_timeout, ) if install_result.returncode != 0: print(f"[{self.name}] npm install failed: {install_result.stderr}") From 5dc3423af6b31742f3873328229ee7924a2f8ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=98=8A?= Date: Sat, 25 Apr 2026 16:49:01 +0800 Subject: [PATCH 2/3] fix(tools): mark patch tool conditionally required params as required - Add 'path', 'old_string', 'new_string', and 'patch' to required list - Update description to clarify mode-specific parameter requirements - This addresses issue where LLMs would omit these parameters because they were not marked as required in the schema, even though they are required depending on the mode Fixes #15524 --- tests/tools/test_file_tools.py | 37 ++++++++++++++++++++++++++++++++++ tools/file_tools.py | 12 +++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 5a215df14a0e0..979d309b09da2 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -323,4 +323,41 @@ def test_truncated_hint_with_nonzero_offset(self, mock_get): assert "offset=100" in raw +class TestPatchSchema: + """Tests for PATCH_SCHEMA to ensure required parameters are properly declared.""" + + def test_patch_schema_includes_all_required_params(self): + """PATCH_SCHEMA should include all parameters that are conditionally required.""" + from tools.file_tools import PATCH_SCHEMA + + # Verify schema structure + assert "parameters" in PATCH_SCHEMA + assert "required" in PATCH_SCHEMA["parameters"] + + # All parameters that are mode-specific should be in required list + required = PATCH_SCHEMA["parameters"]["required"] + assert "mode" in required + assert "path" in required + assert "old_string" in required + assert "new_string" in required + assert "patch" in required + + # replace_all is optional (has default), so it should NOT be in required + assert "replace_all" not in required + + def test_patch_schema_description_mentions_mode_specific_requirements(self): + """PATCH_SCHEMA description should explain mode-specific requirements.""" + from tools.file_tools import PATCH_SCHEMA + + description = PATCH_SCHEMA.get("description", "") + + # Description should mention mode-specific requirements + assert "mode-specific" in description.lower() or "IMPORTANT:" in description + + # Should mention both modes + assert "mode='replace'" in description + assert "mode='patch'" in description + + + diff --git a/tools/file_tools.py b/tools/file_tools.py index 609506c05e1be..385a0146d778a 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -908,18 +908,18 @@ def _check_file_reqs(): PATCH_SCHEMA = { "name": "patch", - "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nReplace mode (default): find a unique string and replace it.\nPatch mode: apply V4A multi-file patches for bulk changes.", + "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nIMPORTANT: Parameters are mode-specific:\n- For mode='replace': provide path, old_string, new_string (optionally replace_all)\n- For mode='patch': provide patch content", "parameters": { "type": "object", "properties": { "mode": {"type": "string", "enum": ["replace", "patch"], "description": "Edit mode: 'replace' for targeted find-and-replace, 'patch' for V4A multi-file patches", "default": "replace"}, - "path": {"type": "string", "description": "File path to edit (required for 'replace' mode)"}, - "old_string": {"type": "string", "description": "Text to find in the file (required for 'replace' mode). Must be unique in the file unless replace_all=true. Include enough surrounding context to ensure uniqueness."}, - "new_string": {"type": "string", "description": "Replacement text (required for 'replace' mode). Can be empty string to delete the matched text."}, + "path": {"type": "string", "description": "File path to edit (required when mode='replace')"}, + "old_string": {"type": "string", "description": "Text to find in file (required when mode='replace'). Must be unique in file unless replace_all=true. Include enough surrounding context to ensure uniqueness."}, + "new_string": {"type": "string", "description": "Replacement text (required when mode='replace'). Can be empty string to delete the matched text."}, "replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring a unique match (default: false)", "default": False}, - "patch": {"type": "string", "description": "V4A format patch content (required for 'patch' mode). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"} + "patch": {"type": "string", "description": "V4A format patch content (required when mode='patch'). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"} }, - "required": ["mode"] + "required": ["mode", "path", "old_string", "new_string", "patch"] } } From f127b65ac9bfedec716ddc15c906f2c6b7df6a17 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Tue, 5 May 2026 02:39:08 +0800 Subject: [PATCH 3/3] fix(agent): redact credentials in session capture logs Apply redact_sensitive_text to message content and system prompt before writing to session_*.json files. This prevents API keys, tokens, and other credentials from leaking into session logs that users may share for debugging or support. Fixes #19845 --- run_agent.py | 29 +++++++++- tests/run_agent/test_run_agent.py | 88 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 7af6d3ab0d654..ddbecce87eca4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -112,6 +112,7 @@ _detect_tool_failure, get_tool_emoji as _get_tool_emoji, ) +from agent.redact import redact_sensitive_text from agent.trajectory import ( convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, @@ -3656,6 +3657,26 @@ def _clean_session_content(content: str) -> str: content = re.sub(r'()\n+', r'\1\n', content) return content.strip() + @staticmethod + def _redact_message_content(content): + """Apply secret redaction to message content (string or list of parts).""" + if content is None: + return content + if isinstance(content, str): + return redact_sensitive_text(content) + if isinstance(content, list): + redacted = [] + for part in content: + if isinstance(part, dict): + part = dict(part) + if isinstance(part.get("text"), str): + part["text"] = redact_sensitive_text(part["text"]) + if isinstance(part.get("content"), str): + part["content"] = redact_sensitive_text(part["content"]) + redacted.append(part) + return redacted + return content + def _save_session_log(self, messages: List[Dict[str, Any]] = None): """ Save the full raw session to a JSON file. @@ -3673,12 +3694,16 @@ def _save_session_log(self, messages: List[Dict[str, Any]] = None): return try: - # Clean assistant content for session logs + # Clean assistant content for session logs, then redact credentials cleaned = [] for msg in messages: if msg.get("role") == "assistant" and msg.get("content"): msg = dict(msg) msg["content"] = self._clean_session_content(msg["content"]) + # Redact secrets from all message content to prevent credential + # leaks in session_*.json files (issue #19845) + msg = dict(msg) + msg["content"] = self._redact_message_content(msg.get("content")) cleaned.append(msg) # Guard: never overwrite a larger session log with fewer messages. @@ -3705,7 +3730,7 @@ def _save_session_log(self, messages: List[Dict[str, Any]] = None): "platform": self.platform, "session_start": self.session_start.isoformat(), "last_updated": datetime.now().isoformat(), - "system_prompt": self._cached_system_prompt or "", + "system_prompt": redact_sensitive_text(self._cached_system_prompt or ""), "tools": self.tools or [], "message_count": len(cleaned), "messages": cleaned, diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 9c54daffe5a6f..512836de42949 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3641,6 +3641,94 @@ def test_uses_shared_atomic_json_helper(self, agent, tmp_path): assert call_args.kwargs["default"] is str +class TestSaveSessionLogRedactsSecrets: + """Regression: session_*.json must not contain plaintext credentials (#19845).""" + + def test_redacts_api_key_in_message_content(self, agent, tmp_path): + agent.session_log_file = tmp_path / "session.json" + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "tool", + "content": 'Response: Authorization: Bearer sk-proj-abc123def456ghi789jkl012mno', + }, + ] + + with patch("run_agent.atomic_json_write") as mock_write: + agent._save_session_log(messages) + + payload = mock_write.call_args.args[1] + tool_msg = payload["messages"][1] + assert "sk-proj-abc123def456ghi789jkl012mno" not in tool_msg["content"] + assert "***" in tool_msg["content"] or "REDACTED" in tool_msg["content"] + + def test_redacts_api_key_in_user_message(self, agent, tmp_path): + agent.session_log_file = tmp_path / "session.json" + messages = [ + {"role": "user", "content": "My key is sk-ant-api03-abc123def456ghi789jkl012mno please use it"}, + ] + + with patch("run_agent.atomic_json_write") as mock_write: + agent._save_session_log(messages) + + payload = mock_write.call_args.args[1] + user_msg = payload["messages"][0] + assert "sk-ant-api03-abc123def456ghi789jkl012mno" not in user_msg["content"] + + def test_redacts_system_prompt_credentials(self, agent, tmp_path): + agent.session_log_file = tmp_path / "session.json" + agent._cached_system_prompt = "Use key sk-proj-realkey1234567890123456 for API calls" + messages = [{"role": "user", "content": "test"}] + + with patch("run_agent.atomic_json_write") as mock_write: + agent._save_session_log(messages) + + payload = mock_write.call_args.args[1] + assert "sk-proj-realkey1234567890123456" not in payload["system_prompt"] + + def test_redacts_list_type_content(self, agent, tmp_path): + agent.session_log_file = tmp_path / "session.json" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Key: gsk_abc123def456ghi789jkl012mno"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ], + }, + ] + + with patch("run_agent.atomic_json_write") as mock_write: + agent._save_session_log(messages) + + payload = mock_write.call_args.args[1] + parts = payload["messages"][0]["content"] + text_part = parts[0] + assert "gsk_abc123def456ghi789jkl012mno" not in text_part["text"] + # Image URL should be preserved + assert parts[1]["image_url"]["url"].startswith("data:image") + + def test_no_redaction_when_disabled(self, agent, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_REDACT_SECRETS", "0") + # Re-import to pick up the env change + import importlib + import agent.redact as redact_mod + old_val = redact_mod._REDACT_ENABLED + redact_mod._REDACT_ENABLED = False + try: + agent.session_log_file = tmp_path / "session.json" + messages = [{"role": "user", "content": "Key: sk-proj-test1234567890123456"}] + + with patch("run_agent.atomic_json_write") as mock_write: + agent._save_session_log(messages) + + payload = mock_write.call_args.args[1] + # When disabled, content passes through unchanged + assert "sk-proj-test1234567890123456" in payload["messages"][0]["content"] + finally: + redact_mod._REDACT_ENABLED = old_val + + # =================================================================== # Anthropic adapter integration fixes # ===================================================================