Redacting credentials leaked by browser_type tool
NousResearch/hermes-agent · cluster 10520 · agent
Data as of
Nothing open on GitHub. Fix merged in #52850. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #52850. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #52850. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs concern a security problem where text typed via a browser automation tool exposes credentials in output or display surfaces. The work involves identifying and redacting sensitive input across various display contexts.
Open issues0
Open PRs0
Fixes on record
-
#52850 fix(browser): redact recognized secrets in browser_type display surfaces
files:
agent/display.py,agent/tool_executor.py,scripts/release.py,tests/agent/test_display.py,tests/run_agent/test_run_agent.py,tests/tools/test_browser_camofox.py,tests/tools/test_browser_type_redaction.py,tools/browser_camofox.py,tools/browser_tool.pyShow the diff
-
agent/display.py@@ -16,6 +16,7 @@ from typing import Any from utils import safe_json_loads +from agent.redact import redact_sensitive_text from agent.tool_result_classification import file_mutation_result_landed # ANSI escape codes for coloring tool failure indicators @@ -339,6 +340,62 @@ def _read_file_line_label(args: dict) -> str: return f"L{offset}-{offset + limit - 1}" +def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any: + """Apply secret redaction to browser_type text in display-facing payloads. + + Backends sometimes echo the attempted input in error strings or fallback + metadata. When the raw typed value contains a recognizable secret (API + key, token, JWT, etc.) the redacted form differs from the raw value, so we + replace every occurrence of the raw value with its redacted form before a + browser_type result reaches logs, callbacks, the model, or chat history. + + Normal typed text (search queries, addresses, form fields) matches no + secret pattern, so it passes through unchanged and stays readable. + + Redaction is forced here regardless of the global ``security.redact_secrets`` + preference: a typed credential leaking into chat history is a security + boundary, not mere log hygiene. + """ + if typed_text is None: + return value + needle = str(typed_text) + if needle == "": + return value + redacted = redact_sensitive_text(needle, force=True) + if redacted == needle: + # Nothing secret-looking in the typed text; leave payload untouched. + return value + if isinstance(value, str): + return value.replace(needle, redacted) + if isinstance(value, dict): + return { + key: redact_browser_typed_text_for_display(item, typed_text) + for key, item in value.items() + } + if isinstance(value, list): + return [redact_browser_typed_text_for_display(item, typed_text) for item in value] + if isinstance(value, tuple): + return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value) + return value + + +def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None: + """Return a copy of tool args safe for logs/progress UI. + + For ``browser_type`` the ``text`` argument is run through the same + secret-pattern redactor used for logs. Recognizable credentials (API + keys, tokens) are masked before the value reaches tool progress + notifications; normal typed text is left intact for debuggability. + """ + if not isinstance(args, dict): + return args + if tool_name == "browser_type" and isinstance(args.get("text"), str): + safe_args = dict(args) + safe_args["text"] = redact_sensitive_text(args["text"], force=True) + return safe_args + return args + + def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]: if not isinstance(tasks, list): return 0, [] @@ -362,6 +419,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - max_len = _tool_preview_max_len if not args: return None + args = redact_tool_args_for_display(tool_name, args) or args primary_args = { "terminal": "command", "web_search": "query", "web_extract": "urls", "read_file": "path", "write_file": "path", "patch": "path", @@ -1085,6 +1143,7 @@ def get_cute_tool_message( When *result* is provided the line is checked for failure indicators. Failed tool calls get a red prefix and an informational suffix. """ + args = redact_tool_args_for_display(tool_name, args) or args dur = f"{duration:.1f}s" is_failure, failure_suffix = _detect_tool_failure(tool_name, result) skin_prefix = get_skin_tool_prefix() -
agent/tool_executor.py@@ -26,6 +26,7 @@ build_tool_preview as _build_tool_preview, get_cute_tool_message as _get_cute_tool_message_impl, get_tool_emoji as _get_tool_emoji, + redact_tool_args_for_display as _redact_tool_args_for_display, _detect_tool_failure, ) from agent.tool_guardrails import ToolGuardrailDecision @@ -469,10 +470,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}") for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1): - args_str = json.dumps(args, ensure_ascii=False) + display_args = _redact_tool_args_for_display(name, args) or args + args_str = json.dumps(display_args, ensure_ascii=False) if agent.verbose_logging: - print(f" 📞 Tool {i}: {name}({list(args.keys())})") - print(agent._wrap_verbose("Args: ", json.dumps(args, indent=2, ensure_ascii=False))) + print(f" 📞 Tool {i}: {name}({list(display_args.keys())})") + print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False))) else: args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}") @@ -482,8 +484,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe continue if agent.tool_progress_callback: try: - preview = _build_tool_preview(name, args) - agent.tool_progress_callback("tool.started", name, preview, args) + display_args = _redact_tool_args_for_display(name, args) or args + preview = _build_tool_preview(name, display_args) + agent.tool_progress_callback("tool.started", name, preview, display_args) except Exception as cb_err: logging.debug(f"Tool progress callback error: {cb_err}") @@ -492,7 +495,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe continue if agent.tool_start_callback: try: - agent.tool_start_callback(tc.id, name, args) + display_args = _redact_tool_args_for_display(name, args) or args + agent.tool_start_callback(tc.id, name, display_args) except Exception as cb_err: logging.debug(f"Tool start callback error: {cb_err}") @@ -792,7 +796,8 @@ def _run_tool(index, tool_call, function_name, function_args, middleware_trace): if not blocked and agent.tool_complete_callback: try: - agent.tool_complete_callback(tc.id, name, args, function_result) + display_args = _redact_tool_args_for_display(name, args) or args + agent.tool_complete_callback(tc.id, name, display_args, function_result) except Exception as cb_err: logging.debug(f"Tool complete callback error: {cb_err}") @@ -954,10 +959,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe agent._iters_since_skill = 0 if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": - args_str = json.dumps(function_args, ensure_ascii=False) + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + args_str = json.dumps(display_args, ensure_ascii=False) if agent.verbose_logging: - print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})") - print(agent._wrap_verbose("Args: ", json.dumps(function_args, indent=2, ensure_ascii=False))) + print(f" 📞 Tool {i}: {function_name}({list(display_args.keys())})") + print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False))) else: args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}") @@ -978,14 +984,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe if not _execution_blocked and agent.tool_progress_callback: try: - preview = _build_tool_preview(function_name, function_args) - agent.tool_progress_callback("tool.started", function_name, preview, function_args) + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + preview = _build_tool_preview(function_name, display_args) + agent.tool_progress_callback("tool.started", function_name, preview, display_args) except Exception as cb_err: logging.debug(f"Tool progress callback error: {cb_err}") if not _execution_blocked and agent.tool_start_callback: try: - agent.tool_start_callback(tool_call.id, function_name, function_args) + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + agent.tool_start_callback(tool_call.id, function_name, display_args) except Exception as cb_err: logging.debug(f"Tool start callback error: {cb_err}") @@ -1215,7 +1223,8 @@ def _execute(next_args: dict) -> Any: if agent._should_emit_quiet_tool_messages(): face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) - preview = _build_tool_preview(function_name, function_args) or function_name + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + preview = _build_tool_preview(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _ce_result = None @@ -1248,7 +1257,8 @@ def _execute(next_args: dict) -> Any: if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner(): face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) - preview = _build_tool_preview(function_name, function_args) or function_name + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + preview = _build_tool_preview(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _mem_result = None @@ -1279,7 +1289,8 @@ def _execute(next_args: dict) -> Any: if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner(): face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) - preview = _build_tool_preview(function_name, function_args) or function_name + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + preview = _build_tool_preview(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _spinner_result = None @@ -1441,7 +1452,8 @@ def _execute(next_args: dict) -> Any: if not _execution_blocked and agent.tool_complete_callback: try: - agent.tool_complete_callback(tool_call.id, function_name, function_args, function_result) + display_args = _redact_tool_args_for_display(function_name, function_args) or function_args + agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result) except Exception as cb_err: logging.debug(f"Tool complete callback error: {cb_err}") -
scripts/release.py@@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "rebel0789", # PR #47308 salvage (redact browser_type typed text across display surfaces; #47197) "[redacted:email]": "agt-user", # PR #48496 salvage (telegram CLOSE-WAIT polling heartbeat, #48495) "[redacted:email]": "DavidMetcalfe", # PR #52272 salvage (route reasoning-model thinking-timeouts to timeout not context_overflow + reasoning-specific guidance; #52271) "[redacted:email]": "herbalizer404", # PR #49076 + #51835 salvage (auxiliary compression fallback: 403/session-usage payment errors + honor fallback chain when aux provider auth unavailable) -
tests/agent/test_display.py@@ -9,6 +9,7 @@ capture_local_edit_snapshot, extract_edit_diff, get_cute_tool_message, + redact_tool_args_for_display, set_tool_preview_max_len, _render_inline_unified_diff, _summarize_rendered_diff_sections, @@ -86,6 +87,35 @@ def test_read_file_preview_includes_requested_line_range(self): result = build_tool_preview("read_file", {"path": "./package.json", "offset": 1, "limit": 5}) assert result == "package.json L1-5" + def test_browser_type_preview_redacts_api_key(self): + secret = "[redacted:secret-assignment]" + result = build_tool_preview("browser_type", {"ref": "@e3", "text": secret}) + assert result is not None + assert secret not in result + assert "sk-pro" in result and "..." in result + + def test_browser_type_preview_keeps_normal_text(self): + text = "hello world search query" + result = build_tool_preview("browser_type", {"ref": "@e3", "text": text}) + assert result is not None + assert text in result + + def test_browser_type_display_args_redact_api_key(self): + secret = "[redacted:secret-assignment]" + safe_args = redact_tool_args_for_display( + "browser_type", {"ref": "@e3", "text": secret} + ) + assert secret not in str(safe_args) + assert safe_args["ref"] == "@e3" + assert safe_args["text"].startswith("ghp_AB") + + def test_browser_type_display_args_keep_normal_text(self): + text = "my_normal_password_123" + safe_args = redact_tool_args_for_display( + "browser_type", {"ref": "@e3", "text": text} + ) + assert safe_args == {"ref": "@e3", "text": text} + def test_unknown_tool_with_fallback_key(self): """Unknown tool but with a recognized fallback key should still preview.""" result = build_tool_preview("custom_tool", {"query": "test query"}) @@ -242,6 +272,29 @@ def test_delegate_task_batch_message_includes_goals(self): ) assert "2x: Review PR A | Review PR B" in line + def test_browser_type_cute_message_redacts_api_key(self): + secret = "[redacted:secret-assignment]" + line = get_cute_tool_message( + "browser_type", + {"ref": "@password", "text": secret}, + 0.1, + result='{"success": true, "typed": "sk-pro...EFGH"}', + ) + + assert secret not in line + assert "sk-pro" in line + + def test_browser_type_cute_message_keeps_normal_text(self): + text = "hello world" + line = get_cute_tool_message( + "browser_type", + {"ref": "@search", "text": text}, + 0.1, + result='{"success": true, "typed": "hello world"}', + ) + + assert text in line + class TestEditDiffPreview: def test_extract_edit_diff_for_patch(self): -
tests/run_agent/test_run_agent.py@@ -2589,6 +2589,30 @@ def test_sequential_tool_callbacks_fire_in_order(self, agent): assert starts == [("c1", "web_search", {"query": "hello"})] assert completes == [("c1", "web_search", {"query": "hello"}, '{"success": true}')] + def test_sequential_browser_type_callbacks_redact_api_key(self, agent): + secret = "[redacted:secret-assignment]" + tool_call = _mock_tool_call( + name="browser_type", + arguments=json.dumps({"ref": "@apikey", "text": secret}), + call_id="c-secret", + ) + mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call]) + messages = [] + starts = [] + completes = [] + progress = [] + agent.tool_start_callback = lambda tool_call_id, function_name, function_args: starts.append((tool_call_id, function_name, function_args)) + agent.tool_complete_callback = lambda tool_call_id, function_name, function_args, function_result: completes.append((tool_call_id, function_name, function_args, function_result)) + agent.tool_progress_callback = lambda event, name, preview, args, **kw: progress.append((event, name, preview, args)) + + with patch("run_agent.handle_function_call", return_value='{"success": true, "typed": "sk-pro...EFGH"}'): + agent._execute_tool_calls_sequential(mock_msg, messages, "task-1") + + assert starts[0][2]["text"].startswith("sk-pro") + assert completes[0][2]["text"].startswith("sk-pro") + assert progress[0][2].startswith("sk-pro") + assert secret not in repr(starts + completes + progress) + def test_concurrent_tool_callbacks_fire_for_each_tool(self, agent): tc1 = _mock_tool_call(name="web_search", arguments='{"query":"one"}', call_id="c1") tc2 = _mock_tool_call(name="web_search", arguments='{"query":"two"}', call_id="c2") @@ -2610,6 +2634,30 @@ def test_concurrent_tool_callbacks_fire_for_each_tool(self, agent): assert {entry[0] for entry in completes} == {"c1", "c2"} assert {entry[3] for entry in completes} == {'{"id":1}', '{"id":2}'} + def test_concurrent_browser_type_callbacks_redact_api_key(self, agent): + secret = "[redacted:secret-assignment]" + tc = _mock_tool_call( + name="browser_type", + arguments=json.dumps({"ref": "@apikey", "text": secret}), + call_id="c-secret", + ) + mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) + messages = [] + starts = [] + completes = [] + progress = [] + agent.tool_start_callback = lambda tool_call_id, function_name, function_args: starts.append((tool_call_id, function_name, function_args)) + agent.tool_complete_callback = lambda tool_call_id, function_name, function_args, function_result: completes.append((tool_call_id, function_name, function_args, function_result)) + agent.tool_progress_callback = lambda event, name, preview, args, **kw: progress.append((event, name, preview, args)) + + with patch("run_agent.handle_function_call", return_value='{"success": true, "typed": "sk-pro...EFGH"}'): + agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1") + + assert starts[0][2]["text"].startswith("sk-pro") + assert completes[0][2]["text"].startswith("sk-pro") + assert progress[0][2].startswith("sk-pro") + assert secret not in repr(starts + completes + progress) + def test_invoke_tool_handles_agent_level_tools(self, agent): """_invoke_tool should handle todo tool directly.""" with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}') as mock_todo: -
tests/tools/test_browser_camofox.py@@ -235,8 +235,39 @@ def test_type(self, mock_post, monkeypatch): mock_post.return_value = _mock_response(json_data={"ok": True}) result = json.loads(camofox_type("@e3", "hello world", task_id="t5")) assert result["success"] is True + # Normal text is left readable. assert result["typed"] == "hello world" + @patch("tools.browser_camofox.requests.post") + def test_type_redacts_api_key(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + monkeypatch.setenv("HERMES_REDACT_SECRETS", "true") + mock_post.return_value = _mock_response(json_data={"tabId": "tab5b", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t5b") + + secret = "[redacted:secret-assignment]" + mock_post.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_type("@apikey", secret, task_id="t5b")) + assert result["success"] is True + assert secret not in json.dumps(result) + assert result["typed"].startswith("sk-pro") + + @patch("tools.browser_camofox.requests.post") + def test_type_failure_redacts_api_key(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + monkeypatch.setenv("HERMES_REDACT_SECRETS", "true") + mock_post.return_value = _mock_response(json_data={"tabId": "tab5c", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t5c") + + secret = "[redacted:secret-assignment]" + mock_post.side_effect = RuntimeError(f"camofox failed while typing {secret}") + raw_result = camofox_type("@apikey", secret, task_id="t5c") + result = json.loads(raw_result) + + assert result["success"] is False + assert secret not in raw_result + assert "sk-pro" in raw_result + @patch("tools.browser_camofox.requests.post") def test_scroll(self, mock_post, monkeypatch): monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") -
tests/tools/test_browser_type_redaction.py@@ -0,0 +1,74 @@ +"""Regression tests for browser_type display redaction. + +Typed text is passed through the same secret-pattern redactor used for logs: +recognizable credentials (API keys, tokens) are masked in display-facing +output, while normal typed text is left intact. The raw value is always sent +to the browser backend regardless. +""" + +import json +from unittest.mock import patch + +from tools.browser_tool import browser_type + + +def test_browser_type_redacts_api_key_in_output(monkeypatch): + monkeypatch.delenv("CAMOFOX_URL", raising=False) + monkeypatch.delenv("BROWSER_CDP_URL", raising=False) + monkeypatch.setenv("HERMES_REDACT_SECRETS", "true") + secret = "[redacted:secret-assignment]" + + with patch( + "tools.browser_tool._run_browser_command", + return_value={"success": True}, + ) as mock_run: + result = json.loads(browser_type("@apikey", secret, task_id="redaction-test")) + + assert result["success"] is True + assert secret not in json.dumps(result) + assert result["typed"].startswith("sk-pro") + # Raw secret still typed into the page. + mock_run.assert_called_once() + assert mock_run.call_args.args[2] == ["@apikey", secret] + + +def test_browser_type_keeps_normal_text_in_output(monkeypatch): + monkeypatch.delenv("CAMOFOX_URL", raising=False) + monkeypatch.delenv("BROWSER_CDP_URL", raising=False) + monkeypatch.setenv("HERMES_REDACT_SECRETS", "true") + text = "hello world search query" + + with patch( + "tools.browser_tool._run_browser_command", + return_value={"success": True}, + ) as mock_run: + result = json.loads(browser_type("@search", text, task_id="redaction-test")) + + assert result["success"] is True + assert result["typed"] == text + mock_run.assert_called_once() + assert mock_run.call_args.args[2] == ["@search", text] + + +def test_browser_type_failure_redacts_api_key_in_error(monkeypatch): + monkeypatch.delenv("CAMOFOX_URL", raising=False) + monkeypatch.delenv("BROWSER_CDP_URL", raising=False) + monkeypatch.setenv("HERMES_REDACT_SECRETS", "true") + secret = "[redacted:secret-assignment]" + + with patch( + "tools.browser_tool._run_browser_command", + return_value={ + "success": False, + "error": f"backend failed while typing {secret}", + "fallback_warning": f"chrome fallback also saw {secret}", + }, + ) as mock_run: + raw_result = browser_type("@apikey", secret, task_id="redaction-test") + result = json.loads(raw_result) + + assert result["success"] is False + assert secret not in raw_result + assert "sk-pro" in raw_result + mock_run.assert_called_once() + assert mock_run.call_args.args[2] == ["@apikey", secret] -
tools/browser_camofox.py@@ -562,13 +562,28 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str: f"/tabs/{session['tab_id']}/type", {"userId": session["user_id"], "ref": clean_ref, "text": text}, ) - return json.dumps({ + from agent.display import ( + redact_browser_typed_text_for_display, + redact_tool_args_for_display, + ) + + display_text = (redact_tool_args_for_display("browser_type", {"text": text}) or {})["text"] + + response = { "success": True, - "typed": text, + # Match browser_tool.browser_type: run typed text through the + # secret-pattern redactor so API keys / tokens don't leak into + # tool progress or chat history. The raw text is still typed into + # the page; only the returned display value is redacted. + "typed": display_text, "element": clean_ref, - }) + } + response = redact_browser_typed_text_for_display(response, text) + return json.dumps(response) except Exception as e: - return tool_error(str(e), success=False) + from agent.display import redact_browser_typed_text_for_display + + return tool_error(redact_browser_typed_text_for_display(str(e), text), success=False) def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str: -
tools/browser_tool.py@@ -2758,19 +2758,34 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str: # Use fill command (clears then types) result = _run_browser_command(effective_task_id, "fill", [ref, text]) + from agent.display import ( + redact_browser_typed_text_for_display, + redact_tool_args_for_display, + ) + + display_text = (redact_tool_args_for_display("browser_type", {"text": text}) or {})["text"] + if result.get("success"): response = { "success": True, - "typed": text, + # Run typed text through the secret-pattern redactor so API keys / + # tokens don't leak into tool progress or chat history. Normal + # text passes through unchanged. The raw value was already sent + # to the browser command above. + "typed": display_text, "element": ref } - return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False) + response = _copy_fallback_warning(response, result) + response = redact_browser_typed_text_for_display(response, text) + return json.dumps(response, ensure_ascii=False) else: response = { "success": False, "error": result.get("error", f"Failed to type into {ref}") } - return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False) + response = _copy_fallback_warning(response, result) + response = redact_browser_typed_text_for_display(response, text) + return json.dumps(response, ensure_ascii=False) def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
-
2 PRs closed without merging
From the close comments: merged via another change 1 · other 1
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #52850
- Closed issues
- 2
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 26 Jun 2026