From 3906964c934aa90049bb56c1ca98b9e3ea3a7345 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 7 Jul 2026 21:59:29 -0400 Subject: [PATCH] Add on-demand transport prompt output --- README.md | 2 + ampbrowser/cli.py | 10 ++++ ampbrowser/launch.py | 67 ++++++++++++++++++++++++ ampbrowser/transport_manager.py | 37 +++++++++----- docs/open-flow.md | 9 ++++ tests/test_cli_open.py | 91 +++++++++++++++++++++++++++++++++ tests/test_launch.py | 11 ++++ tests/test_transport_manager.py | 14 +++++ 8 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 tests/test_cli_open.py diff --git a/README.md b/README.md index aa28354..cb29d96 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ python3 -m ampbrowser route ipfs://bafyexample/ python3 -m ampbrowser inspect python3 -m ampbrowser plan gemini://wownero.org/ python3 -m ampbrowser open https://wownero.org/ +python3 -m ampbrowser open http://example.onion/ --launch +python3 -m ampbrowser open http://example.onion/ --yes --launch python3 -m ampbrowser open http://example.b32.i2p/ --yes python3 -m ampbrowser transport start tor python3 -m ampbrowser transport status tor diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index 89ae75d..8599eb4 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -168,6 +168,9 @@ def _cmd_open( message=transport_result.message, transport_setup_status=transport_result.status, transport_setup_provider=transport_result.provider, + transport_setup_owned=transport_result.owned, + transport_setup_pid=transport_result.pid, + transport_setup_endpoint=transport_result.endpoint, transport_setup_message=transport_result.message, ) open_plan = execute_open(open_plan, root=Path.cwd()) @@ -196,8 +199,15 @@ def _cmd_open( f"proxy={open_plan.proxy} " f"runtime_path={runtime_path} " f"user_js_path={user_js_path} " + f"setup_prompt_title=\"{_safe(open_plan.setup_prompt_title)}\" " + f"setup_prompt_body=\"{_safe(open_plan.setup_prompt_body)}\" " + f"setup_prompt_approve_label=\"{_safe(open_plan.setup_prompt_approve_label)}\" " + f"setup_prompt_approval_command=\"{_safe(open_plan.setup_prompt_approval_command)}\" " f"transport_setup_status={open_plan.transport_setup_status} " f"transport_setup_provider={open_plan.transport_setup_provider} " + f"transport_setup_owned={str(open_plan.transport_setup_owned).lower()} " + f"transport_setup_pid={open_plan.transport_setup_pid} " + f"transport_setup_endpoint={open_plan.transport_setup_endpoint} " f"transport_setup_message=\"{_safe(open_plan.transport_setup_message)}\" " f"launch_command=\"{_safe(launch_command)}\" " f"setup_steps=\"{_safe(setup_steps)}\" " diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py index f288a16..aeac923 100644 --- a/ampbrowser/launch.py +++ b/ampbrowser/launch.py @@ -5,6 +5,7 @@ from dataclasses import replace import json import os from pathlib import Path +import shlex import subprocess from urllib.parse import urlparse @@ -34,8 +35,15 @@ class OpenPlan: message: str launch_spec: BrowserLaunchSpec | None = None browser_pid: int = 0 + setup_prompt_title: str = "-" + setup_prompt_body: str = "-" + setup_prompt_approve_label: str = "-" + setup_prompt_approval_command: str = "-" transport_setup_status: str = "-" transport_setup_provider: str = "-" + transport_setup_owned: bool = False + transport_setup_pid: int = 0 + transport_setup_endpoint: str = "-" transport_setup_message: str = "-" @@ -68,6 +76,7 @@ def prepare_open( if browse_plan.requires_consent: setup_steps = _setup_steps(browse_plan) + setup_prompt = _setup_prompt(browse_plan, setup_steps) if not consent: return OpenPlan( browse_plan=browse_plan, @@ -79,6 +88,10 @@ def prepare_open( setup_steps=setup_steps, message=browse_plan.prompt, launch_spec=launch_spec, + setup_prompt_title=setup_prompt.title, + setup_prompt_body=setup_prompt.body, + setup_prompt_approve_label=setup_prompt.approve_label, + setup_prompt_approval_command=setup_prompt.approval_command, ) return OpenPlan( browse_plan=browse_plan, @@ -90,6 +103,10 @@ def prepare_open( setup_steps=setup_steps, message="first-use setup approved; browser launch waits for transport readiness", launch_spec=launch_spec, + setup_prompt_title=setup_prompt.title, + setup_prompt_body=setup_prompt.body, + setup_prompt_approve_label=setup_prompt.approve_label, + setup_prompt_approval_command=setup_prompt.approval_command, ) return OpenPlan( @@ -166,6 +183,56 @@ def _setup_steps(browse_plan: BrowsePlan) -> tuple[str, ...]: return tuple(steps) +@dataclass(frozen=True) +class _SetupPrompt: + title: str + body: str + approve_label: str + approval_command: str + + +def _setup_prompt(browse_plan: BrowsePlan, setup_steps: tuple[str, ...]) -> _SetupPrompt: + transport = browse_plan.route.transport + label = _transport_label(transport) + platform = browse_plan.platform_capability.platform + endpoint = browse_plan.status.endpoint if browse_plan.status else "-" + setup_summary = "; ".join(setup_steps) if setup_steps else "prepare the transport" + if browse_plan.status and not browse_plan.status.installed: + availability = f"{label} is not installed or running." + else: + availability = f"{label} is not running." + if platform == "desktop": + management = f"AMPB can start a managed {label} session and keep its state isolated." + elif platform == "android": + management = f"AMPB can start a visible Android foreground {label} session." + elif platform == "ios": + management = f"AMPB can start a foreground-only in-app {label} session." + else: + management = f"AMPB can prepare {label} for this platform." + body = ( + f"{availability} {management} The browser will use {endpoint} for " + f"{browse_plan.route.normalized}. Setup plan: {setup_summary}." + ) + approval_command = shlex.join(("ampbrowser", "open", browse_plan.route.normalized, "--yes", "--launch")) + return _SetupPrompt( + title=f"Set up {label}?", + body=body, + approve_label=f"Start {label} and open", + approval_command=approval_command, + ) + + +def _transport_label(transport: str) -> str: + labels = { + "tor": "Tor", + "i2p": "I2P", + "ipfs": "IPFS", + "gemini": "Gemini", + "reticulum": "Reticulum", + } + return labels.get(transport, transport) + + def _launch_spec_for( browse_plan: BrowsePlan, *, diff --git a/ampbrowser/transport_manager.py b/ampbrowser/transport_manager.py index adb3b21..119176f 100644 --- a/ampbrowser/transport_manager.py +++ b/ampbrowser/transport_manager.py @@ -158,17 +158,21 @@ def stop_managed_transport( ) if not _pid_alive(pid): - _unlink_state(state_path) + cleaned = _unlink_state(state_path) return ManagedTransportResult( transport, provider, - "stale", + "stale" if cleaned else "stale-cleanup-failed", endpoint, True, pid, state_dir, command, - f"removed stale AMPB-owned {transport} state", + ( + f"removed stale AMPB-owned {transport} state" + if cleaned + else f"could not remove stale AMPB-owned {transport} state" + ), ) try: @@ -189,17 +193,21 @@ def stop_managed_transport( deadline = time.monotonic() + wait_seconds while time.monotonic() <= deadline: if not _pid_alive(pid): - _unlink_state(state_path) + cleaned = _unlink_state(state_path) return ManagedTransportResult( transport, provider, - "stopped", + "stopped" if cleaned else "stopped-cleanup-failed", endpoint, True, pid, state_dir, command, - f"stopped AMPB-owned {transport}", + ( + f"stopped AMPB-owned {transport}" + if cleaned + else f"stopped AMPB-owned {transport} but could not remove state" + ), ) time.sleep(0.1) @@ -404,17 +412,21 @@ def _owned_transport_result( command = tuple(str(part) for part in state.get("command", ())) if not pid or not _pid_alive(pid): - _unlink_state(state_path) + cleaned = _unlink_state(state_path) return ManagedTransportResult( transport, provider, - "stale", + "stale" if cleaned else "stale-cleanup-failed", endpoint, True, pid, state_dir, command, - f"removed stale AMPB-owned {transport} state", + ( + f"removed stale AMPB-owned {transport} state" + if cleaned + else f"could not remove stale AMPB-owned {transport} state" + ), ) if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds): @@ -472,13 +484,14 @@ def _pid_alive(pid: int) -> bool: return False -def _unlink_state(path: Path) -> None: +def _unlink_state(path: Path) -> bool: try: path.unlink() + return True except FileNotFoundError: - pass + return True except OSError: - pass + return False def _endpoint_for(transport: str) -> str: diff --git a/docs/open-flow.md b/docs/open-flow.md index 0897215..a662d58 100644 --- a/docs/open-flow.md +++ b/docs/open-flow.md @@ -22,6 +22,14 @@ profile and launch the configured browser runtime when the selected route is alr ready. With `--launch --yes`, AMPB may start an approved managed transport before launching. Transport setup remains gated by first-use approval and readiness checks. +For a missing transport, `open --launch` does not start anything until approval is +provided. The output includes `setup_prompt_title`, `setup_prompt_body`, +`setup_prompt_approve_label`, and `setup_prompt_approval_command`; native shells should +render those fields as the first-use popup. After approval, launch output includes +`transport_setup_status`, `transport_setup_provider`, `transport_setup_owned`, +`transport_setup_pid`, and `transport_setup_endpoint` so the shell can distinguish an +adopted daemon from an AMPB-owned managed process. + ## Examples ```sh @@ -31,5 +39,6 @@ python3 -m ampbrowser open http://example.b32.i2p/ --yes python3 -m ampbrowser open http://example.b32.i2p/ --config examples/config.toml python3 -m ampbrowser open http://example.b32.i2p/ --platform android python3 -m ampbrowser open https://wownero.org/ --launch +python3 -m ampbrowser open http://example.onion/ --launch python3 -m ampbrowser open http://example.onion/ --yes --launch ``` diff --git a/tests/test_cli_open.py b/tests/test_cli_open.py new file mode 100644 index 0000000..b42c3da --- /dev/null +++ b/tests/test_cli_open.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import replace +import io +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +from ampbrowser.cli import main +from ampbrowser.transport_manager import ManagedTransportResult +from ampbrowser.transports import TransportStatus + + +class CliOpenTest(unittest.TestCase): + def test_onion_launch_without_approval_prints_prompt_without_starting_transport(self) -> None: + status = TransportStatus( + transport="tor", + installed=True, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + + with patch("ampbrowser.plan.inspect_transport", return_value=status): + with patch("ampbrowser.cli.ensure_transport_ready") as ensure_transport_ready: + with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout: + code = main(["open", "http://example.onion/", "--launch"]) + + self.assertEqual(0, code) + ensure_transport_ready.assert_not_called() + output = stdout.getvalue() + self.assertIn("status=consent-required", output) + self.assertIn('setup_prompt_title="Set up Tor?"', output) + self.assertIn("setup_prompt_approval_command=", output) + self.assertIn("transport_setup_status=-", output) + + def test_onion_launch_with_approval_starts_transport_before_browser_launch(self) -> None: + status = TransportStatus( + transport="tor", + installed=True, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + transport_result = ManagedTransportResult( + transport="tor", + provider="arti", + status="started", + endpoint="socks5://127.0.0.1:9050", + owned=True, + pid=1234, + state_dir=".ampb/transports/tor", + command=("/tmp/arti", "proxy"), + message="started managed arti transport", + ) + + def fake_execute(open_plan, *, root: Path): + return replace( + open_plan, + status="launched", + dry_run=False, + browser_pid=5678, + message="launched bundled browser", + ) + + with patch("ampbrowser.plan.inspect_transport", return_value=status): + with patch("ampbrowser.cli.ensure_transport_ready", return_value=transport_result) as ensure_transport_ready: + with patch("ampbrowser.cli.execute_open", side_effect=fake_execute) as execute_open: + with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout: + code = main(["open", "http://example.onion/", "--yes", "--launch"]) + + self.assertEqual(0, code) + ensure_transport_ready.assert_called_once() + execute_open.assert_called_once() + output = stdout.getvalue() + self.assertIn("status=launched", output) + self.assertIn("browser_pid=5678", output) + self.assertIn("transport_setup_status=started", output) + self.assertIn("transport_setup_provider=arti", output) + self.assertIn("transport_setup_owned=true", output) + self.assertIn("transport_setup_pid=1234", output) + self.assertIn("transport_setup_endpoint=socks5://127.0.0.1:9050", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launch.py b/tests/test_launch.py index 7b548fe..add355c 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -49,6 +49,14 @@ class PrepareOpenTest(unittest.TestCase): plan.setup_steps, ) self.assertIn("install and start", plan.message) + self.assertEqual("Set up I2P?", plan.setup_prompt_title) + self.assertIn("I2P is not installed or running.", plan.setup_prompt_body) + self.assertIn("http://example.b32.i2p/", plan.setup_prompt_body) + self.assertEqual("Start I2P and open", plan.setup_prompt_approve_label) + self.assertEqual( + "ampbrowser open http://example.b32.i2p/ --yes --launch", + plan.setup_prompt_approval_command, + ) def test_yes_approves_setup_without_executing_it(self) -> None: status = TransportStatus( @@ -68,6 +76,9 @@ class PrepareOpenTest(unittest.TestCase): self.assertTrue(plan.dry_run) self.assertTrue(plan.consent_granted) self.assertEqual(("start managed Arti SOCKS proxy", "wait for socks5://127.0.0.1:9050"), plan.setup_steps) + self.assertEqual("Set up Tor?", plan.setup_prompt_title) + self.assertIn("Tor is not running.", plan.setup_prompt_body) + self.assertEqual("Start Tor and open", plan.setup_prompt_approve_label) def test_adopted_tor_launch_spec_sets_socks_remote_dns(self) -> None: status = TransportStatus( diff --git a/tests/test_transport_manager.py b/tests/test_transport_manager.py index f17a051..4f8e12d 100644 --- a/tests/test_transport_manager.py +++ b/tests/test_transport_manager.py @@ -139,6 +139,20 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("stale", result.status) self.assertFalse(state_path.exists()) + def test_transport_status_reports_stale_cleanup_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state_dir = root / ".ampb/transports/tor" + state_dir.mkdir(parents=True) + state_path = state_dir / "ampb-owned.json" + state_path.write_text('{"pid": 1234, "provider": "arti", "endpoint": "socks5://127.0.0.1:9050"}\n', encoding="utf-8") + with patch("ampbrowser.transport_manager._pid_alive", return_value=False): + with patch("ampbrowser.transport_manager._unlink_state", return_value=False): + result = transport_status("tor", config=AppConfig(transport_modes={}), root=root) + + self.assertEqual("stale-cleanup-failed", result.status) + self.assertIn("could not remove stale", result.message) + def test_stop_managed_transport_terminates_recorded_pid(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)