diff --git a/src/spark_cli/cli.py b/src/spark_cli/cli.py index 06fc7bc0..7ad0188c 100644 --- a/src/spark_cli/cli.py +++ b/src/spark_cli/cli.py @@ -202,6 +202,7 @@ def discover_repo_root() -> Path: "/root", "/var/run/docker.sock", ) +OPENAI_COMPAT_HTTP_USER_AGENT = "Spark-CLI/1.0 (+https://github.com/vibeforge1111/spark-cli)" TRUST_TIERS = ("builtin", "trusted", "community", "untrusted") TRUST_BLOCK_THRESHOLD = { "builtin": "critical", @@ -5739,6 +5740,7 @@ def browser_use_status_payload() -> dict[str, Any]: cli_path = browser_use_cli_path() package_available = browser_use_package_available() status_doc = browser_use_status_file_payload() + latest_action = browser_use_latest_action_receipt() raw_status = str(status_doc.get("status") or status_doc.get("state") or "").strip().lower() proofs = [str(item) for item in (status_doc.get("proofs") or []) if str(item).strip()] proof_set = set(proofs) @@ -5778,6 +5780,7 @@ def browser_use_status_payload() -> dict[str, Any]: "proof_fresh": proof_fresh, "required_proofs": sorted(BROWSER_USE_REQUIRED_PROOFS), "proven_scope": browser_use_proven_scope(proofs), + "latest_action": latest_action, "unproven_scope": [ "logged-in pages", "cookies/profile reuse", @@ -5789,6 +5792,42 @@ def browser_use_status_payload() -> dict[str, Any]: } +def browser_use_latest_action_receipt() -> dict[str, Any]: + action_dir = BROWSER_USE_STATUS_DIR / "actions" + if not action_dir.exists(): + return {} + try: + candidates = sorted( + [path for path in action_dir.glob("*.json") if path.is_file()], + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + except OSError: + return {} + for path in candidates: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(payload, dict): + continue + return { + "action": str(payload.get("action") or ""), + "url": str(payload.get("url") or ""), + "status": str(payload.get("status") or ""), + "ok": bool(payload.get("ok")), + "checked_at": str(payload.get("checked_at") or ""), + "last_success_at": str(payload.get("last_success_at") or ""), + "last_failure_at": str(payload.get("last_failure_at") or ""), + "last_failure_reason": str(payload.get("last_failure_reason") or ""), + "final_url": str(payload.get("final_url") or ""), + "title": str(payload.get("title") or ""), + "receipt_path": public_local_path_ref(path), + "proofs": [str(item) for item in (payload.get("proofs") or []) if str(item).strip()], + } + return {} + + def browser_use_proof_is_fresh(status_doc: dict[str, Any]) -> bool: timestamp = str(status_doc.get("last_success_at") or status_doc.get("recorded_at") or status_doc.get("checked_at") or "").strip() if not timestamp: @@ -6478,6 +6517,13 @@ def cmd_browser_use(args: argparse.Namespace) -> int: print("Proven scope: " + ", ".join(payload["proven_scope"])) if payload["last_failure_reason"]: print(f"Last failure: {payload['last_failure_reason']}") + latest_action = payload.get("latest_action") if isinstance(payload.get("latest_action"), dict) else {} + if latest_action: + action_label = str(latest_action.get("action") or "action") + action_status = str(latest_action.get("status") or "unknown") + print(f"Latest action: {action_label} -> {action_status}") + if latest_action.get("last_failure_reason"): + print(f"Latest action failure: {latest_action['last_failure_reason']}") print(f"Next: {payload['next_action']}") return 0 if payload["status"] != "failed" else 1 @@ -10515,6 +10561,7 @@ def openai_compatible_chat_completion(target: dict[str, Any], prompt: str) -> st headers={ "Authorization": f"Bearer {target['api_key']}", "Content-Type": "application/json", + "User-Agent": OPENAI_COMPAT_HTTP_USER_AGENT, }, method="POST", ) @@ -13847,6 +13894,12 @@ def update_tracked_runtime_pid(process_key: str, launched_pid: int, runtime_pid: def process_runtime_detail(pids: dict[str, Any], module_names: list[str]) -> tuple[bool, str]: + if not module_names: + return ( + False, + "No Spark-supervised runtime processes are expected from the current install state. " + "Install or repair the starter bundle first.", + ) missing: list[str] = [] running_names: list[str] = [] for name in module_names: @@ -13942,7 +13995,7 @@ def expected_runtime_process_names(installed_names: set[str], setup_state: dict[ names.append("spark-telegram-bot") if "spawner-ui" in installed_names: names.append("spawner-ui") - if isinstance(profiles, dict) and "spark-telegram-bot" in installed_names and not external_telegram: + if isinstance(profiles, dict) and "spark-telegram-bot" in installed_names: for profile, profile_state in sorted(profiles.items()): if isinstance(profile_state, dict) and telegram_profile_should_autostart(profile_state): process_key = module_process_key("spark-telegram-bot", str(profile)) @@ -15069,6 +15122,9 @@ def save_user_config(config: dict[str, Any]) -> None: save_json(USER_CONFIG_PATH, config) +CONFIG_MISSING = object() + + def dotted_get(config: dict[str, Any], key: str, default: Any = None) -> Any: parts = key.split(".") current: Any = config @@ -15120,12 +15176,14 @@ def coerce_config_value(raw: str) -> Any: def cmd_config_get(args: argparse.Namespace) -> int: - value = dotted_get(load_user_config(), args.key) - if value is None: + value = dotted_get(load_user_config(), args.key, default=CONFIG_MISSING) + if value is CONFIG_MISSING: print(f"{args.key} is not set") return 1 if isinstance(value, (dict, list)): print(json.dumps(value, indent=2)) + elif value is None: + print("null") else: print(value) return 0 @@ -16253,7 +16311,7 @@ def build_parser() -> argparse.ArgumentParser: browser_use_screenshot_parser.add_argument("--json", action="store_true") browser_use_screenshot_parser.set_defaults(func=cmd_browser_use, browser_use_command="screenshot") browser_use_task_parser = browser_use_sub.add_parser("task", help="Run a multi-step Browser Use Agent task") - browser_use_task_parser.add_argument("goal", nargs=argparse.REMAINDER, help="Task goal for the Browser Use Agent") + browser_use_task_parser.add_argument("goal", nargs="*", help="Task goal for the Browser Use Agent; use `--` before goals that start with option-like text") browser_use_task_parser.add_argument("--url", help="Optional starting URL", default="") browser_use_task_parser.add_argument("--max-steps", type=positive_int_arg, default=25, help="Maximum Browser Use Agent steps") browser_use_task_parser.add_argument("--json", action="store_true") diff --git a/tests/test_browser_use_cli.py b/tests/test_browser_use_cli.py index 86e60d61..6dbc8ccc 100644 --- a/tests/test_browser_use_cli.py +++ b/tests/test_browser_use_cli.py @@ -105,6 +105,52 @@ def test_status_accepts_builder_screenshot_proof_path(self) -> None: self.assertTrue(payload["ok"]) self.assertEqual(payload["status"], "ready") + def test_status_surfaces_latest_browser_action_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + status_path = Path(tmp_dir) / "state" / "browser-use" / "status.json" + screenshot = status_path.parent / "probe-screenshot.png" + action_dir = status_path.parent / "actions" + action_receipt = action_dir / "spark-browser-timeout.json" + status_path.parent.mkdir(parents=True) + action_dir.mkdir(parents=True) + screenshot.write_bytes(b"png") + status_path.write_text( + cli.json.dumps( + { + "status": "ready", + "last_success_at": datetime.now(timezone.utc).isoformat(), + "proofs": ["doctor", "public_page_open", "screenshot_capture", "state_read"], + "screenshot_path": str(screenshot), + } + ), + encoding="utf-8", + ) + action_receipt.write_text( + cli.json.dumps( + { + "action": "open", + "url": "https://compete.sparkswarm.ai/#agent-playbook", + "status": "failed", + "ok": False, + "checked_at": datetime.now(timezone.utc).isoformat(), + "last_failure_at": datetime.now(timezone.utc).isoformat(), + "last_failure_reason": "Page.navigate() timed out after 20.0s", + } + ), + encoding="utf-8", + ) + with patch.object(cli, "BROWSER_USE_STATUS_DIR", status_path.parent), \ + patch.object(cli, "BROWSER_USE_STATUS_PATH", status_path), \ + patch("spark_cli.cli.browser_use_cli_path", return_value="browser-use"), \ + patch("spark_cli.cli.browser_use_package_available", return_value=True): + payload = cli.browser_use_status_payload() + + self.assertTrue(payload["ok"]) + self.assertEqual(payload["status"], "ready") + self.assertEqual(payload["latest_action"]["action"], "open") + self.assertEqual(payload["latest_action"]["status"], "failed") + self.assertIn("Page.navigate", payload["latest_action"]["last_failure_reason"]) + def test_probe_writes_ready_receipt_for_public_page_scope(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: status_path = Path(tmp_dir) / "state" / "browser-use" / "status.json" @@ -314,6 +360,44 @@ def test_task_requires_goal(self) -> None: self.assertEqual(payload["status"], "blocked") self.assertIn("task is required", payload["last_failure_reason"]) + def test_task_parser_accepts_options_after_goal_text(self) -> None: + args = cli.build_parser().parse_args([ + "browser-use", + "task", + "review", + "the", + "page", + "--url", + "https://example.com", + "--max-steps", + "3", + "--json", + ]) + + self.assertEqual(args.browser_use_command, "task") + self.assertEqual(args.goal, ["review", "the", "page"]) + self.assertEqual(args.url, "https://example.com") + self.assertEqual(args.max_steps, 3) + self.assertTrue(args.json) + + def test_task_parser_keeps_option_like_goal_text_after_separator(self) -> None: + args = cli.build_parser().parse_args([ + "browser-use", + "task", + "explain", + "--", + "--json", + ]) + + self.assertEqual(args.goal, ["explain", "--json"]) + self.assertFalse(args.json) + + def test_task_parser_keeps_json_missing_goal_on_command_path(self) -> None: + args = cli.build_parser().parse_args(["browser-use", "task", "--json"]) + + self.assertEqual(args.goal, []) + self.assertTrue(args.json) + def test_task_receipt_fails_when_agent_does_not_finish(self) -> None: async def fake_agent( goal: str, diff --git a/tests/test_cli.py b/tests/test_cli.py index 3161f7d5..398506d7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ import tempfile import unittest import urllib.error +import urllib.request from argparse import Namespace from contextlib import redirect_stderr, redirect_stdout from io import StringIO @@ -48,6 +49,7 @@ collect_telegram_fix_payload, collect_verify_payload, configure_telegram_profile, + cmd_config_get, cmd_list, cmd_providers, cmd_recommend, @@ -160,6 +162,8 @@ parse_secret_pairs, parse_version_constraint, parse_version_tuple, + openai_compatible_chat_completion, + OPENAI_COMPAT_HTTP_USER_AGENT, provider_status_payload, provider_recommendations_payload, provider_test_payload, @@ -1640,11 +1644,19 @@ def test_dotted_set_and_get_roundtrips_nested_paths(self) -> None: config: dict = {} dotted_set(config, "dashboard.port", 8765) dotted_set(config, "model", "sonnet") + dotted_set(config, "disabled", None) self.assertEqual(dotted_get(config, "dashboard.port"), 8765) self.assertEqual(dotted_get(config, "model"), "sonnet") + self.assertIsNone(dotted_get(config, "disabled", default="fallback")) self.assertIsNone(dotted_get(config, "missing.key")) self.assertEqual(dotted_get(config, "missing.key", default="fallback"), "fallback") + def test_config_get_prints_stored_null_value(self) -> None: + with patch("spark_cli.cli.load_user_config", return_value={"feature": {"flag": None}}), \ + patch("sys.stdout", new_callable=StringIO) as stdout: + self.assertEqual(cmd_config_get(Namespace(key="feature.flag")), 0) + self.assertEqual(stdout.getvalue(), "null\n") + def test_dotted_unset_removes_nested_key_and_reports_hit(self) -> None: config = {"dashboard": {"port": 8765, "theme": "dark"}} self.assertTrue(dotted_unset(config, "dashboard.port")) @@ -7631,6 +7643,20 @@ def test_expected_runtime_process_names_includes_telegram_profiles(self) -> None ["spawner-ui", "spark-telegram-bot:spark-agi"], ) + def test_expected_runtime_process_names_keeps_autostart_profile_for_external_ingress(self) -> None: + setup_state = { + "telegram_ingress_mode": "external", + "telegram_profiles": { + "primary": {"relay_port": 8789}, + "tester": {"relay_port": 8790, "autostart": False}, + }, + } + + self.assertEqual( + expected_runtime_process_names({"spark-telegram-bot", "spawner-ui"}, setup_state), + ["spawner-ui", "spark-telegram-bot:primary"], + ) + def test_expected_runtime_process_names_uses_default_bot_without_profiles(self) -> None: self.assertEqual( expected_runtime_process_names({"spark-telegram-bot", "spawner-ui"}, {}), @@ -10482,6 +10508,33 @@ def test_provider_status_payload_uses_legacy_secret_keys_for_api_auth(self) -> N self.assertEqual(payload["roles"][role]["auth_mode"], "api_key") self.assertEqual(payload["roles"][role]["model"], "glm-5.1") + def test_openai_compatible_chat_completion_sends_user_agent(self) -> None: + captured: dict[str, str] = {} + + class FakeResponse: + def read(self) -> bytes: + return json.dumps({"choices": [{"message": {"content": "PING_OK"}}]}).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *args: object) -> None: + return None + + def fake_urlopen(request: urllib.request.Request, timeout: float = 0) -> FakeResponse: + captured["User-Agent"] = request.headers.get("User-agent") or request.headers.get("User-Agent", "") + return FakeResponse() + + target = { + "base_url": "https://api.example.test/v1", + "api_key": "test-key", + "model": "test-model", + } + with patch("urllib.request.urlopen", side_effect=fake_urlopen): + result = openai_compatible_chat_completion(target, "ping") + self.assertEqual(result, "PING_OK") + self.assertEqual(captured["User-Agent"], OPENAI_COMPAT_HTTP_USER_AGENT) + def test_collect_verify_payload_reports_launch_ready_stack(self) -> None: expected = [ "spark-researcher", @@ -12336,6 +12389,28 @@ def fake_read_generated_env(path: Path) -> dict[str, str]: self.assertIn("spark-telegram-bot", checks["runtime_processes"]["detail"]) self.assertIn("spawner-ui", checks["runtime_processes"]["detail"]) + def test_collect_verify_payload_does_not_pass_empty_runtime_process_expectation(self) -> None: + status_payload = { + "ok": False, + "modules": [], + "tracked_pids": {}, + "repair_hints": [], + } + provider_payload = {"ok": False, "roles": {}} + + with patch("spark_cli.cli.collect_status_payload", return_value=status_payload), \ + patch("spark_cli.cli.provider_status_payload", return_value=provider_payload), \ + patch("spark_cli.cli.load_json", return_value={}), \ + patch("spark_cli.cli.read_generated_env", return_value={}), \ + patch("spark_cli.cli.collect_secret_surface_payload", return_value={"ok": True, "detail": "clean", "findings": []}), \ + patch("spark_cli.cli.resolve_bundle_names", return_value=[]): + payload = collect_verify_payload() + + checks = {check["name"]: check for check in payload["checks"]} + self.assertFalse(checks["runtime_processes"]["ok"]) + self.assertIn("No Spark-supervised runtime processes are expected", checks["runtime_processes"]["detail"]) + self.assertNotIn("Runtime processes are running", checks["runtime_processes"]["detail"]) + def test_collect_verify_payload_accepts_legacy_spawner_bot_default_provider(self) -> None: expected = ["spark-researcher", "spark-character", "spark-intelligence-builder", "domain-chip-memory", "spawner-ui", "spark-telegram-bot"] status_payload = {