Add on-demand transport prompt output

This commit is contained in:
jwinterm
2026-07-07 21:59:29 -04:00
parent d832174982
commit 3906964c93
8 changed files with 229 additions and 12 deletions
+2
View File
@@ -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
+10
View File
@@ -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)}\" "
+67
View File
@@ -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,
*,
+25 -12
View File
@@ -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:
+9
View File
@@ -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
```
+91
View File
@@ -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()
+11
View File
@@ -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(
+14
View File
@@ -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)