diff --git a/src/reverse_api/auto_engineer.py b/src/reverse_api/auto_engineer.py index 35fed9f..30ad314 100644 --- a/src/reverse_api/auto_engineer.py +++ b/src/reverse_api/auto_engineer.py @@ -65,6 +65,7 @@ class AutoPromptEngineer(Protocol): agent_provider: str mcp_run_id: str headless: bool + start_url: str | None prompt: str scripts_dir: Path har_path: Path @@ -91,6 +92,7 @@ def __init__( # `headless` is auto-engineer specific: for MCP providers it configures the MCP # server's browser launch; for `agent-browser` it only adjusts prompt wording. headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) har_dir = get_har_dir(run_id, output_dir) har_path = har_dir / "recording.har" @@ -105,6 +107,7 @@ def __init__( self.mcp_run_id = run_id self.agent_provider = agent_provider self.headless = headless + self.start_url = start_url def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]: """Build (system_prompt, user_message) for auto mode. @@ -145,6 +148,7 @@ def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]: template_kwargs = { "prompt": self.prompt, "scripts_dir": str(self.scripts_dir), + "start_url_block": "\n" + load("auto/start_url", start_url=self.start_url) if self.start_url else "", } if self.agent_provider != "chrome-mcp": template_kwargs["har_path"] = str(self.har_path) @@ -367,6 +371,7 @@ class OpenCodeAutoEngineer(OpenCodeEngineer): def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agent_provider: str = "auto", **kwargs: Any) -> None: """Initialize OpenCode-backed agent engineer.""" headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) har_dir = get_har_dir(run_id, output_dir) har_path = har_dir / "recording.har" @@ -381,6 +386,7 @@ def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agen self.agent_provider = agent_provider self.mcp_name: str | None = None self.headless = headless + self.start_url = start_url def _get_active_prompts(self) -> tuple[str, str]: return ClaudeAutoEngineer._build_auto_prompts(self) @@ -618,6 +624,7 @@ def __init__( from .copilot_engineer import CopilotEngineer headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) har_dir = get_har_dir(run_id, output_dir) har_path = har_dir / "recording.har" @@ -632,6 +639,7 @@ def __init__( self.mcp_run_id = run_id self.agent_provider = agent_provider self.headless = headless + self.start_url = start_url def start_sync(self) -> None: self._engineer.start_sync() @@ -658,6 +666,7 @@ async def analyze_and_generate(self) -> dict[str, Any] | None: eng.agent_provider = self.agent_provider eng.mcp_run_id = self.mcp_run_id eng.headless = self.headless + eng.start_url = self.start_url if self.agent_provider == "agent-browser": ab_setup = ensure_agent_browser_runtime() diff --git a/src/reverse_api/browser.py b/src/reverse_api/browser.py index 7df1d8e..1249c06 100644 --- a/src/reverse_api/browser.py +++ b/src/reverse_api/browser.py @@ -15,7 +15,7 @@ from rich.console import Console from rich.status import Status -from .utils import get_har_dir, get_timestamp +from .utils import get_har_dir, get_timestamp, normalize_url console = Console() @@ -233,15 +233,6 @@ def _inject_stealth(self, page: Page) -> None: """Inject stealth scripts into page before any other scripts run.""" page.add_init_script(STEALTH_JS) - @staticmethod - def _normalize_url(url: str) -> str: - """Prepend https:// when the user typed a bare host (e.g. - ``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs.""" - url = url.strip() - if url and "://" not in url: - return "https://" + url - return url - def _abort_playwright(self) -> None: """Quietly tear down the browser/Playwright after a failed start. @@ -440,7 +431,7 @@ def start(self, start_url: str | None = None) -> Path: console.print() if start_url: - start_url = self._normalize_url(start_url) + start_url = normalize_url(start_url) self._playwright = sync_playwright().start() diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index 575318a..174098c 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -41,6 +41,7 @@ get_history_path, get_messages_path, get_timestamp, + normalize_url, resolve_run, ) @@ -1222,6 +1223,9 @@ def agent( Agent mode runs an integrated capture + reverse-engineering pipeline. """ + if url: + url = normalize_url(url) + if dry_run: # --dry-run is fundamentally about emitting machine-parseable validation # results, so it implies --json (and therefore --no-interactive). @@ -1539,6 +1543,9 @@ def run_auto_capture( if not prompt: raise click.UsageError("a prompt is required") + if url: + url = normalize_url(url) + if agent_provider == "chrome-mcp" and not headless: from .chrome_mcp import check_auto_connect @@ -1636,6 +1643,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, ) elif sdk == "copilot": from .auto_engineer import CopilotAutoEngineer @@ -1651,6 +1659,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, ) elif sdk == "cursor": from .cursor_engineer import CursorAutoEngineer @@ -1667,6 +1676,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, cursor_model=model or config_manager.get("cursor_model", "composer-2.5"), cursor_web_search=bool(config_manager.get("cursor_web_search", True)), cursor_setting_sources=_cursor_src, @@ -1685,6 +1695,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, ) # CopilotAutoEngineer wraps the CopilotEngineer that owns the session. diff --git a/src/reverse_api/copilot_engineer.py b/src/reverse_api/copilot_engineer.py index 7ba5f40..7f10a57 100644 --- a/src/reverse_api/copilot_engineer.py +++ b/src/reverse_api/copilot_engineer.py @@ -28,6 +28,7 @@ class CopilotEngineer(BaseEngineer): agent_provider: str mcp_run_id: str headless: bool + start_url: str | None def __init__( self, diff --git a/src/reverse_api/cursor_engineer.py b/src/reverse_api/cursor_engineer.py index 6ea6fbf..a6829d5 100644 --- a/src/reverse_api/cursor_engineer.py +++ b/src/reverse_api/cursor_engineer.py @@ -497,6 +497,7 @@ def __init__( **kwargs: Any, ) -> None: headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) from .utils import get_har_dir har_dir = get_har_dir(run_id, output_dir) @@ -512,6 +513,7 @@ def __init__( self.mcp_run_id = run_id self.agent_provider = agent_provider self.headless = headless + self.start_url = start_url def _cursor_mcp_servers(self) -> dict[str, Any]: if self.agent_provider == "agent-browser": diff --git a/src/reverse_api/prompts/auto/start_url.md b/src/reverse_api/prompts/auto/start_url.md new file mode 100644 index 0000000..0a3c1c6 --- /dev/null +++ b/src/reverse_api/prompts/auto/start_url.md @@ -0,0 +1,5 @@ + +{start_url} + + +Start the mission by opening this URL. diff --git a/src/reverse_api/prompts/auto/user_agent_browser.md b/src/reverse_api/prompts/auto/user_agent_browser.md index dec2b9a..4139594 100644 --- a/src/reverse_api/prompts/auto/user_agent_browser.md +++ b/src/reverse_api/prompts/auto/user_agent_browser.md @@ -1,7 +1,7 @@ {prompt} - +{start_url_block} {scripts_dir} @@ -59,7 +59,7 @@ Start HAR recording **before** navigation and confirm it succeeded (abort if thi export AGENT_BROWSER_SESSION="{agent_browser_session}" {agent_browser_shell} network har start # verify exit code 0; if non-zero, diagnose with `{agent_browser_shell} doctor` and stop -{agent_browser_shell} open https://example.com +{agent_browser_shell} open "" # the start URL, or the site the mission names {agent_browser_shell} snapshot -i --json # … iterate … ``` diff --git a/src/reverse_api/prompts/auto/user_chrome_mcp.md b/src/reverse_api/prompts/auto/user_chrome_mcp.md index d32bc54..c2024eb 100644 --- a/src/reverse_api/prompts/auto/user_chrome_mcp.md +++ b/src/reverse_api/prompts/auto/user_chrome_mcp.md @@ -1,7 +1,7 @@ {prompt} - +{start_url_block} {scripts_dir} diff --git a/src/reverse_api/prompts/auto/user_playwright.md b/src/reverse_api/prompts/auto/user_playwright.md index f9c9a22..d341331 100644 --- a/src/reverse_api/prompts/auto/user_playwright.md +++ b/src/reverse_api/prompts/auto/user_playwright.md @@ -1,7 +1,7 @@ {prompt} - +{start_url_block} {scripts_dir} diff --git a/src/reverse_api/utils.py b/src/reverse_api/utils.py index 3915208..142cf7d 100644 --- a/src/reverse_api/utils.py +++ b/src/reverse_api/utils.py @@ -64,6 +64,15 @@ ) +def normalize_url(url: str) -> str: + """Prepend https:// when the user typed a bare host (e.g. + ``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs.""" + url = url.strip() + if url and "://" not in url: + return "https://" + url + return url + + def build_sdk_env() -> dict[str, str]: """Environment overrides for Claude Agent SDK subprocesses. diff --git a/tests/test_auto_engineer.py b/tests/test_auto_engineer.py index d2ef7db..c5ac131 100644 --- a/tests/test_auto_engineer.py +++ b/tests/test_auto_engineer.py @@ -314,6 +314,55 @@ def test_opencode_delegates_prompts(self, tmp_path): assert "npx" in user_p +class TestStartUrlPrompt: + """The agent --url value reaches the user message.""" + + URL = "https://jobs.ashbyhq.com/openai" + + def _user_prompt(self, tmp_path, agent_provider, **kwargs): + with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"): + with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"): + with patch("reverse_api.base_engineer.MessageStore"): + with patch("reverse_api.agent_browser.agent_browser_npx_package", return_value="agent-browser@test"): + eng = ClaudeAutoEngineer( + run_id="test123", + prompt="fetch all open job postings", + model="claude-sonnet-4-6", + output_dir=str(tmp_path), + agent_provider=agent_provider, + **kwargs, + ) + return eng._build_auto_prompts()[1] + + @pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"]) + def test_start_url_follows_mission(self, tmp_path, agent_provider): + user_p = self._user_prompt(tmp_path, agent_provider, start_url=self.URL) + assert f"\n{self.URL}\n" in user_p + assert user_p.index("") < user_p.index("") < user_p.index("") + + @pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"]) + def test_no_start_url_block_without_url(self, tmp_path, agent_provider): + user_p = self._user_prompt(tmp_path, agent_provider) + assert "" not in user_p + assert "\n\n" in user_p + + def test_opencode_renders_start_url(self, tmp_path): + with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"): + with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"): + with patch("reverse_api.base_engineer.MessageStore"): + with patch("reverse_api.opencode_engineer.OpenCodeUI"): + eng = OpenCodeAutoEngineer( + run_id="test123", + prompt="fetch all open job postings", + output_dir=str(tmp_path), + opencode_provider="anthropic", + opencode_model="claude-opus-4-6", + start_url=self.URL, + ) + _sys_p, user_p = eng._get_active_prompts() + assert f"\n{self.URL}\n" in user_p + + class TestChromeMcpConfig: """Test MCP configuration selection.""" diff --git a/tests/test_cli_agent_json.py b/tests/test_cli_agent_json.py index 12c797d..44ae1fd 100644 --- a/tests/test_cli_agent_json.py +++ b/tests/test_cli_agent_json.py @@ -699,3 +699,55 @@ def test_direct_interrupt_after_claude_result_keeps_partial_run(tmp_path, output payload = json.loads(result.stdout.strip().splitlines()[-1]) assert payload["error"] == "interrupted" assert payload["script_path"] == "completed.py" + + +@pytest.mark.parametrize( + "sdk,engine_path", + [ + ("claude", "reverse_api.auto_engineer.ClaudeAutoEngineer"), + ("opencode", "reverse_api.auto_engineer.OpenCodeAutoEngineer"), + ("copilot", "reverse_api.auto_engineer.CopilotAutoEngineer"), + ("cursor", "reverse_api.cursor_engineer.CursorAutoEngineer"), + ], +) +def test_agent_url_reaches_engine(tmp_path, sdk, engine_path): + from unittest.mock import AsyncMock + + from reverse_api.config import ConfigManager + + config = ConfigManager(tmp_path / "config.json") + config.config.update({"sdk": sdk, "agent_provider": "auto", "real_time_sync": False}) + engine = MagicMock() + engine.analyze_and_generate = AsyncMock(return_value=None) + with ( + patch("reverse_api.cli.config_manager", config), + patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")), + patch(engine_path, return_value=engine) as engine_cls, + ): + CliRunner().invoke( + main, + ["agent", "-p", "fetch job postings", "-u", "https://jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"], + ) + assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai" + + +def test_agent_bare_host_url_gets_https_scheme(tmp_path): + from unittest.mock import AsyncMock + + from reverse_api.config import ConfigManager + + config = ConfigManager(tmp_path / "config.json") + config.config.update({"sdk": "claude", "agent_provider": "auto", "real_time_sync": False}) + engine = MagicMock() + engine.analyze_and_generate = AsyncMock(return_value=None) + with ( + patch("reverse_api.cli.config_manager", config), + patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")), + patch("reverse_api.auto_engineer.ClaudeAutoEngineer", return_value=engine) as engine_cls, + ): + result = CliRunner().invoke( + main, + ["agent", "-p", "fetch job postings", "-u", "jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"], + ) + assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai" + assert json.loads(result.stdout.strip().splitlines()[-1])["url"] == "https://jobs.ashbyhq.com/openai" diff --git a/tests/test_cli_followups.py b/tests/test_cli_followups.py index e3ebaa2..79e32d6 100644 --- a/tests/test_cli_followups.py +++ b/tests/test_cli_followups.py @@ -434,6 +434,23 @@ def test_dry_run_bad_url_is_misuse(self, tmp_path): assert payload["error_kind"] == "misuse" assert any(c["name"] == "url" and c["status"] == "error" for c in payload["checks"]) + def test_dry_run_bare_host_gets_https_scheme(self, tmp_path): + """A bare host passes the url check, as it does on a real run.""" + from reverse_api.cli import agent as agent_cmd + + runner = CliRunner() + with patch("reverse_api.cli.config_manager") as cm: + cm.get.side_effect = lambda key, default=None: { + "agent_provider": "auto", + "sdk": "claude", + "output_dir": str(tmp_path), + }.get(key, default) + result = runner.invoke(agent_cmd, ["--dry-run", "-p", "x", "-u", "jobs.example.com/x"]) + + payload = json.loads(result.stdout.strip()) + assert payload["url"] == "https://jobs.example.com/x" + assert any(c["name"] == "url" and c["status"] == "ok" for c in payload["checks"]) + def test_dry_run_unwritable_output_dir_is_config_invalid(self, tmp_path): """Unwritable output_dir → error_kind=config_invalid, not misuse.""" from reverse_api.cli import agent as agent_cmd diff --git a/tests/test_manual_browser_startup_failure.py b/tests/test_manual_browser_startup_failure.py index be267d5..48c5fe1 100644 --- a/tests/test_manual_browser_startup_failure.py +++ b/tests/test_manual_browser_startup_failure.py @@ -34,8 +34,3 @@ def test_playwright_stopped_when_start_raises(self, tmp_path): fake_pw.stop.assert_called_once() assert browser._playwright is None - - def test_scheme_added_to_bare_host(self): - assert ManualBrowser._normalize_url("jobs.example.com/x") == "https://jobs.example.com/x" - assert ManualBrowser._normalize_url("http://a.com") == "http://a.com" - assert ManualBrowser._normalize_url(" https://b.com ") == "https://b.com" diff --git a/tests/test_prompts.py b/tests/test_prompts.py index e01f3a5..25d913e 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -219,6 +219,7 @@ def test_auto_user_playwright_loads(self): prompt="browse example.com", scripts_dir="/tmp/scripts", har_path="/tmp/recording.har", + start_url_block="", ) assert "browse example.com" in text assert "browser_navigate" in text @@ -229,6 +230,7 @@ def test_auto_user_chrome_mcp_loads(self): "auto/user_chrome_mcp", prompt="browse example.com", scripts_dir="/tmp/scripts", + start_url_block="", ) assert "browse example.com" in text assert "navigate_page" in text diff --git a/tests/test_utils.py b/tests/test_utils.py index 316696f..57558cf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -870,6 +870,15 @@ def test_unrelated_error(self): assert not is_context_overflow_error("rate limit exceeded") +class TestNormalizeUrl: + def test_scheme_added_to_bare_host(self): + from reverse_api.utils import normalize_url + + assert normalize_url("jobs.example.com/x") == "https://jobs.example.com/x" + assert normalize_url("http://a.com") == "http://a.com" + assert normalize_url(" https://b.com ") == "https://b.com" + + class TestBuildSdkSettings: """Claude SDK sessions are isolated from the user's Claude Code setup by default."""