diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py
index 1a7f039..e489e68 100644
--- a/ampbrowser/cli.py
+++ b/ampbrowser/cli.py
@@ -13,6 +13,7 @@ from .launch import prepare_open
from .open_runner import launch_open_plan
from .plan import plan_url
from .platforms import PLATFORM_CHOICES
+from .route_helper import serve_route_helper
from .routing import route_url
from .transport_manager import ensure_transport_ready, stop_managed_transport, transport_status
from .transports import inspect_transports
@@ -90,6 +91,13 @@ def main(argv: list[str] | None = None) -> int:
docs_generate = docs_subcommands.add_parser("generate", help="Generate docs from code.")
docs_generate.add_argument("--check", action="store_true")
+ helper_parser = subcommands.add_parser("helper", help=argparse.SUPPRESS)
+ helper_parser.add_argument("--host", default="127.0.0.1")
+ helper_parser.add_argument("--port", type=int, required=True)
+ helper_parser.add_argument("--token", required=True)
+ helper_parser.add_argument("--root", type=Path, default=Path.cwd())
+ helper_parser.add_argument("--config", type=Path, help="Path to an AMPB config file.")
+
args = parser.parse_args(argv)
try:
if args.command == "route":
@@ -116,6 +124,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_fixture(args)
if args.command == "docs":
return _cmd_docs(args)
+ if args.command == "helper":
+ return _cmd_helper(args)
except Exception as exc: # noqa: BLE001
print(f"AMPBROWSER status=error message={exc}", file=sys.stderr)
return 1
@@ -241,6 +251,10 @@ def _print_open_plan(prefix: str, open_plan, extra_fields: tuple[str, ...] = ())
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"route_helper_status={open_plan.route_helper_status} "
+ f"route_helper_endpoint={open_plan.route_helper_endpoint} "
+ f"route_helper_pid={open_plan.route_helper_pid} "
+ f"route_helper_message=\"{_safe(open_plan.route_helper_message)}\" "
f"launch_command=\"{_safe(launch_command)}\" "
f"setup_steps=\"{_safe(setup_steps)}\" "
f"message=\"{message}\""
@@ -301,6 +315,17 @@ def _cmd_docs(args) -> int:
return 1
+def _cmd_helper(args) -> int:
+ serve_route_helper(
+ host=args.host,
+ port=args.port,
+ token=args.token,
+ root=args.root,
+ config_path=args.config,
+ )
+ return 0
+
+
def _cmd_fixture(args) -> int:
if args.fixture_command == "check":
result = check_fixture_manifest(args.manifest)
diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py
index f873395..4c50671 100644
--- a/ampbrowser/launch.py
+++ b/ampbrowser/launch.py
@@ -5,8 +5,11 @@ from dataclasses import replace
import json
import os
from pathlib import Path
+import secrets
import shlex
+import socket
import subprocess
+import sys
from urllib.parse import urlparse
from .adapters import adapter_for
@@ -16,7 +19,26 @@ from .plan import BrowsePlan, plan_url
ROUTE_AWARE_PROFILE = "route-aware"
ROUTE_AWARE_PAC_NAME = "ampb-proxy.pac"
+ROUTE_HELPER_EXTENSION_ID = "ampb-route-helper@such.software"
PAC_URL_PLACEHOLDER = "__AMPB_ROUTE_AWARE_PAC_URL__"
+HELPER_URL_PLACEHOLDER = "__AMPB_ROUTE_HELPER_URL__"
+HELPER_TOKEN_PLACEHOLDER = "__AMPB_ROUTE_HELPER_TOKEN__"
+
+
+@dataclass(frozen=True)
+class ProfileAsset:
+ path: str
+ content: str
+
+
+@dataclass(frozen=True)
+class RouteHelperLaunch:
+ status: str
+ endpoint: str = "-"
+ token: str = "-"
+ pid: int = 0
+ command: tuple[str, ...] = ()
+ message: str = "-"
@dataclass(frozen=True)
@@ -29,6 +51,8 @@ class BrowserLaunchSpec:
route_aware: bool = False
pac_path: str = "-"
pac_content: str = ""
+ extension_path: str = "-"
+ extension_assets: tuple[ProfileAsset, ...] = ()
@dataclass(frozen=True)
@@ -54,6 +78,10 @@ class OpenPlan:
transport_setup_pid: int = 0
transport_setup_endpoint: str = "-"
transport_setup_message: str = "-"
+ route_helper_status: str = "-"
+ route_helper_endpoint: str = "-"
+ route_helper_pid: int = 0
+ route_helper_message: str = "-"
def prepare_open(
@@ -163,9 +191,23 @@ def execute_open(open_plan: OpenPlan, *, root: Path | None = None) -> OpenPlan:
message=f"browser runtime not found: {spec.runtime_path}",
)
- _write_profile(root, spec)
+ route_helper = _start_route_helper(root) if spec.route_aware else RouteHelperLaunch("disabled")
+ _write_profile(root, spec, route_helper=route_helper)
process = subprocess.Popen(spec.command, cwd=str(root)) # noqa: S603
- return replace(open_plan, dry_run=False, status="launched", browser_pid=process.pid, message="launched bundled browser")
+ message = "launched bundled browser"
+ if spec.route_aware:
+ message = f"launched bundled browser with route helper {route_helper.status}"
+ return replace(
+ open_plan,
+ dry_run=False,
+ status="launched",
+ browser_pid=process.pid,
+ message=message,
+ route_helper_status=route_helper.status,
+ route_helper_endpoint=route_helper.endpoint,
+ route_helper_pid=route_helper.pid,
+ route_helper_message=route_helper.message,
+ )
def _proxy_for(browse_plan: BrowsePlan) -> str:
@@ -266,6 +308,8 @@ def _launch_spec_for(
prefs = _prefs_for(browse_plan, route_aware=route_aware)
pac_path = str(Path(profile_path) / ROUTE_AWARE_PAC_NAME) if route_aware else "-"
pac_content = _route_aware_pac() if route_aware else ""
+ extension_path = str(Path(profile_path) / "extensions" / ROUTE_HELPER_EXTENSION_ID) if route_aware else "-"
+ extension_assets = _route_helper_extension_assets(extension_path) if route_aware else ()
return BrowserLaunchSpec(
runtime_path=runtime_path,
profile_path=profile_path,
@@ -275,6 +319,8 @@ def _launch_spec_for(
route_aware=route_aware,
pac_path=pac_path,
pac_content=pac_content,
+ extension_path=extension_path,
+ extension_assets=extension_assets,
)
@@ -317,6 +363,9 @@ def _prefs_for(browse_plan: BrowsePlan, *, route_aware: bool = False) -> tuple[s
("network.proxy.failover_direct", False),
("network.proxy.socks_remote_dns", True),
("network.proxy.no_proxies_on", ""),
+ ("extensions.autoDisableScopes", 0),
+ ("extensions.enabledScopes", 5),
+ ("xpinstall.signatures.required", False),
]
)
return tuple(_format_pref(name, value) for name, value in prefs)
@@ -398,6 +447,177 @@ def _pac_proxy_for_endpoint(proxy_type: str, endpoint: str) -> str:
return f"{proxy_type} {host}:{port}"
+def _route_helper_extension_assets(extension_path: str) -> tuple[ProfileAsset, ...]:
+ base = Path(extension_path)
+ return (
+ ProfileAsset(str(base / "manifest.json"), _route_helper_manifest()),
+ ProfileAsset(str(base / "background.js"), _route_helper_background_js()),
+ ProfileAsset(str(base / "setup.html"), _route_helper_setup_html()),
+ ProfileAsset(str(base / "setup.js"), _route_helper_setup_js()),
+ ProfileAsset(str(base / "setup.css"), _route_helper_setup_css()),
+ )
+
+
+def _route_helper_manifest() -> str:
+ return json.dumps(
+ {
+ "manifest_version": 2,
+ "name": "AMPB Route Helper",
+ "version": "0.1.0",
+ "description": "Starts AMPB-managed transports when alternate-network links are opened.",
+ "applications": {"gecko": {"id": ROUTE_HELPER_EXTENSION_ID}},
+ "permissions": [
+ "tabs",
+ "webNavigation",
+ "http://127.0.0.1/*",
+ ],
+ "background": {"scripts": ["background.js"]},
+ },
+ indent=2,
+ sort_keys=True,
+ ) + "\n"
+
+
+def _route_helper_background_js() -> str:
+ return "\n".join(
+ (
+ '"use strict";',
+ "",
+ f'const HELPER_URL = "{HELPER_URL_PLACEHOLDER}";',
+ f'const HELPER_TOKEN = "{HELPER_TOKEN_PLACEHOLDER}";',
+ "const pendingByTab = new Map();",
+ "",
+ "function transportForUrl(rawUrl) {",
+ " try {",
+ " const parsed = new URL(rawUrl);",
+ " const host = parsed.hostname.toLowerCase().replace(/\\.$/, '');",
+ " if (host.endsWith('.onion')) return 'tor';",
+ " if (host.endsWith('.i2p')) return 'i2p';",
+ " } catch (_error) {}",
+ " return '';",
+ "}",
+ "",
+ "async function helper(action, transport, url) {",
+ " const response = await fetch(HELPER_URL, {",
+ " method: 'POST',",
+ " headers: {",
+ " 'Content-Type': 'application/json',",
+ " 'X-AMPB-Token': HELPER_TOKEN,",
+ " },",
+ " body: JSON.stringify({ action, transport, url }),",
+ " });",
+ " return response.json();",
+ "}",
+ "",
+ "async function handleNavigate(details) {",
+ " if (details.frameId !== 0) return;",
+ " const transport = transportForUrl(details.url);",
+ " if (!transport) return;",
+ " const key = `${details.tabId}:${details.url}`;",
+ " if (pendingByTab.get(details.tabId) === details.url) return;",
+ " let status;",
+ " try {",
+ " status = await helper('status', transport, details.url);",
+ " } catch (error) {",
+ " status = { ok: false, ready: false, message: String(error) };",
+ " }",
+ " if (status.ready) return;",
+ " pendingByTab.set(details.tabId, details.url);",
+ " const setupUrl = browser.runtime.getURL(",
+ " `setup.html?transport=${encodeURIComponent(transport)}&url=${encodeURIComponent(details.url)}&message=${encodeURIComponent(status.message || '')}`",
+ " );",
+ " await browser.tabs.update(details.tabId, { url: setupUrl });",
+ "}",
+ "",
+ "browser.webNavigation.onBeforeNavigate.addListener(handleNavigate);",
+ "",
+ "browser.runtime.onMessage.addListener((message) => {",
+ " if (!message || message.type !== 'ampb-ensure') return false;",
+ " return helper('ensure', message.transport, message.url);",
+ "});",
+ "",
+ )
+ )
+
+
+def _route_helper_setup_html() -> str:
+ return "\n".join(
+ (
+ "",
+ '',
+ "
",
+ ' ',
+ ' ',
+ " Set Up Transport",
+ ' ',
+ "",
+ "",
+ ' ',
+ ' AMPB Route Helper
',
+ ' Set Up Transport
',
+ ' AMPB needs to start a local transport before opening this route.
',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ " ",
+ ' ',
+ "",
+ "",
+ "",
+ )
+ )
+
+
+def _route_helper_setup_js() -> str:
+ return "\n".join(
+ (
+ '"use strict";',
+ "",
+ "const params = new URLSearchParams(window.location.search);",
+ "const transport = params.get('transport') || '';",
+ "const url = params.get('url') || '';",
+ "const message = params.get('message') || '';",
+ "const label = transport === 'tor' ? 'Tor' : transport === 'i2p' ? 'I2P' : transport;",
+ "",
+ "document.getElementById('title').textContent = `Set up ${label}?`;",
+ "document.getElementById('body').textContent = `AMPB can start a managed ${label} session and keep its state isolated.`;",
+ "document.getElementById('url').textContent = url;",
+ "document.getElementById('message').textContent = message;",
+ "document.getElementById('approve').textContent = `Start ${label} and open`;",
+ "",
+ "document.getElementById('approve').addEventListener('click', async () => {",
+ " const status = document.getElementById('status');",
+ " status.textContent = `Starting ${label}...`;",
+ " const result = await browser.runtime.sendMessage({ type: 'ampb-ensure', transport, url });",
+ " if (result && result.ready) {",
+ " status.textContent = `${label} is ready. Opening route...`;",
+ " window.location.href = url;",
+ " return;",
+ " }",
+ " status.textContent = (result && result.message) || `${label} did not become ready.`;",
+ "});",
+ "",
+ )
+ )
+
+
+def _route_helper_setup_css() -> str:
+ return "\n".join(
+ (
+ ":root { color-scheme: light dark; font-family: system-ui, sans-serif; }",
+ "body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111; color: #f7f1e8; }",
+ ".panel { width: min(680px, calc(100vw - 32px)); }",
+ ".eyebrow { color: #36d1c4; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }",
+ "h1 { font-size: 2rem; margin: 0 0 12px; }",
+ "pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; border: 1px solid #444; }",
+ "button { font: inherit; font-weight: 700; padding: 12px 16px; border: 0; background: #36d1c4; color: #111; }",
+ ".message, .status { color: #cfc7bb; }",
+ "",
+ )
+ )
+
+
def _endpoint_host_port(endpoint: str) -> tuple[str, int]:
parsed = urlparse(endpoint)
return parsed.hostname or "127.0.0.1", parsed.port or 0
@@ -413,7 +633,38 @@ def _format_pref(name: str, value: str | int | bool) -> str:
return f"user_pref({json.dumps(name)}, {literal});"
-def _write_profile(root: Path, spec: BrowserLaunchSpec) -> None:
+def _start_route_helper(root: Path) -> RouteHelperLaunch:
+ port = _free_loopback_port()
+ token = secrets.token_urlsafe(24)
+ endpoint = f"http://127.0.0.1:{port}/"
+ command = (
+ sys.executable,
+ "-m",
+ "ampbrowser",
+ "helper",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ "--token",
+ token,
+ "--root",
+ str(root),
+ )
+ try:
+ process = subprocess.Popen(command, cwd=str(root), start_new_session=True) # noqa: S603
+ except OSError as exc:
+ return RouteHelperLaunch("start-failed", endpoint=endpoint, token=token, command=command, message=str(exc))
+ return RouteHelperLaunch("started", endpoint=endpoint, token=token, pid=process.pid, command=command)
+
+
+def _free_loopback_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _write_profile(root: Path, spec: BrowserLaunchSpec, *, route_helper: RouteHelperLaunch | None = None) -> None:
profile_path = _path_from(root, spec.profile_path)
profile_path.mkdir(parents=True, exist_ok=True)
pac_url = "-"
@@ -422,6 +673,14 @@ def _write_profile(root: Path, spec: BrowserLaunchSpec) -> None:
pac_path.parent.mkdir(parents=True, exist_ok=True)
pac_path.write_text(spec.pac_content, encoding="utf-8")
pac_url = pac_path.resolve().as_uri()
+ if spec.route_aware:
+ helper = route_helper or RouteHelperLaunch("disabled")
+ for asset in spec.extension_assets:
+ asset_path = _path_from(root, asset.path)
+ asset_path.parent.mkdir(parents=True, exist_ok=True)
+ content = asset.content.replace(HELPER_URL_PLACEHOLDER, helper.endpoint)
+ content = content.replace(HELPER_TOKEN_PLACEHOLDER, helper.token)
+ asset_path.write_text(content, encoding="utf-8")
user_js_path = _path_from(root, spec.user_js_path)
text = "// Managed by AMPB. Local browser profile policy.\n" + "\n".join(spec.prefs) + "\n"
text = text.replace(PAC_URL_PLACEHOLDER, pac_url)
diff --git a/ampbrowser/route_helper.py b/ampbrowser/route_helper.py
new file mode 100644
index 0000000..895ed9c
--- /dev/null
+++ b/ampbrowser/route_helper.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+from typing import Any
+
+from .config import AppConfig, load_config
+from .routing import route_url
+from .transport_manager import ManagedTransportResult, ensure_transport_ready, transport_status
+
+
+SUPPORTED_HELPER_TRANSPORTS = {"tor", "i2p"}
+
+
+def handle_helper_message(
+ message: dict[str, Any],
+ *,
+ root: Path,
+ config: AppConfig,
+) -> dict[str, Any]:
+ action = _string(message.get("action"))
+ transport = _message_transport(message)
+ if not transport:
+ return _error_response("unsupported-route", "URL does not route to a managed browser transport")
+ if transport not in SUPPORTED_HELPER_TRANSPORTS:
+ return _error_response("unsupported-transport", f"{transport} is not supported by the route helper yet")
+
+ if action == "status":
+ return _result_response(transport_status(transport, config=config, root=root))
+ if action == "ensure":
+ return _result_response(ensure_transport_ready(transport, config=config, root=root))
+ return _error_response("unsupported-action", f"unsupported helper action: {action or '-'}")
+
+
+def serve_route_helper(
+ *,
+ host: str,
+ port: int,
+ token: str,
+ root: Path,
+ config_path: Path | None = None,
+) -> None:
+ config = load_config(root, config_path)
+ server = _RouteHelperServer((host, port), _RouteHelperHandler)
+ server.token = token
+ server.root_path = root
+ server.config = config
+ server.serve_forever()
+
+
+class _RouteHelperServer(ThreadingHTTPServer):
+ token: str
+ root_path: Path
+ config: AppConfig
+
+
+class _RouteHelperHandler(BaseHTTPRequestHandler):
+ server: _RouteHelperServer
+
+ def do_OPTIONS(self) -> None:
+ self._write_json(200, {"ok": True})
+
+ def do_POST(self) -> None:
+ if self.headers.get("X-AMPB-Token") != self.server.token:
+ self._write_json(403, _error_response("forbidden", "invalid helper token"))
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ except ValueError:
+ length = 0
+ try:
+ payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
+ except json.JSONDecodeError:
+ self._write_json(400, _error_response("bad-json", "request body is not valid JSON"))
+ return
+ if not isinstance(payload, dict):
+ self._write_json(400, _error_response("bad-request", "request body must be a JSON object"))
+ return
+ response = handle_helper_message(payload, root=self.server.root_path, config=self.server.config)
+ self._write_json(200 if response.get("ok") else 400, response)
+
+ def log_message(self, _format: str, *_args: object) -> None:
+ return
+
+ def _write_json(self, status: int, payload: dict[str, Any]) -> None:
+ data = json.dumps(payload, sort_keys=True).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, X-AMPB-Token")
+ self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+
+def _message_transport(message: dict[str, Any]) -> str:
+ raw_transport = _string(message.get("transport"))
+ if raw_transport:
+ return raw_transport
+ raw_url = _string(message.get("url"))
+ if not raw_url:
+ return ""
+ return route_url(raw_url).transport
+
+
+def _result_response(result: ManagedTransportResult) -> dict[str, Any]:
+ return {
+ "ok": True,
+ "transport": result.transport,
+ "provider": result.provider,
+ "provider_source": result.provider_source,
+ "status": result.status,
+ "ready": result.ready,
+ "owned": result.owned,
+ "pid": result.pid,
+ "endpoint": result.endpoint,
+ "state_dir": result.state_dir,
+ "message": result.message,
+ "command": list(result.command),
+ }
+
+
+def _error_response(status: str, message: str) -> dict[str, Any]:
+ return {
+ "ok": False,
+ "status": status,
+ "ready": False,
+ "message": message,
+ }
+
+
+def _string(value: object) -> str:
+ return value if isinstance(value, str) else ""
diff --git a/docs/open-flow.md b/docs/open-flow.md
index 14c6cf3..17ef3a6 100644
--- a/docs/open-flow.md
+++ b/docs/open-flow.md
@@ -34,6 +34,12 @@ adopted daemon from an AMPB-owned managed process.
through a desktop dialog when possible, falls back to a terminal prompt when needed, and
launches through the same transport runner after approval.
+`ampbrowser open --route-aware --launch` creates one desktop profile for clearnet,
+Tor, and I2P. The profile uses a PAC file for `.onion` and `.i2p` routing, installs the
+AMPB route-helper extension, and starts a loopback AMPB helper. If a user clicks an
+alternate-network link before its local transport is ready, the extension shows a setup
+page and asks the helper to start the AMPB-managed transport after approval.
+
## Examples
```sh
@@ -43,6 +49,7 @@ 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 https://ampgateway.site/ --route-aware --launch
python3 -m ampbrowser open http://example.onion/ --launch
python3 -m ampbrowser open http://example.onion/ --yes --launch
python3 -m ampbrowser shell http://example.onion/
diff --git a/docs/transport-management.md b/docs/transport-management.md
index 780449a..98f606a 100644
--- a/docs/transport-management.md
+++ b/docs/transport-management.md
@@ -17,6 +17,11 @@ state before launching. Transport policy comes from `.ampb/config.toml` or an ex
launching a browser. It records only AMPB-owned processes and refuses to stop adopted
user or system daemons.
+Route-aware desktop launches add a local helper process for in-browser setup prompts. The
+helper accepts token-gated loopback requests from the AMPB profile extension, reports
+transport status, and starts only supported AMPB-managed transports. It does not expose a
+public network listener or manage adopted daemons.
+
## Lifecycle
1. Inspect known local endpoints.
diff --git a/tests/test_launch.py b/tests/test_launch.py
index 48103f9..6e981d0 100644
--- a/tests/test_launch.py
+++ b/tests/test_launch.py
@@ -6,7 +6,7 @@ import tempfile
from pathlib import Path
from ampbrowser.config import AppConfig
-from ampbrowser.launch import execute_open, prepare_open
+from ampbrowser.launch import RouteHelperLaunch, execute_open, prepare_open
from ampbrowser.transports import TransportStatus
@@ -138,10 +138,15 @@ class PrepareOpenTest(unittest.TestCase):
self.assertIn('user_pref("network.proxy.type", 2);', plan.launch_spec.prefs)
self.assertIn("__AMPB_ROUTE_AWARE_PAC_URL__", "\n".join(plan.launch_spec.prefs))
- with patch("ampbrowser.launch.subprocess.Popen") as popen:
- launched = execute_open(plan, root=root)
+ helper = RouteHelperLaunch("started", endpoint="http://127.0.0.1:44001/", token="test-token", pid=4321)
+ with patch("ampbrowser.launch._start_route_helper", return_value=helper):
+ with patch("ampbrowser.launch.subprocess.Popen") as popen:
+ launched = execute_open(plan, root=root)
self.assertEqual("launched", launched.status)
+ self.assertEqual("started", launched.route_helper_status)
+ self.assertEqual("http://127.0.0.1:44001/", launched.route_helper_endpoint)
+ self.assertEqual(4321, launched.route_helper_pid)
popen.assert_called_once_with(
(str(runtime), "-no-remote", "-profile", ".ampb/profiles/route-aware", "https://ampgateway.site/"),
cwd=str(root),
@@ -156,6 +161,14 @@ class PrepareOpenTest(unittest.TestCase):
self.assertIn('hasSuffix(h, ".i2p")', pac)
self.assertIn('return "PROXY 127.0.0.1:4444";', pac)
self.assertIn('return "DIRECT";', pac)
+ extension = profile / "extensions/ampb-route-helper@such.software"
+ manifest = (extension / "manifest.json").read_text(encoding="utf-8")
+ background = (extension / "background.js").read_text(encoding="utf-8")
+ setup = (extension / "setup.html").read_text(encoding="utf-8")
+ self.assertIn("webNavigation", manifest)
+ self.assertIn("http://127.0.0.1:44001/", background)
+ self.assertIn("test-token", background)
+ self.assertIn("Set Up Transport", setup)
def test_execute_open_blocks_when_runtime_is_missing(self) -> None:
config = AppConfig(runtime_path="/missing/ampb/firefox", transport_modes={})
diff --git a/tests/test_route_helper.py b/tests/test_route_helper.py
new file mode 100644
index 0000000..6d9e7c2
--- /dev/null
+++ b/tests/test_route_helper.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from ampbrowser.config import AppConfig
+from ampbrowser.route_helper import handle_helper_message
+from ampbrowser.transport_manager import ManagedTransportResult
+
+
+class RouteHelperTest(unittest.TestCase):
+ def test_status_routes_url_to_transport(self) -> None:
+ result = ManagedTransportResult(
+ transport="tor",
+ provider="arti",
+ status="ready",
+ endpoint="socks5://127.0.0.1:9050",
+ owned=True,
+ pid=1234,
+ state_dir=".ampb/transports/tor",
+ command=("/tmp/arti", "proxy"),
+ message="using running AMPB-owned tor",
+ provider_source="bundled-sidecar",
+ )
+
+ with tempfile.TemporaryDirectory() as tmp:
+ with patch("ampbrowser.route_helper.transport_status", return_value=result) as status:
+ response = handle_helper_message(
+ {"action": "status", "url": "http://example.onion/"},
+ root=Path(tmp),
+ config=AppConfig(transport_modes={}),
+ )
+
+ self.assertTrue(response["ok"])
+ self.assertTrue(response["ready"])
+ self.assertEqual("tor", response["transport"])
+ self.assertEqual("bundled-sidecar", response["provider_source"])
+ status.assert_called_once()
+
+ def test_ensure_starts_i2p_transport(self) -> None:
+ result = ManagedTransportResult(
+ transport="i2p",
+ provider="i2pd",
+ status="started",
+ endpoint="http://127.0.0.1:4444",
+ owned=True,
+ pid=1234,
+ state_dir=".ampb/transports/i2p",
+ command=("/opt/i2pd",),
+ message="started managed i2pd transport",
+ provider_source="system-package",
+ )
+
+ with tempfile.TemporaryDirectory() as tmp:
+ with patch("ampbrowser.route_helper.ensure_transport_ready", return_value=result) as ensure:
+ response = handle_helper_message(
+ {"action": "ensure", "transport": "i2p", "url": "http://example.b32.i2p/"},
+ root=Path(tmp),
+ config=AppConfig(transport_modes={}),
+ )
+
+ self.assertTrue(response["ok"])
+ self.assertTrue(response["ready"])
+ self.assertEqual("started", response["status"])
+ ensure.assert_called_once()
+
+ def test_blocks_unsupported_routes(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ response = handle_helper_message(
+ {"action": "ensure", "url": "https://example.com/"},
+ root=Path(tmp),
+ config=AppConfig(transport_modes={}),
+ )
+
+ self.assertFalse(response["ok"])
+ self.assertEqual("unsupported-transport", response["status"])
+
+
+if __name__ == "__main__":
+ unittest.main()