Add isolated transport broker

This commit is contained in:
jwinterm
2026-07-11 09:34:51 -04:00
parent 4379b99245
commit 2c68c072ba
20 changed files with 731 additions and 206 deletions
+4 -3
View File
@@ -21,8 +21,8 @@ capabilities they exercise, while project-specific target notes stay in private
- Prompt on first visit before installing or starting a missing transport.
- Manage approved transports through isolated AMPB state.
- Keep browser profiles isolated by transport.
- Treat route-aware desktop launches as a convenience and QA mode until storage isolation
is proven for mixed clearnet, Tor, and I2P navigation.
- Hand alternate-network links from the clearnet broker to transport-specific browser
profiles before the destination request leaves the broker profile.
- Present transport health in plain language.
## Non-goals
@@ -41,7 +41,7 @@ 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 https://ampgateway.site/ --route-aware --launch
python3 -m ampbrowser open https://ampgateway.site/ --broker --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/
@@ -67,6 +67,7 @@ python3 -m unittest discover -s tests
- [Architecture](docs/architecture.md)
- [Browser builds](docs/browser-builds.md)
- [Transport broker](docs/broker.md)
- [Candidate transports](docs/candidate-transports.md)
- [Configuration](docs/configuration.md)
- [Desktop vertical](docs/desktop-vertical.md)
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from pathlib import Path
from .config import AppConfig
from .launch import OpenPlan, prepare_open
from .open_runner import launch_open_plan
from .routing import route_url
BROKER_TRANSPORTS = {"tor", "i2p"}
class BrokerRouteError(ValueError):
pass
def open_brokered_url(
raw_url: str,
*,
expected_transport: str,
consent: bool,
config: AppConfig,
root: Path,
) -> OpenPlan:
route = route_url(raw_url)
if route.transport != expected_transport:
raise BrokerRouteError(
f"route transport {route.transport} does not match requested transport {expected_transport}"
)
if route.transport not in BROKER_TRANSPORTS:
raise BrokerRouteError(f"{route.transport} is not supported by the desktop broker yet")
open_plan = prepare_open(
route.normalized,
consent=consent,
dry_run=False,
config=config,
platform="desktop",
)
return launch_open_plan(open_plan, config=config, root=root)
+18 -11
View File
@@ -41,9 +41,11 @@ def main(argv: list[str] | None = None) -> int:
help="Approve first-use setup in the dry-run plan.",
)
open_parser.add_argument(
"--broker",
"--route-aware",
dest="broker",
action="store_true",
help="Launch one desktop profile with PAC routing for clearnet, Tor, and I2P.",
help="Launch a clearnet broker that hands Tor and I2P routes to isolated profiles.",
)
open_mode = open_parser.add_mutually_exclusive_group()
open_mode.add_argument(
@@ -66,6 +68,11 @@ def main(argv: list[str] | None = None) -> int:
action="store_true",
help="Approve first-use setup without showing a prompt.",
)
shell_parser.add_argument(
"--broker",
action="store_true",
help="Launch the isolated transport broker in the bundled browser.",
)
subcommands.add_parser("inspect", help="Inspect local transport readiness.")
@@ -116,10 +123,10 @@ def main(argv: list[str] | None = None) -> int:
launch=args.launch,
config_path=args.config,
platform=args.platform,
route_aware=args.route_aware,
broker=args.broker,
)
if args.command == "shell":
return _cmd_shell(args.url, approve=args.yes, config_path=args.config)
return _cmd_shell(args.url, approve=args.yes, broker=args.broker, config_path=args.config)
if args.command == "inspect":
return _cmd_inspect()
if args.command == "transport":
@@ -182,7 +189,7 @@ def _cmd_open(
launch: bool,
config_path: Path | None,
platform: str | None,
route_aware: bool,
broker: bool,
) -> int:
config = load_config(Path.cwd(), config_path)
open_plan = prepare_open(
@@ -191,7 +198,7 @@ def _cmd_open(
dry_run=dry_run,
config=config,
platform=platform,
route_aware=route_aware,
broker=broker,
)
if launch:
open_plan = launch_open_plan(open_plan, config=config, root=Path.cwd())
@@ -199,9 +206,9 @@ def _cmd_open(
return 0
def _cmd_shell(url: str, *, approve: bool, config_path: Path | None) -> int:
def _cmd_shell(url: str, *, approve: bool, broker: bool, config_path: Path | None) -> int:
config = load_config(Path.cwd(), config_path)
result = launch_desktop_shell(url, config=config, root=Path.cwd(), auto_approve=approve)
result = launch_desktop_shell(url, config=config, root=Path.cwd(), auto_approve=approve, broker=broker)
_print_open_plan(
"AMPBROWSER_SHELL",
result.open_plan,
@@ -220,8 +227,7 @@ def _print_open_plan(prefix: str, open_plan, extra_fields: tuple[str, ...] = ())
launch_command = shlex.join(launch_spec.command) if launch_spec else "-"
runtime_path = launch_spec.runtime_path if launch_spec else "-"
user_js_path = launch_spec.user_js_path if launch_spec else "-"
route_aware = str(bool(launch_spec and launch_spec.route_aware)).lower()
pac_path = launch_spec.pac_path if launch_spec else "-"
broker = str(open_plan.broker).lower()
message = _safe(open_plan.message)
extra = (" " + " ".join(extra_fields)) if extra_fields else ""
print(
@@ -240,10 +246,9 @@ def _print_open_plan(prefix: str, open_plan, extra_fields: tuple[str, ...] = ())
f"browser_pid={open_plan.browser_pid} "
f"profile_path={open_plan.profile_path} "
f"proxy={open_plan.proxy} "
f"route_aware={route_aware} "
f"broker={broker} "
f"runtime_path={runtime_path} "
f"user_js_path={user_js_path} "
f"pac_path={pac_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)}\" "
@@ -351,6 +356,8 @@ def _cmd_fixture(args) -> int:
f"actual_transport={check.actual_transport} "
f"expected_profile={check.expected_profile} "
f"actual_profile={check.actual_profile} "
f"expected_isolation={check.expected_isolation} "
f"actual_isolation={check.actual_isolation} "
f"tier={check.tier} "
f"identity={check.identity} "
f"payments={check.payments} "
+2
View File
@@ -22,6 +22,7 @@ class ConfigError(ValueError):
@dataclass(frozen=True)
class AppConfig:
config_path: str = ""
state_dir: str = ".ampb"
default_engine: str = "ampb-gecko"
runtime_path: str = ""
@@ -82,6 +83,7 @@ def _parse_config(data: dict[str, Any], path: Path) -> AppConfig:
transport_binaries = _transport_binaries(transports, path)
return AppConfig(
config_path=str(path.resolve()),
state_dir=state_dir,
default_engine=default_engine,
runtime_path=runtime_path,
+17 -2
View File
@@ -27,9 +27,17 @@ def launch_desktop_shell(
config: AppConfig,
root: Path,
auto_approve: bool = False,
broker: bool = False,
prompt_driver: PromptDriver | None = None,
) -> DesktopShellResult:
open_plan = prepare_open(raw_url, consent=False, dry_run=False, config=config, platform="desktop")
open_plan = prepare_open(
raw_url,
consent=False,
dry_run=False,
config=config,
platform="desktop",
broker=broker,
)
prompt_shown = False
prompt_approved = False
@@ -45,7 +53,14 @@ def launch_desktop_shell(
prompt_shown,
False,
)
open_plan = prepare_open(raw_url, consent=True, dry_run=False, config=config, platform="desktop")
open_plan = prepare_open(
raw_url,
consent=True,
dry_run=False,
config=config,
platform="desktop",
broker=broker,
)
return DesktopShellResult(
launch_open_plan(open_plan, config=config, root=root),
+8
View File
@@ -23,6 +23,8 @@ class FixtureCheck:
actual_transport: str
expected_profile: str
actual_profile: str
expected_isolation: str
actual_isolation: str
tier: str
identity: str
payments: str
@@ -78,15 +80,19 @@ def _check_fixture(site_id: str, fixture: Any, path: Path) -> FixtureCheck:
raise ValueError(f"{path}: {protocol}: missing checks table")
expected_transport = _string(checks, "transport")
expected_profile = _string(checks, "profile")
expected_isolation = _optional_string(checks, "isolation", "transport-profile")
interaction = _interaction_policy(fixture)
route_metadata = _route_metadata(fixture)
route = route_url(url)
actual_isolation = "transport-profile" if route.profile == route.transport else "shared-profile"
failures: list[str] = []
if route.transport != expected_transport:
failures.append(f"transport expected {expected_transport} got {route.transport}")
if route.profile != expected_profile:
failures.append(f"profile expected {expected_profile} got {route.profile}")
if actual_isolation != expected_isolation:
failures.append(f"isolation expected {expected_isolation} got {actual_isolation}")
failures.extend(
interaction_failures(
expected_transport,
@@ -108,6 +114,8 @@ def _check_fixture(site_id: str, fixture: Any, path: Path) -> FixtureCheck:
actual_transport=route.transport,
expected_profile=expected_profile,
actual_profile=route.profile,
expected_isolation=expected_isolation,
actual_isolation=actual_isolation,
tier=interaction["tier"],
identity=interaction["identity"],
payments=interaction["payments"],
+295 -125
View File
@@ -19,12 +19,11 @@ from .config import AppConfig, default_config
from .plan import BrowsePlan, plan_url
ROUTE_AWARE_PROFILE = "route-aware"
ROUTE_AWARE_PAC_NAME = "ampb-proxy.pac"
BROKER_PROFILE = "broker"
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__"
PROFILE_SESSION_NAME = "ampb-session.json"
@dataclass(frozen=True)
@@ -51,9 +50,7 @@ class BrowserLaunchSpec:
user_js_path: str
command: tuple[str, ...]
prefs: tuple[str, ...]
route_aware: bool = False
pac_path: str = "-"
pac_content: str = ""
broker: bool = False
extension_path: str = "-"
extension_assets: tuple[ProfileAsset, ...] = ()
@@ -68,6 +65,7 @@ class OpenPlan:
proxy: str
setup_steps: tuple[str, ...]
message: str
broker: bool = False
launch_spec: BrowserLaunchSpec | None = None
browser_pid: int = 0
setup_prompt_title: str = "-"
@@ -94,18 +92,32 @@ def prepare_open(
dry_run: bool = True,
config: AppConfig | None = None,
platform: str | None = None,
broker: bool = False,
route_aware: bool = False,
) -> OpenPlan:
config = config or default_config()
browse_plan = plan_url(raw_url, config=config, platform=platform)
broker = broker or route_aware
if broker and browse_plan.route.transport != "clearnet":
return OpenPlan(
browse_plan=browse_plan,
status="blocked",
dry_run=dry_run,
consent_granted=consent,
profile_path=config.profile_path(BROKER_PROFILE),
proxy="-",
setup_steps=(),
message="broker entry URLs must use clearnet; alternate routes open in their isolated profile",
broker=True,
)
profile_name = (
ROUTE_AWARE_PROFILE
if route_aware and browse_plan.route.transport in {"clearnet", "tor", "i2p"}
BROKER_PROFILE
if broker
else browse_plan.route.profile
)
profile_path = config.profile_path(profile_name)
proxy = _proxy_for(browse_plan)
launch_spec = _launch_spec_for(browse_plan, config=config, profile_path=profile_path, route_aware=route_aware)
launch_spec = _launch_spec_for(browse_plan, config=config, profile_path=profile_path, broker=broker)
if browse_plan.action.startswith("blocked"):
return OpenPlan(
@@ -117,12 +129,13 @@ def prepare_open(
proxy=proxy,
setup_steps=(),
message=browse_plan.action,
broker=broker,
launch_spec=launch_spec,
)
if browse_plan.requires_consent:
setup_steps = _setup_steps(browse_plan)
setup_prompt = _setup_prompt(browse_plan, setup_steps, route_aware=route_aware)
setup_prompt = _setup_prompt(browse_plan, setup_steps)
if not consent:
return OpenPlan(
browse_plan=browse_plan,
@@ -133,6 +146,7 @@ def prepare_open(
proxy=proxy,
setup_steps=setup_steps,
message=browse_plan.prompt,
broker=broker,
launch_spec=launch_spec,
setup_prompt_title=setup_prompt.title,
setup_prompt_body=setup_prompt.body,
@@ -148,6 +162,7 @@ def prepare_open(
proxy=proxy,
setup_steps=setup_steps,
message="first-use setup approved; browser launch waits for transport readiness",
broker=broker,
launch_spec=launch_spec,
setup_prompt_title=setup_prompt.title,
setup_prompt_body=setup_prompt.body,
@@ -164,11 +179,17 @@ def prepare_open(
proxy=proxy,
setup_steps=(),
message="ready to open with isolated profile",
broker=broker,
launch_spec=launch_spec,
)
def execute_open(open_plan: OpenPlan, *, root: Path | None = None) -> OpenPlan:
def execute_open(
open_plan: OpenPlan,
*,
root: Path | None = None,
config: AppConfig | None = None,
) -> OpenPlan:
root = root or Path.cwd()
spec = open_plan.launch_spec
if spec is None:
@@ -194,17 +215,31 @@ def execute_open(open_plan: OpenPlan, *, root: Path | None = None) -> OpenPlan:
message=f"browser runtime not found: {spec.runtime_path}",
)
route_helper = _route_helper_launch_plan(root) if spec.route_aware else RouteHelperLaunch("disabled")
active_pid = _active_profile_session(root, spec)
if active_pid:
return _open_existing_profile(open_plan, root=root, spec=spec, active_pid=active_pid)
config_path = Path(config.config_path) if config and config.config_path else None
route_helper = (
_route_helper_launch_plan(root, config_path=config_path)
if spec.broker
else RouteHelperLaunch("disabled")
)
_write_profile(root, spec, route_helper=route_helper)
launch_spec = replace(spec, command=_execution_command(root, spec))
process = subprocess.Popen(launch_spec.command, cwd=str(root), start_new_session=True) # noqa: S603
if spec.route_aware:
route_helper = _start_route_helper(root, planned=route_helper, watch_pid=process.pid)
if spec.broker:
route_helper = _start_route_helper(
root,
planned=route_helper,
watch_pid=process.pid,
config_path=config_path,
)
exit_code = _immediate_exit_code(process)
if exit_code is not None:
helper_status = route_helper.status
helper_message = route_helper.message
if spec.route_aware:
if spec.broker:
helper_status = _stop_route_helper(route_helper)
helper_message = "stopped route helper after browser exited immediately"
return replace(
@@ -219,9 +254,10 @@ def execute_open(open_plan: OpenPlan, *, root: Path | None = None) -> OpenPlan:
route_helper_pid=route_helper.pid,
route_helper_message=helper_message,
)
_record_profile_session(root, spec, process.pid)
message = "launched bundled browser"
if spec.route_aware:
message = f"launched bundled browser with route helper {route_helper.status}"
if spec.broker:
message = f"launched isolated transport broker with route helper {route_helper.status}"
return replace(
open_plan,
dry_run=False,
@@ -274,7 +310,7 @@ class _SetupPrompt:
approval_command: str
def _setup_prompt(browse_plan: BrowsePlan, setup_steps: tuple[str, ...], *, route_aware: bool = False) -> _SetupPrompt:
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
@@ -297,8 +333,6 @@ def _setup_prompt(browse_plan: BrowsePlan, setup_steps: tuple[str, ...], *, rout
f"{browse_plan.route.normalized}. Setup plan: {setup_summary}."
)
approval_args = ["ampbrowser", "open", browse_plan.route.normalized, "--yes", "--launch"]
if route_aware:
approval_args.append("--route-aware")
approval_command = shlex.join(approval_args)
return _SetupPrompt(
title=f"Set up {label}?",
@@ -324,27 +358,23 @@ def _launch_spec_for(
*,
config: AppConfig,
profile_path: str,
route_aware: bool = False,
broker: bool = False,
) -> BrowserLaunchSpec | None:
if browse_plan.route.transport not in {"clearnet", "tor", "i2p"}:
return None
runtime_path = _runtime_path(config)
user_js_path = str(Path(profile_path) / "user.js")
command = (runtime_path, "--new-instance", "--profile", profile_path, browse_plan.route.normalized)
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 ()
prefs = _prefs_for(browse_plan, broker=broker)
extension_path = str(Path(profile_path) / "extensions" / ROUTE_HELPER_EXTENSION_ID) if broker else "-"
extension_assets = _route_helper_extension_assets(extension_path) if broker else ()
return BrowserLaunchSpec(
runtime_path=runtime_path,
profile_path=profile_path,
user_js_path=user_js_path,
command=command,
prefs=prefs,
route_aware=route_aware,
pac_path=pac_path,
pac_content=pac_content,
broker=broker,
extension_path=extension_path,
extension_assets=extension_assets,
)
@@ -372,7 +402,7 @@ def _runtime_candidates(build_root: Path) -> tuple[Path, ...]:
)
def _prefs_for(browse_plan: BrowsePlan, *, route_aware: bool = False) -> tuple[str, ...]:
def _prefs_for(browse_plan: BrowsePlan, *, broker: bool = False) -> tuple[str, ...]:
prefs: list[tuple[str, str | int | bool]] = [
("browser.shell.checkDefaultBrowser", False),
("datareporting.policy.dataSubmissionEnabled", False),
@@ -381,14 +411,10 @@ def _prefs_for(browse_plan: BrowsePlan, *, route_aware: bool = False) -> tuple[s
("network.predictor.enabled", False),
]
if route_aware:
if broker:
prefs.extend(
[
("network.proxy.type", 2),
("network.proxy.autoconfig_url", PAC_URL_PLACEHOLDER),
("network.proxy.failover_direct", False),
("network.proxy.socks_remote_dns", True),
("network.proxy.no_proxies_on", ""),
("network.proxy.type", 0),
("extensions.autoDisableScopes", 0),
("extensions.enabledScopes", 5),
("xpinstall.signatures.required", False),
@@ -427,52 +453,6 @@ def _prefs_for(browse_plan: BrowsePlan, *, route_aware: bool = False) -> tuple[s
return tuple(_format_pref(name, value) for name, value in prefs)
def _route_aware_pac() -> str:
tor_proxy = _pac_proxy_for_endpoint(
"SOCKS5",
_endpoint_for_transport("tor", "socks5://127.0.0.1:9050"),
)
i2p_proxy = _pac_proxy_for_endpoint(
"PROXY",
_endpoint_for_transport("i2p", "http://127.0.0.1:4444"),
)
return "\n".join(
(
"// Managed by AMPB. Route-aware proxy policy.",
"function hasSuffix(value, suffix) {",
" return value.length >= suffix.length && value.substring(value.length - suffix.length) === suffix;",
"}",
"",
"function FindProxyForURL(url, host) {",
' var h = (host || "").toLowerCase();',
' if (h.length > 0 && h.charAt(h.length - 1) === ".") {',
" h = h.substring(0, h.length - 1);",
" }",
' if (hasSuffix(h, ".onion")) {',
f' return "{tor_proxy}";',
" }",
' if (hasSuffix(h, ".i2p")) {',
f' return "{i2p_proxy}";',
" }",
' return "DIRECT";',
"}",
"",
)
)
def _endpoint_for_transport(transport: str, fallback: str) -> str:
adapter = adapter_for(transport)
if adapter:
return adapter.endpoint
return fallback
def _pac_proxy_for_endpoint(proxy_type: str, endpoint: str) -> str:
host, port = _endpoint_host_port(endpoint)
return f"{proxy_type} {host}:{port}"
def _route_helper_extension_assets(extension_path: str) -> tuple[ProfileAsset, ...]:
base = Path(extension_path)
return (
@@ -481,6 +461,8 @@ def _route_helper_extension_assets(extension_path: str) -> tuple[ProfileAsset, .
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()),
ProfileAsset(str(base / "handoff.html"), _route_helper_handoff_html()),
ProfileAsset(str(base / "handoff.js"), _route_helper_handoff_js()),
)
@@ -490,11 +472,20 @@ def _route_helper_manifest() -> str:
"manifest_version": 2,
"name": "AMPB Route Helper",
"version": "0.1.0",
"description": "Starts AMPB-managed transports when alternate-network links are opened.",
"description": "Hands alternate-network links to isolated AMPB transport profiles.",
"applications": {"gecko": {"id": ROUTE_HELPER_EXTENSION_ID}},
"permissions": [
"tabs",
"webNavigation",
"webRequest",
"webRequestBlocking",
"http://*.onion/*",
"https://*.onion/*",
"ws://*.onion/*",
"wss://*.onion/*",
"http://*.i2p/*",
"https://*.i2p/*",
"ws://*.i2p/*",
"wss://*.i2p/*",
"http://127.0.0.1/*",
],
"background": {"scripts": ["background.js"]},
@@ -523,44 +514,64 @@ def _route_helper_background_js() -> str:
" return '';",
"}",
"",
"async function helper(action, transport, url) {",
"async function helper(action, transport, url, consent = false) {",
" const response = await fetch(HELPER_URL, {",
" method: 'POST',",
" headers: {",
" 'Content-Type': 'application/json',",
" 'X-AMPB-Token': HELPER_TOKEN,",
" },",
" body: JSON.stringify({ action, transport, url }),",
" body: JSON.stringify({ action, transport, url, consent }),",
" });",
" 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 installCommand = Array.isArray(status.install_command) ? status.install_command.join(' ') : '';",
" const setupUrl = browser.runtime.getURL(",
" `setup.html?transport=${encodeURIComponent(transport)}&url=${encodeURIComponent(details.url)}&message=${encodeURIComponent(status.message || '')}&hint=${encodeURIComponent(status.setup_hint || '')}&install=${encodeURIComponent(installCommand)}`",
" );",
" await browser.tabs.update(details.tabId, { url: setupUrl });",
"function internalUrl(page, transport, url, result) {",
" const installCommand = Array.isArray(result.install_command) ? result.install_command.join(' ') : '';",
" const params = new URLSearchParams({",
" transport,",
" url,",
" message: result.message || '',",
" hint: result.setup_hint || '',",
" install: installCommand,",
" });",
" return browser.runtime.getURL(`${page}.html?${params.toString()}`);",
"}",
"",
"browser.webNavigation.onBeforeNavigate.addListener(handleNavigate);",
"async function handoff(details, transport) {",
" let result;",
" try {",
" result = await helper('open', transport, details.url, false);",
" } catch (error) {",
" result = { ok: false, launched: false, message: String(error) };",
" }",
" const page = result.launched ? 'handoff' : 'setup';",
" try {",
" await browser.tabs.update(details.tabId, { url: internalUrl(page, transport, details.url, result) });",
" } finally {",
" pendingByTab.delete(details.tabId);",
" }",
"}",
"",
"function interceptRequest(details) {",
" const transport = transportForUrl(details.url);",
" if (!transport) return {};",
" if (details.type !== 'main_frame' || details.tabId < 0) return { cancel: true };",
" if (pendingByTab.get(details.tabId) === details.url) return { cancel: true };",
" pendingByTab.set(details.tabId, details.url);",
" void handoff(details, transport);",
" return { cancel: true };",
"}",
"",
"browser.webRequest.onBeforeRequest.addListener(",
" interceptRequest,",
" { urls: ['http://*.onion/*', 'https://*.onion/*', 'ws://*.onion/*', 'wss://*.onion/*', 'http://*.i2p/*', 'https://*.i2p/*', 'ws://*.i2p/*', 'wss://*.i2p/*'] },",
" ['blocking']",
");",
"",
"browser.runtime.onMessage.addListener((message) => {",
" if (!message || message.type !== 'ampb-ensure') return false;",
" return helper('ensure', message.transport, message.url);",
" if (!message || message.type !== 'ampb-open') return false;",
" return helper('open', message.transport, message.url, message.consent === true);",
"});",
"",
)
@@ -631,10 +642,11 @@ def _route_helper_setup_js() -> str:
"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;",
" const result = await browser.runtime.sendMessage({ type: 'ampb-open', transport, url, consent: true });",
" if (result && result.launched) {",
" status.textContent = `${label} opened in its isolated browser profile.`;",
" const params = new URLSearchParams({ transport, url });",
" window.location.href = browser.runtime.getURL(`handoff.html?${params.toString()}`);",
" return;",
" }",
" if (result && Array.isArray(result.install_command)) {",
@@ -664,6 +676,49 @@ def _route_helper_setup_css() -> str:
)
def _route_helper_handoff_html() -> str:
return "\n".join(
(
"<!doctype html>",
'<html lang="en">',
"<head>",
' <meta charset="utf-8">',
' <meta name="viewport" content="width=device-width, initial-scale=1">',
" <title>Route Opened</title>",
' <link rel="stylesheet" href="setup.css">',
"</head>",
"<body>",
' <main class="panel">',
' <p class="eyebrow">AMPB Transport Broker</p>',
' <h1 id="title">Route opened</h1>',
' <p id="body">The destination is open in its isolated transport profile.</p>',
' <pre id="url"></pre>',
" </main>",
' <script src="handoff.js"></script>',
"</body>",
"</html>",
"",
)
)
def _route_helper_handoff_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 label = transport === 'tor' ? 'Tor' : transport === 'i2p' ? 'I2P' : transport;",
"document.getElementById('title').textContent = `${label} route opened`;",
"document.getElementById('body').textContent = `This destination opened in the isolated ${label} profile. This clearnet profile did not load it.`;",
"document.getElementById('url').textContent = url;",
"",
)
)
def _endpoint_host_port(endpoint: str) -> tuple[str, int]:
parsed = urlparse(endpoint)
return parsed.hostname or "127.0.0.1", parsed.port or 0
@@ -679,7 +734,7 @@ def _format_pref(name: str, value: str | int | bool) -> str:
return f"user_pref({json.dumps(name)}, {literal});"
def _route_helper_launch_plan(root: Path) -> RouteHelperLaunch:
def _route_helper_launch_plan(root: Path, *, config_path: Path | None = None) -> RouteHelperLaunch:
port = _free_loopback_port()
token = secrets.token_urlsafe(24)
endpoint = f"http://127.0.0.1:{port}/"
@@ -687,7 +742,7 @@ def _route_helper_launch_plan(root: Path) -> RouteHelperLaunch:
"planned",
endpoint=endpoint,
token=token,
command=_route_helper_command(root=root, port=port, token=token),
command=_route_helper_command(root=root, port=port, token=token, config_path=config_path),
)
@@ -696,10 +751,17 @@ def _start_route_helper(
*,
planned: RouteHelperLaunch | None = None,
watch_pid: int = 0,
config_path: Path | None = None,
) -> RouteHelperLaunch:
helper = planned or _route_helper_launch_plan(root)
helper = planned or _route_helper_launch_plan(root, config_path=config_path)
port = _endpoint_host_port(helper.endpoint)[1]
command = _route_helper_command(root=root, port=port, token=helper.token, watch_pid=watch_pid)
command = _route_helper_command(
root=root,
port=port,
token=helper.token,
watch_pid=watch_pid,
config_path=config_path,
)
try:
process = subprocess.Popen(command, cwd=str(root), start_new_session=True) # noqa: S603
except OSError as exc:
@@ -707,7 +769,14 @@ def _start_route_helper(
return replace(helper, status="started", pid=process.pid, watch_pid=watch_pid, command=command, message="-")
def _route_helper_command(*, root: Path, port: int, token: str, watch_pid: int = 0) -> tuple[str, ...]:
def _route_helper_command(
*,
root: Path,
port: int,
token: str,
watch_pid: int = 0,
config_path: Path | None = None,
) -> tuple[str, ...]:
command = (
sys.executable,
"-m",
@@ -724,6 +793,8 @@ def _route_helper_command(*, root: Path, port: int, token: str, watch_pid: int =
)
if watch_pid > 0:
command = (*command, "--watch-pid", str(watch_pid))
if config_path:
command = (*command, "--config", str(config_path))
return command
@@ -739,6 +810,102 @@ def _execution_command(root: Path, spec: BrowserLaunchSpec) -> tuple[str, ...]:
return tuple(command)
def _open_existing_profile(
open_plan: OpenPlan,
*,
root: Path,
spec: BrowserLaunchSpec,
active_pid: int,
) -> OpenPlan:
runtime_path = str(_path_from(root, spec.runtime_path))
profile_path = str(_path_from(root, spec.profile_path))
command = (runtime_path, "--profile", profile_path, "--new-tab", open_plan.browse_plan.route.normalized)
try:
process = subprocess.Popen(command, cwd=str(root), start_new_session=True) # noqa: S603
except OSError as exc:
return replace(
open_plan,
dry_run=False,
status="profile-reuse-failed",
browser_pid=active_pid,
message=f"could not open route in active {open_plan.browse_plan.route.profile} profile: {exc}",
)
exit_code = _immediate_exit_code(process)
if exit_code == 0:
return replace(
open_plan,
dry_run=False,
launch_spec=replace(spec, command=command),
status="opened-existing",
browser_pid=active_pid,
message=f"opened route in active {open_plan.browse_plan.route.profile} profile",
)
if exit_code is None:
_record_profile_session(root, spec, process.pid)
return replace(
open_plan,
dry_run=False,
launch_spec=replace(spec, command=command),
status="launched",
browser_pid=process.pid,
message=f"launched replacement {open_plan.browse_plan.route.profile} profile process",
)
return replace(
open_plan,
dry_run=False,
launch_spec=replace(spec, command=command),
status="profile-reuse-failed",
browser_pid=active_pid,
message=f"active profile rejected route with code {exit_code}",
)
def _active_profile_session(root: Path, spec: BrowserLaunchSpec) -> int:
session_path = _profile_session_path(root, spec)
try:
data = json.loads(session_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return 0
if not isinstance(data, dict):
return 0
pid = data.get("pid")
runtime_path = data.get("runtime_path")
if not isinstance(pid, int) or pid <= 0 or runtime_path != str(_path_from(root, spec.runtime_path)):
return 0
if _pid_exists(pid):
return pid
try:
session_path.unlink()
except OSError:
pass
return 0
def _record_profile_session(root: Path, spec: BrowserLaunchSpec, pid: int) -> None:
if not isinstance(pid, int) or pid <= 0:
return
session_path = _profile_session_path(root, spec)
session_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"pid": pid,
"profile_path": str(_path_from(root, spec.profile_path)),
"runtime_path": str(_path_from(root, spec.runtime_path)),
}
temporary_path = session_path.with_name(f".{PROFILE_SESSION_NAME}.{pid}.tmp")
try:
temporary_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary_path.replace(session_path)
except OSError:
try:
temporary_path.unlink()
except OSError:
pass
def _profile_session_path(root: Path, spec: BrowserLaunchSpec) -> Path:
return _path_from(root, spec.profile_path) / PROFILE_SESSION_NAME
def _stop_route_helper(route_helper: RouteHelperLaunch) -> str:
if route_helper.pid <= 0:
return route_helper.status
@@ -757,6 +924,16 @@ def _immediate_exit_code(process: subprocess.Popen, *, wait_seconds: float = 1.0
return None
def _pid_exists(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _free_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
@@ -766,13 +943,7 @@ def _free_loopback_port() -> int:
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 = "-"
if spec.route_aware and spec.pac_content:
pac_path = _path_from(root, spec.pac_path)
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:
if spec.broker:
helper = route_helper or RouteHelperLaunch("disabled")
for asset in spec.extension_assets:
asset_path = _path_from(root, asset.path)
@@ -782,7 +953,6 @@ def _write_profile(root: Path, spec: BrowserLaunchSpec, *, route_helper: RouteHe
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)
user_js_path.write_text(text, encoding="utf-8")
+1 -1
View File
@@ -29,4 +29,4 @@ def launch_open_plan(open_plan: OpenPlan, *, config: AppConfig, root: Path) -> O
transport_setup_endpoint=transport_result.endpoint,
transport_setup_message=transport_result.message,
)
return execute_open(open_plan, root=root)
return execute_open(open_plan, root=root, config=config)
+46
View File
@@ -8,7 +8,9 @@ import threading
import time
from typing import Any
from .broker import BrokerRouteError, open_brokered_url
from .config import AppConfig, load_config
from .launch import OpenPlan
from .routing import route_url
from .transport_manager import ManagedTransportResult, ensure_transport_ready, transport_status
@@ -33,6 +35,24 @@ def handle_helper_message(
return _result_response(transport_status(transport, config=config, root=root))
if action == "ensure":
return _result_response(ensure_transport_ready(transport, config=config, root=root))
if action == "open":
raw_url = _string(message.get("url"))
if not raw_url:
return _error_response("missing-url", "broker open requires a URL")
try:
open_plan = open_brokered_url(
raw_url,
expected_transport=transport,
consent=message.get("consent") is True,
config=config,
root=root,
)
except BrokerRouteError as exc:
return _error_response("route-mismatch", str(exc))
transport_result = None
if open_plan.status not in {"launched", "opened-existing"}:
transport_result = transport_status(transport, config=config, root=root)
return _open_response(open_plan, transport_result)
return _error_response("unsupported-action", f"unsupported helper action: {action or '-'}")
@@ -131,6 +151,32 @@ def _result_response(result: ManagedTransportResult) -> dict[str, Any]:
}
def _open_response(
open_plan: OpenPlan,
transport_result: ManagedTransportResult | None,
) -> dict[str, Any]:
route = open_plan.browse_plan.route
opened = open_plan.status in {"launched", "opened-existing"}
return {
"ok": True,
"transport": route.transport,
"profile": route.profile,
"status": open_plan.status,
"ready": opened,
"launched": opened,
"requires_consent": open_plan.status == "consent-required",
"consent_granted": open_plan.consent_granted,
"browser_pid": open_plan.browser_pid,
"profile_path": open_plan.profile_path,
"message": open_plan.message,
"setup_prompt_title": open_plan.setup_prompt_title,
"setup_prompt_body": open_plan.setup_prompt_body,
"setup_prompt_approve_label": open_plan.setup_prompt_approve_label,
"setup_hint": transport_result.setup_hint if transport_result else "-",
"install_command": list(transport_result.install_command) if transport_result else [],
}
def _error_response(status: str, message: str) -> dict[str, Any]:
return {
"ok": False,
+25 -4
View File
@@ -1,6 +1,6 @@
# Architecture
> Status: draft | Updated 2026-07-07 | Applies to: AMPB contributors and integrators
> Status: draft | Updated 2026-07-11 | Applies to: AMPB contributors and integrators
AMPB is a browser orchestration layer for privacy, resilient-network, content-addressed,
and alternate-web transports. It keeps the routing, transport management, and profile
@@ -16,12 +16,20 @@ isolation decisions outside the browser engine until those contracts are stable.
6. Start or install managed transports only after user consent.
7. Open the URL with the matching isolated profile.
The clearnet entry profile is a broker, not a mixed-network profile. It uses direct
clearnet networking. Its extension blocks all `.onion` and `.i2p` requests from that
profile. For a top-level navigation, it asks a token-gated loopback helper to apply the
normal route and consent policy, then opens the destination in a separate transport
profile and browser process.
## Main Components
- `ampbrowser.routing`: deterministic URL-to-transport rules.
- `ampbrowser.adapters`: transport ownership, setup, inspection, and health contracts.
- `ampbrowser.transports`: local readiness checks for Tor, I2P, IPFS, Reticulum, and Gemini.
- `ampbrowser.plan`: dry-run action planner for browser launches.
- `ampbrowser.broker`: validated handoff from the clearnet entry profile to an isolated
transport profile.
- `ampbrowser.launch`: side-effect-free launch specs and first-use consent state.
- `ampbrowser.platforms`: platform capability matrix for desktop, Android, and iOS.
- `ampbrowser.candidates`: evaluated transport candidates before active adapter support.
@@ -49,8 +57,21 @@ Generated backend metadata lives in
The first implementation path is documented in [desktop vertical](desktop-vertical.md).
## Isolation Boundaries
- Browser storage is isolated by transport profile.
- Transport runtime state is isolated under AMPB-owned transport directories when AMPB
manages the daemon.
- A transport profile remains sticky: ordinary navigation stays in that profile and uses
its configured proxy until the user explicitly starts a different context.
- The loopback helper accepts a random per-launch token and recomputes the URL route before
opening it. A caller cannot label an I2P URL as Tor or vice versa.
These controls prevent accidental browser-state mixing. They do not by themselves provide
Tor Browser fingerprinting resistance or make non-anonymous transports anonymous.
## Relationship To AMPG
AMPG publishes protocol-specific site variants. AMPB opens those variants with the right
transport and profile. Wownero is the shared fixture for static publishing, while
interactive targets exercise sessions, dynamic assets, forms, and script policy.
AMPG publishes protocol-specific site variants and declares their expected transport,
profile, and isolation mode in fixture manifests. AMPB validates and enforces the browser
side of that contract. AMPG never controls local browser daemon ownership or profile state.
+44
View File
@@ -0,0 +1,44 @@
# Transport Broker
> Status: draft | Updated 2026-07-11 | Applies to: AMPB desktop users and implementers
The transport broker is AMPB's clearnet entry profile. It gives users one place to begin
without combining clearnet, Tor, and I2P browser storage or proxy policy.
## Handoff
1. The broker profile begins a top-level navigation.
2. Its extension recognizes `.onion` or `.i2p` and cancels the request.
3. A token-gated loopback helper independently routes the URL.
4. If needed, AMPB asks for first-use consent and starts or adopts the transport.
5. AMPB opens the URL in `.ampb/profiles/tor` or `.ampb/profiles/i2p` with that profile's
proxy policy.
6. The broker tab reports that the route opened in an isolated context.
AMPB records the process it launches for each exact profile. Later handoffs reuse only a
live process recorded for that profile; otherwise AMPB launches a new isolated process.
The helper rejects a handoff when the supplied transport does not match the URL. It is
tied to the broker browser process and stops when that process exits.
Subresources, frames, and background requests to alternate-network hosts are blocked in
the clearnet broker and are not converted into handoffs. Only top-level navigation can
open another transport context.
## Boundaries
The broker separates cookies, cache, history, service workers, and other profile storage
by transport. AMPB-managed daemon state is separately stored under
`.ampb/transports/<transport>`.
Transport isolation is not an anonymity claim. Tor browsing still requires Tor Browser
hardening and update parity before AMPB can claim equivalent fingerprinting resistance.
I2P and Reticulum have different threat models and must be described on their own terms.
## Command
```sh
python3 -m ampbrowser open https://ampgateway.site/ --broker --launch
```
`--route-aware` is retained as a compatibility alias for `--broker`.
+5 -3
View File
@@ -27,9 +27,10 @@ artifacts are available for the selected checkout.
## Transport Path
The first transport target is Tor through an AMPB-owned Arti/Tor runtime. The current
desktop vertical can launch a bundled Gecko runtime, start a route-aware helper, and keep
the helper tied to the browser process. I2P uses the same route, consent, state, and
health-check model. Reticulum follows after the resilient-network adapter is concrete.
desktop vertical can launch a bundled Gecko runtime, start the isolated transport broker,
and keep its token-gated helper tied to the broker process. Tor and I2P destinations launch
in their own browser profiles after route validation and consent. Reticulum follows after
the resilient-network adapter and renderer handoff are concrete.
## Desktop Shell
@@ -41,4 +42,5 @@ approval. Use `--yes` for scripted verification without showing the dialog.
```sh
python3 -m ampbrowser shell http://example.onion/
python3 -m ampbrowser shell http://example.onion/ --yes
python3 -m ampbrowser shell https://ampgateway.site/ --broker
```
+7 -5
View File
@@ -1,10 +1,11 @@
# Fixture Manifests
> Status: draft | Updated 2026-07-06 | Applies to: AMPB and AMPG compatibility checks
> Status: draft | Updated 2026-07-11 | Applies to: AMPB and AMPG compatibility checks
AMPB can check AMPG fixture manifests without contacting any network. The check confirms
that each generated fixture URL routes to the expected AMPB transport and profile, and
that its declared interaction policy fits that transport.
that each generated fixture URL routes to the expected AMPB transport and profile, uses
the declared transport-profile isolation contract, and fits that transport's interaction
policy.
Route-expanded fixtures may include `route.match` and `route.fixture_path`, which identify
the route group that produced the fixture.
@@ -20,8 +21,9 @@ manifest.
## Scope
Fixture checks validate route selection and declared interaction policy only. They do not
verify daemon health, hidden service reachability, IPFS pinning, TLS, or content freshness.
Fixture checks validate route selection, profile isolation expectations, and declared
interaction policy only. They do not verify process separation, daemon health, hidden
service reachability, IPFS pinning, TLS, or content freshness.
The manifest must not contain private deployment notes, service keys, credentials, or
host inventory.
+7 -8
View File
@@ -34,13 +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 <url> --route-aware --launch` creates a desktop convenience profile for
clearnet, Tor, and I2P QA. The profile uses a PAC file for `.onion` and `.i2p` routing,
installs the AMPB route-helper extension, and starts a token-gated loopback helper tied
to the browser process. 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. This mode proves routing and first-use setup; it
is not the final per-transport storage isolation model.
`ampbrowser open <clearnet-url> --broker --launch` creates the desktop entry profile. The
broker has direct clearnet networking and no mixed-transport PAC. Its extension cancels
all `.onion` and `.i2p` requests before they leave the profile. For a top-level navigation,
it asks a token-gated loopback helper to open the URL in the isolated Tor or I2P profile.
Missing transports use the same first-use consent flow before the handoff. `--route-aware`
remains a compatibility alias for `--broker`.
## Examples
@@ -51,7 +50,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 https://ampgateway.site/ --broker --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/
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from ampbrowser.broker import BrokerRouteError, open_brokered_url
from ampbrowser.config import AppConfig
from ampbrowser.transports import TransportStatus
class BrokerTest(unittest.TestCase):
def test_broker_launches_matching_route_without_shared_profile(self) -> None:
status = TransportStatus(
transport="tor",
installed=True,
running=True,
endpoint="socks5://127.0.0.1:9050",
adoptable=True,
manage_supported=True,
note="Tor SOCKS proxy",
)
with tempfile.TemporaryDirectory() as tmp:
with patch("ampbrowser.plan.inspect_transport", return_value=status):
with patch("ampbrowser.broker.launch_open_plan", side_effect=lambda plan, **_: plan) as launch:
result = open_brokered_url(
"http://example.onion/",
expected_transport="tor",
consent=False,
config=AppConfig(transport_modes={}),
root=Path(tmp),
)
self.assertEqual("ready", result.status)
self.assertEqual(".ampb/profiles/tor", result.profile_path)
self.assertFalse(result.launch_spec.broker)
launch.assert_called_once()
def test_broker_rejects_claimed_transport_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(BrokerRouteError):
open_brokered_url(
"http://example.b32.i2p/",
expected_transport="tor",
consent=False,
config=AppConfig(transport_modes={}),
root=Path(tmp),
)
if __name__ == "__main__":
unittest.main()
+13 -6
View File
@@ -63,7 +63,7 @@ class CliOpenTest(unittest.TestCase):
provider_source="bundled-sidecar",
)
def fake_execute(open_plan, *, root: Path):
def fake_execute(open_plan, *, root: Path, config: AppConfig):
return replace(
open_plan,
status="launched",
@@ -108,15 +108,22 @@ class CliOpenTest(unittest.TestCase):
self.assertIn("prompt_shown=false", output)
self.assertIn("prompt_approved=false", output)
def test_route_aware_open_prints_profile_and_pac_path(self) -> None:
def test_broker_open_prints_isolated_broker_profile(self) -> None:
with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout:
code = main(["open", "https://ampgateway.site/", "--broker"])
self.assertEqual(0, code)
output = stdout.getvalue()
self.assertIn("broker=true", output)
self.assertIn("profile_path=.ampb/profiles/broker", output)
self.assertNotIn("pac_path=", output)
def test_route_aware_flag_remains_broker_alias(self) -> None:
with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout:
code = main(["open", "https://ampgateway.site/", "--route-aware"])
self.assertEqual(0, code)
output = stdout.getvalue()
self.assertIn("route_aware=true", output)
self.assertIn("profile_path=.ampb/profiles/route-aware", output)
self.assertIn("pac_path=.ampb/profiles/route-aware/ampb-proxy.pac", output)
self.assertIn("broker=true", stdout.getvalue())
def test_hidden_helper_command_passes_watch_pid(self) -> None:
with patch("ampbrowser.cli.serve_route_helper") as helper:
+1
View File
@@ -40,6 +40,7 @@ enabled = false
config = load_config(Path(tmp), path)
self.assertEqual(str(path.resolve()), config.config_path)
self.assertEqual(".local/ampb", config.state_dir)
self.assertEqual("/opt/ampb/firefox", config.runtime_path)
self.assertEqual("adopt", config.transport_mode("tor"))
+6 -1
View File
@@ -26,7 +26,11 @@ class FixtureCheckTest(unittest.TestCase):
{
"protocol": "tor",
"url": "http://wownero.onion/",
"checks": {"transport": "tor", "profile": "tor"},
"checks": {
"transport": "tor",
"profile": "tor",
"isolation": "transport-profile",
},
},
{
"protocol": "ipfs",
@@ -45,6 +49,7 @@ class FixtureCheckTest(unittest.TestCase):
self.assertTrue(result.ok)
self.assertEqual(3, len(result.checks))
self.assertEqual("transport-profile", result.checks[1].actual_isolation)
self.assertEqual("/snapshot/*", result.checks[2].route_match)
self.assertEqual("/snapshot/", result.checks[2].fixture_path)
+85 -37
View File
@@ -6,11 +6,22 @@ import tempfile
from pathlib import Path
from ampbrowser.config import AppConfig
from ampbrowser.launch import RouteHelperLaunch, execute_open, prepare_open
from ampbrowser.launch import RouteHelperLaunch, _route_helper_command, execute_open, prepare_open
from ampbrowser.transports import TransportStatus
class PrepareOpenTest(unittest.TestCase):
def test_route_helper_command_preserves_selected_config(self) -> None:
command = _route_helper_command(
root=Path("/tmp/ampb-root"),
port=44001,
token="test-token",
watch_pid=9876,
config_path=Path("/tmp/ampb-config.toml"),
)
self.assertEqual(("--config", "/tmp/ampb-config.toml"), command[-2:])
def test_clearnet_is_ready_without_consent(self) -> None:
config = AppConfig(runtime_path="/opt/ampb/firefox", transport_modes={})
@@ -130,25 +141,25 @@ class PrepareOpenTest(unittest.TestCase):
self.assertTrue(user_js.exists())
self.assertIn('user_pref("network.proxy.type", 0);', user_js.read_text(encoding="utf-8"))
def test_route_aware_open_writes_pac_profile(self) -> None:
def test_broker_open_writes_direct_profile_and_handoff_extension(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={})
plan = prepare_open("https://ampgateway.site/", config=config, route_aware=True)
plan = prepare_open("https://ampgateway.site/", config=config, broker=True)
self.assertEqual(".ampb/profiles/route-aware", plan.profile_path)
self.assertEqual(".ampb/profiles/broker", plan.profile_path)
self.assertIsNotNone(plan.launch_spec)
self.assertTrue(plan.launch_spec.route_aware)
self.assertEqual(".ampb/profiles/route-aware/ampb-proxy.pac", plan.launch_spec.pac_path)
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))
self.assertTrue(plan.launch_spec.broker)
self.assertIn('user_pref("network.proxy.type", 0);', plan.launch_spec.prefs)
planned_helper = RouteHelperLaunch("planned", endpoint="http://127.0.0.1:44001/", token="test-token")
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)
with patch("ampbrowser.launch._route_helper_launch_plan", return_value=planned_helper):
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)
@@ -159,33 +170,39 @@ class PrepareOpenTest(unittest.TestCase):
str(runtime),
"--new-instance",
"--profile",
str(root / ".ampb/profiles/route-aware"),
str(root / ".ampb/profiles/broker"),
"https://ampgateway.site/",
),
cwd=str(root),
start_new_session=True,
)
profile = root / ".ampb/profiles/route-aware"
profile = root / ".ampb/profiles/broker"
user_js = (profile / "user.js").read_text(encoding="utf-8")
pac = (profile / "ampb-proxy.pac").read_text(encoding="utf-8")
self.assertIn((profile / "ampb-proxy.pac").resolve().as_uri(), user_js)
self.assertNotIn("__AMPB_ROUTE_AWARE_PAC_URL__", user_js)
self.assertIn('hasSuffix(h, ".onion")', pac)
self.assertIn('return "SOCKS5 127.0.0.1:9050";', pac)
self.assertIn('hasSuffix(h, ".i2p")', pac)
self.assertIn('return "PROXY 127.0.0.1:4444";', pac)
self.assertIn('return "DIRECT";', pac)
self.assertIn('user_pref("network.proxy.type", 0);', user_js)
self.assertFalse((profile / "ampb-proxy.pac").exists())
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("webRequestBlocking", manifest)
self.assertIn("http://*.onion/*", manifest)
self.assertIn("wss://*.i2p/*", manifest)
self.assertIn("cancel: true", background)
self.assertIn("helper('open'", background)
self.assertIn("install_command", background)
self.assertIn("http://127.0.0.1:", background)
self.assertNotIn("__AMPB_ROUTE_HELPER_URL__", background)
self.assertNotIn("__AMPB_ROUTE_HELPER_TOKEN__", background)
self.assertIn("Set Up Transport", setup)
self.assertIn("install-command", setup)
self.assertTrue((extension / "handoff.html").exists())
def test_broker_entry_rejects_alternate_transport_url(self) -> None:
plan = prepare_open("http://example.onion/", broker=True)
self.assertEqual("blocked", plan.status)
self.assertEqual(".ampb/profiles/broker", plan.profile_path)
self.assertIn("broker entry URLs must use clearnet", plan.message)
def test_execute_open_blocks_when_runtime_is_missing(self) -> None:
config = AppConfig(runtime_path="/missing/ampb/firefox", transport_modes={})
@@ -196,21 +213,50 @@ class PrepareOpenTest(unittest.TestCase):
self.assertEqual("runtime-missing", launched.status)
self.assertIn("browser runtime not found", launched.message)
def test_route_aware_launch_stops_helper_when_browser_exits_immediately(self) -> None:
def test_execute_open_reuses_recorded_profile_process(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={})
plan = prepare_open("https://ampgateway.site/", config=config, route_aware=True)
plan = prepare_open("https://example.com/", config=config)
with patch("ampbrowser.launch._active_profile_session", return_value=4321):
with patch("ampbrowser.launch.subprocess.Popen") as popen:
popen.return_value.poll.return_value = 0
opened = execute_open(plan, root=root)
self.assertEqual("opened-existing", opened.status)
self.assertEqual(4321, opened.browser_pid)
popen.assert_called_once_with(
(
str(runtime),
"--profile",
str(root / ".ampb/profiles/clearnet"),
"--new-tab",
"https://example.com/",
),
cwd=str(root),
start_new_session=True,
)
def test_broker_launch_stops_helper_when_browser_exits_immediately(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={})
plan = prepare_open("https://ampgateway.site/", config=config, broker=True)
planned_helper = RouteHelperLaunch("planned", endpoint="http://127.0.0.1:44001/", token="test-token")
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._stop_route_helper", return_value="stopped") as stop_helper:
with patch("ampbrowser.launch.subprocess.Popen") as popen:
popen.return_value.pid = 9876
popen.return_value.poll.return_value = 1
launched = execute_open(plan, root=root)
with patch("ampbrowser.launch._route_helper_launch_plan", return_value=planned_helper):
with patch("ampbrowser.launch._start_route_helper", return_value=helper):
with patch("ampbrowser.launch._stop_route_helper", return_value="stopped") as stop_helper:
with patch("ampbrowser.launch.subprocess.Popen") as popen:
popen.return_value.pid = 9876
popen.return_value.poll.return_value = 1
launched = execute_open(plan, root=root)
self.assertEqual("browser-exited", launched.status)
self.assertEqual(9876, launched.browser_pid)
@@ -218,20 +264,22 @@ class PrepareOpenTest(unittest.TestCase):
self.assertIn("browser exited immediately", launched.message)
stop_helper.assert_called_once_with(helper)
def test_route_aware_launch_starts_helper_with_browser_pid(self) -> None:
def test_broker_launch_starts_helper_with_browser_pid(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={})
plan = prepare_open("https://ampgateway.site/", config=config, route_aware=True)
plan = prepare_open("https://ampgateway.site/", config=config, broker=True)
planned_helper = RouteHelperLaunch("planned", endpoint="http://127.0.0.1:44001/", token="test-token")
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) as start_helper:
with patch("ampbrowser.launch.subprocess.Popen") as popen:
popen.return_value.pid = 9876
popen.return_value.poll.return_value = None
launched = execute_open(plan, root=root)
with patch("ampbrowser.launch._route_helper_launch_plan", return_value=planned_helper):
with patch("ampbrowser.launch._start_route_helper", return_value=helper) as start_helper:
with patch("ampbrowser.launch.subprocess.Popen") as popen:
popen.return_value.pid = 9876
popen.return_value.poll.return_value = None
launched = execute_open(plan, root=root)
self.assertEqual("launched", launched.status)
start_helper.assert_called_once()
+52
View File
@@ -1,13 +1,16 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from ampbrowser.config import AppConfig
from ampbrowser.launch import prepare_open
from ampbrowser.route_helper import handle_helper_message
from ampbrowser.transport_manager import ManagedTransportResult
from ampbrowser.transports import TransportStatus
class RouteHelperTest(unittest.TestCase):
@@ -81,6 +84,55 @@ class RouteHelperTest(unittest.TestCase):
self.assertFalse(response["ok"])
self.assertEqual("unsupported-transport", response["status"])
def test_open_hands_route_to_isolated_transport_profile(self) -> None:
status = TransportStatus(
transport="tor",
installed=True,
running=True,
endpoint="socks5://127.0.0.1:9050",
adoptable=True,
manage_supported=True,
note="Tor SOCKS proxy",
)
with patch("ampbrowser.plan.inspect_transport", return_value=status):
plan = prepare_open("http://example.onion/")
launched = replace(plan, status="launched", dry_run=False, browser_pid=4321)
with tempfile.TemporaryDirectory() as tmp:
with patch("ampbrowser.route_helper.open_brokered_url", return_value=launched) as broker_open:
response = handle_helper_message(
{
"action": "open",
"transport": "tor",
"url": "http://example.onion/",
"consent": True,
},
root=Path(tmp),
config=AppConfig(transport_modes={}),
)
self.assertTrue(response["ok"])
self.assertTrue(response["launched"])
self.assertEqual("tor", response["profile"])
self.assertEqual(".ampb/profiles/tor", response["profile_path"])
self.assertEqual(4321, response["browser_pid"])
broker_open.assert_called_once()
def test_open_rejects_transport_url_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
response = handle_helper_message(
{
"action": "open",
"transport": "tor",
"url": "http://example.b32.i2p/",
},
root=Path(tmp),
config=AppConfig(transport_modes={}),
)
self.assertFalse(response["ok"])
self.assertEqual("route-mismatch", response["status"])
if __name__ == "__main__":
unittest.main()