Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions src/spark_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
84 changes: 84 additions & 0 deletions tests/test_browser_use_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +49,7 @@
collect_telegram_fix_payload,
collect_verify_payload,
configure_telegram_profile,
cmd_config_get,
cmd_list,
cmd_providers,
cmd_recommend,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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"}, {}),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = {
Expand Down
Loading