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. - Prompt on first visit before installing or starting a missing transport.
- Manage approved transports through isolated AMPB state. - Manage approved transports through isolated AMPB state.
- Keep browser profiles isolated by transport. - Keep browser profiles isolated by transport.
- Treat route-aware desktop launches as a convenience and QA mode until storage isolation - Hand alternate-network links from the clearnet broker to transport-specific browser
is proven for mixed clearnet, Tor, and I2P navigation. profiles before the destination request leaves the broker profile.
- Present transport health in plain language. - Present transport health in plain language.
## Non-goals ## Non-goals
@@ -41,7 +41,7 @@ python3 -m ampbrowser route ipfs://bafyexample/
python3 -m ampbrowser inspect python3 -m ampbrowser inspect
python3 -m ampbrowser plan gemini://wownero.org/ python3 -m ampbrowser plan gemini://wownero.org/
python3 -m ampbrowser open https://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/ --launch
python3 -m ampbrowser open http://example.onion/ --yes --launch python3 -m ampbrowser open http://example.onion/ --yes --launch
python3 -m ampbrowser shell http://example.onion/ python3 -m ampbrowser shell http://example.onion/
@@ -67,6 +67,7 @@ python3 -m unittest discover -s tests
- [Architecture](docs/architecture.md) - [Architecture](docs/architecture.md)
- [Browser builds](docs/browser-builds.md) - [Browser builds](docs/browser-builds.md)
- [Transport broker](docs/broker.md)
- [Candidate transports](docs/candidate-transports.md) - [Candidate transports](docs/candidate-transports.md)
- [Configuration](docs/configuration.md) - [Configuration](docs/configuration.md)
- [Desktop vertical](docs/desktop-vertical.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.", help="Approve first-use setup in the dry-run plan.",
) )
open_parser.add_argument( open_parser.add_argument(
"--broker",
"--route-aware", "--route-aware",
dest="broker",
action="store_true", 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 = open_parser.add_mutually_exclusive_group()
open_mode.add_argument( open_mode.add_argument(
@@ -66,6 +68,11 @@ def main(argv: list[str] | None = None) -> int:
action="store_true", action="store_true",
help="Approve first-use setup without showing a prompt.", 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.") subcommands.add_parser("inspect", help="Inspect local transport readiness.")
@@ -116,10 +123,10 @@ def main(argv: list[str] | None = None) -> int:
launch=args.launch, launch=args.launch,
config_path=args.config, config_path=args.config,
platform=args.platform, platform=args.platform,
route_aware=args.route_aware, broker=args.broker,
) )
if args.command == "shell": 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": if args.command == "inspect":
return _cmd_inspect() return _cmd_inspect()
if args.command == "transport": if args.command == "transport":
@@ -182,7 +189,7 @@ def _cmd_open(
launch: bool, launch: bool,
config_path: Path | None, config_path: Path | None,
platform: str | None, platform: str | None,
route_aware: bool, broker: bool,
) -> int: ) -> int:
config = load_config(Path.cwd(), config_path) config = load_config(Path.cwd(), config_path)
open_plan = prepare_open( open_plan = prepare_open(
@@ -191,7 +198,7 @@ def _cmd_open(
dry_run=dry_run, dry_run=dry_run,
config=config, config=config,
platform=platform, platform=platform,
route_aware=route_aware, broker=broker,
) )
if launch: if launch:
open_plan = launch_open_plan(open_plan, config=config, root=Path.cwd()) open_plan = launch_open_plan(open_plan, config=config, root=Path.cwd())
@@ -199,9 +206,9 @@ def _cmd_open(
return 0 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) 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( _print_open_plan(
"AMPBROWSER_SHELL", "AMPBROWSER_SHELL",
result.open_plan, 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 "-" launch_command = shlex.join(launch_spec.command) if launch_spec else "-"
runtime_path = launch_spec.runtime_path 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 "-" user_js_path = launch_spec.user_js_path if launch_spec else "-"
route_aware = str(bool(launch_spec and launch_spec.route_aware)).lower() broker = str(open_plan.broker).lower()
pac_path = launch_spec.pac_path if launch_spec else "-"
message = _safe(open_plan.message) message = _safe(open_plan.message)
extra = (" " + " ".join(extra_fields)) if extra_fields else "" extra = (" " + " ".join(extra_fields)) if extra_fields else ""
print( 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"browser_pid={open_plan.browser_pid} "
f"profile_path={open_plan.profile_path} " f"profile_path={open_plan.profile_path} "
f"proxy={open_plan.proxy} " f"proxy={open_plan.proxy} "
f"route_aware={route_aware} " f"broker={broker} "
f"runtime_path={runtime_path} " f"runtime_path={runtime_path} "
f"user_js_path={user_js_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_title=\"{_safe(open_plan.setup_prompt_title)}\" "
f"setup_prompt_body=\"{_safe(open_plan.setup_prompt_body)}\" " 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_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"actual_transport={check.actual_transport} "
f"expected_profile={check.expected_profile} " f"expected_profile={check.expected_profile} "
f"actual_profile={check.actual_profile} " f"actual_profile={check.actual_profile} "
f"expected_isolation={check.expected_isolation} "
f"actual_isolation={check.actual_isolation} "
f"tier={check.tier} " f"tier={check.tier} "
f"identity={check.identity} " f"identity={check.identity} "
f"payments={check.payments} " f"payments={check.payments} "
+2
View File
@@ -22,6 +22,7 @@ class ConfigError(ValueError):
@dataclass(frozen=True) @dataclass(frozen=True)
class AppConfig: class AppConfig:
config_path: str = ""
state_dir: str = ".ampb" state_dir: str = ".ampb"
default_engine: str = "ampb-gecko" default_engine: str = "ampb-gecko"
runtime_path: str = "" runtime_path: str = ""
@@ -82,6 +83,7 @@ def _parse_config(data: dict[str, Any], path: Path) -> AppConfig:
transport_binaries = _transport_binaries(transports, path) transport_binaries = _transport_binaries(transports, path)
return AppConfig( return AppConfig(
config_path=str(path.resolve()),
state_dir=state_dir, state_dir=state_dir,
default_engine=default_engine, default_engine=default_engine,
runtime_path=runtime_path, runtime_path=runtime_path,
+17 -2
View File
@@ -27,9 +27,17 @@ def launch_desktop_shell(
config: AppConfig, config: AppConfig,
root: Path, root: Path,
auto_approve: bool = False, auto_approve: bool = False,
broker: bool = False,
prompt_driver: PromptDriver | None = None, prompt_driver: PromptDriver | None = None,
) -> DesktopShellResult: ) -> 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_shown = False
prompt_approved = False prompt_approved = False
@@ -45,7 +53,14 @@ def launch_desktop_shell(
prompt_shown, prompt_shown,
False, 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( return DesktopShellResult(
launch_open_plan(open_plan, config=config, root=root), launch_open_plan(open_plan, config=config, root=root),
+8
View File
@@ -23,6 +23,8 @@ class FixtureCheck:
actual_transport: str actual_transport: str
expected_profile: str expected_profile: str
actual_profile: str actual_profile: str
expected_isolation: str
actual_isolation: str
tier: str tier: str
identity: str identity: str
payments: 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") raise ValueError(f"{path}: {protocol}: missing checks table")
expected_transport = _string(checks, "transport") expected_transport = _string(checks, "transport")
expected_profile = _string(checks, "profile") expected_profile = _string(checks, "profile")
expected_isolation = _optional_string(checks, "isolation", "transport-profile")
interaction = _interaction_policy(fixture) interaction = _interaction_policy(fixture)
route_metadata = _route_metadata(fixture) route_metadata = _route_metadata(fixture)
route = route_url(url) route = route_url(url)
actual_isolation = "transport-profile" if route.profile == route.transport else "shared-profile"
failures: list[str] = [] failures: list[str] = []
if route.transport != expected_transport: if route.transport != expected_transport:
failures.append(f"transport expected {expected_transport} got {route.transport}") failures.append(f"transport expected {expected_transport} got {route.transport}")
if route.profile != expected_profile: if route.profile != expected_profile:
failures.append(f"profile expected {expected_profile} got {route.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( failures.extend(
interaction_failures( interaction_failures(
expected_transport, expected_transport,
@@ -108,6 +114,8 @@ def _check_fixture(site_id: str, fixture: Any, path: Path) -> FixtureCheck:
actual_transport=route.transport, actual_transport=route.transport,
expected_profile=expected_profile, expected_profile=expected_profile,
actual_profile=route.profile, actual_profile=route.profile,
expected_isolation=expected_isolation,
actual_isolation=actual_isolation,
tier=interaction["tier"], tier=interaction["tier"],
identity=interaction["identity"], identity=interaction["identity"],
payments=interaction["payments"], payments=interaction["payments"],
+295 -125
View File
@@ -19,12 +19,11 @@ from .config import AppConfig, default_config
from .plan import BrowsePlan, plan_url from .plan import BrowsePlan, plan_url
ROUTE_AWARE_PROFILE = "route-aware" BROKER_PROFILE = "broker"
ROUTE_AWARE_PAC_NAME = "ampb-proxy.pac"
ROUTE_HELPER_EXTENSION_ID = "ampb-route-helper@such.software" 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_URL_PLACEHOLDER = "__AMPB_ROUTE_HELPER_URL__"
HELPER_TOKEN_PLACEHOLDER = "__AMPB_ROUTE_HELPER_TOKEN__" HELPER_TOKEN_PLACEHOLDER = "__AMPB_ROUTE_HELPER_TOKEN__"
PROFILE_SESSION_NAME = "ampb-session.json"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -51,9 +50,7 @@ class BrowserLaunchSpec:
user_js_path: str user_js_path: str
command: tuple[str, ...] command: tuple[str, ...]
prefs: tuple[str, ...] prefs: tuple[str, ...]
route_aware: bool = False broker: bool = False
pac_path: str = "-"
pac_content: str = ""
extension_path: str = "-" extension_path: str = "-"
extension_assets: tuple[ProfileAsset, ...] = () extension_assets: tuple[ProfileAsset, ...] = ()
@@ -68,6 +65,7 @@ class OpenPlan:
proxy: str proxy: str
setup_steps: tuple[str, ...] setup_steps: tuple[str, ...]
message: str message: str
broker: bool = False
launch_spec: BrowserLaunchSpec | None = None launch_spec: BrowserLaunchSpec | None = None
browser_pid: int = 0 browser_pid: int = 0
setup_prompt_title: str = "-" setup_prompt_title: str = "-"
@@ -94,18 +92,32 @@ def prepare_open(
dry_run: bool = True, dry_run: bool = True,
config: AppConfig | None = None, config: AppConfig | None = None,
platform: str | None = None, platform: str | None = None,
broker: bool = False,
route_aware: bool = False, route_aware: bool = False,
) -> OpenPlan: ) -> OpenPlan:
config = config or default_config() config = config or default_config()
browse_plan = plan_url(raw_url, config=config, platform=platform) 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 = ( profile_name = (
ROUTE_AWARE_PROFILE BROKER_PROFILE
if route_aware and browse_plan.route.transport in {"clearnet", "tor", "i2p"} if broker
else browse_plan.route.profile else browse_plan.route.profile
) )
profile_path = config.profile_path(profile_name) profile_path = config.profile_path(profile_name)
proxy = _proxy_for(browse_plan) 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"): if browse_plan.action.startswith("blocked"):
return OpenPlan( return OpenPlan(
@@ -117,12 +129,13 @@ def prepare_open(
proxy=proxy, proxy=proxy,
setup_steps=(), setup_steps=(),
message=browse_plan.action, message=browse_plan.action,
broker=broker,
launch_spec=launch_spec, launch_spec=launch_spec,
) )
if browse_plan.requires_consent: if browse_plan.requires_consent:
setup_steps = _setup_steps(browse_plan) 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: if not consent:
return OpenPlan( return OpenPlan(
browse_plan=browse_plan, browse_plan=browse_plan,
@@ -133,6 +146,7 @@ def prepare_open(
proxy=proxy, proxy=proxy,
setup_steps=setup_steps, setup_steps=setup_steps,
message=browse_plan.prompt, message=browse_plan.prompt,
broker=broker,
launch_spec=launch_spec, launch_spec=launch_spec,
setup_prompt_title=setup_prompt.title, setup_prompt_title=setup_prompt.title,
setup_prompt_body=setup_prompt.body, setup_prompt_body=setup_prompt.body,
@@ -148,6 +162,7 @@ def prepare_open(
proxy=proxy, proxy=proxy,
setup_steps=setup_steps, setup_steps=setup_steps,
message="first-use setup approved; browser launch waits for transport readiness", message="first-use setup approved; browser launch waits for transport readiness",
broker=broker,
launch_spec=launch_spec, launch_spec=launch_spec,
setup_prompt_title=setup_prompt.title, setup_prompt_title=setup_prompt.title,
setup_prompt_body=setup_prompt.body, setup_prompt_body=setup_prompt.body,
@@ -164,11 +179,17 @@ def prepare_open(
proxy=proxy, proxy=proxy,
setup_steps=(), setup_steps=(),
message="ready to open with isolated profile", message="ready to open with isolated profile",
broker=broker,
launch_spec=launch_spec, 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() root = root or Path.cwd()
spec = open_plan.launch_spec spec = open_plan.launch_spec
if spec is None: 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}", 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) _write_profile(root, spec, route_helper=route_helper)
launch_spec = replace(spec, command=_execution_command(root, spec)) launch_spec = replace(spec, command=_execution_command(root, spec))
process = subprocess.Popen(launch_spec.command, cwd=str(root), start_new_session=True) # noqa: S603 process = subprocess.Popen(launch_spec.command, cwd=str(root), start_new_session=True) # noqa: S603
if spec.route_aware: if spec.broker:
route_helper = _start_route_helper(root, planned=route_helper, watch_pid=process.pid) route_helper = _start_route_helper(
root,
planned=route_helper,
watch_pid=process.pid,
config_path=config_path,
)
exit_code = _immediate_exit_code(process) exit_code = _immediate_exit_code(process)
if exit_code is not None: if exit_code is not None:
helper_status = route_helper.status helper_status = route_helper.status
helper_message = route_helper.message helper_message = route_helper.message
if spec.route_aware: if spec.broker:
helper_status = _stop_route_helper(route_helper) helper_status = _stop_route_helper(route_helper)
helper_message = "stopped route helper after browser exited immediately" helper_message = "stopped route helper after browser exited immediately"
return replace( 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_pid=route_helper.pid,
route_helper_message=helper_message, route_helper_message=helper_message,
) )
_record_profile_session(root, spec, process.pid)
message = "launched bundled browser" message = "launched bundled browser"
if spec.route_aware: if spec.broker:
message = f"launched bundled browser with route helper {route_helper.status}" message = f"launched isolated transport broker with route helper {route_helper.status}"
return replace( return replace(
open_plan, open_plan,
dry_run=False, dry_run=False,
@@ -274,7 +310,7 @@ class _SetupPrompt:
approval_command: str 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 transport = browse_plan.route.transport
label = _transport_label(transport) label = _transport_label(transport)
platform = browse_plan.platform_capability.platform 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}." f"{browse_plan.route.normalized}. Setup plan: {setup_summary}."
) )
approval_args = ["ampbrowser", "open", browse_plan.route.normalized, "--yes", "--launch"] approval_args = ["ampbrowser", "open", browse_plan.route.normalized, "--yes", "--launch"]
if route_aware:
approval_args.append("--route-aware")
approval_command = shlex.join(approval_args) approval_command = shlex.join(approval_args)
return _SetupPrompt( return _SetupPrompt(
title=f"Set up {label}?", title=f"Set up {label}?",
@@ -324,27 +358,23 @@ def _launch_spec_for(
*, *,
config: AppConfig, config: AppConfig,
profile_path: str, profile_path: str,
route_aware: bool = False, broker: bool = False,
) -> BrowserLaunchSpec | None: ) -> BrowserLaunchSpec | None:
if browse_plan.route.transport not in {"clearnet", "tor", "i2p"}: if browse_plan.route.transport not in {"clearnet", "tor", "i2p"}:
return None return None
runtime_path = _runtime_path(config) runtime_path = _runtime_path(config)
user_js_path = str(Path(profile_path) / "user.js") user_js_path = str(Path(profile_path) / "user.js")
command = (runtime_path, "--new-instance", "--profile", profile_path, browse_plan.route.normalized) command = (runtime_path, "--new-instance", "--profile", profile_path, browse_plan.route.normalized)
prefs = _prefs_for(browse_plan, route_aware=route_aware) prefs = _prefs_for(browse_plan, broker=broker)
pac_path = str(Path(profile_path) / ROUTE_AWARE_PAC_NAME) if route_aware else "-" extension_path = str(Path(profile_path) / "extensions" / ROUTE_HELPER_EXTENSION_ID) if broker else "-"
pac_content = _route_aware_pac() if route_aware else "" extension_assets = _route_helper_extension_assets(extension_path) if broker 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( return BrowserLaunchSpec(
runtime_path=runtime_path, runtime_path=runtime_path,
profile_path=profile_path, profile_path=profile_path,
user_js_path=user_js_path, user_js_path=user_js_path,
command=command, command=command,
prefs=prefs, prefs=prefs,
route_aware=route_aware, broker=broker,
pac_path=pac_path,
pac_content=pac_content,
extension_path=extension_path, extension_path=extension_path,
extension_assets=extension_assets, 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]] = [ prefs: list[tuple[str, str | int | bool]] = [
("browser.shell.checkDefaultBrowser", False), ("browser.shell.checkDefaultBrowser", False),
("datareporting.policy.dataSubmissionEnabled", 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), ("network.predictor.enabled", False),
] ]
if route_aware: if broker:
prefs.extend( prefs.extend(
[ [
("network.proxy.type", 2), ("network.proxy.type", 0),
("network.proxy.autoconfig_url", PAC_URL_PLACEHOLDER),
("network.proxy.failover_direct", False),
("network.proxy.socks_remote_dns", True),
("network.proxy.no_proxies_on", ""),
("extensions.autoDisableScopes", 0), ("extensions.autoDisableScopes", 0),
("extensions.enabledScopes", 5), ("extensions.enabledScopes", 5),
("xpinstall.signatures.required", False), ("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) 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, ...]: def _route_helper_extension_assets(extension_path: str) -> tuple[ProfileAsset, ...]:
base = Path(extension_path) base = Path(extension_path)
return ( 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.html"), _route_helper_setup_html()),
ProfileAsset(str(base / "setup.js"), _route_helper_setup_js()), ProfileAsset(str(base / "setup.js"), _route_helper_setup_js()),
ProfileAsset(str(base / "setup.css"), _route_helper_setup_css()), 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, "manifest_version": 2,
"name": "AMPB Route Helper", "name": "AMPB Route Helper",
"version": "0.1.0", "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}}, "applications": {"gecko": {"id": ROUTE_HELPER_EXTENSION_ID}},
"permissions": [ "permissions": [
"tabs", "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/*", "http://127.0.0.1/*",
], ],
"background": {"scripts": ["background.js"]}, "background": {"scripts": ["background.js"]},
@@ -523,44 +514,64 @@ def _route_helper_background_js() -> str:
" return '';", " return '';",
"}", "}",
"", "",
"async function helper(action, transport, url) {", "async function helper(action, transport, url, consent = false) {",
" const response = await fetch(HELPER_URL, {", " const response = await fetch(HELPER_URL, {",
" method: 'POST',", " method: 'POST',",
" headers: {", " headers: {",
" 'Content-Type': 'application/json',", " 'Content-Type': 'application/json',",
" 'X-AMPB-Token': HELPER_TOKEN,", " 'X-AMPB-Token': HELPER_TOKEN,",
" },", " },",
" body: JSON.stringify({ action, transport, url }),", " body: JSON.stringify({ action, transport, url, consent }),",
" });", " });",
" return response.json();", " return response.json();",
"}", "}",
"", "",
"async function handleNavigate(details) {", "function internalUrl(page, transport, url, result) {",
" if (details.frameId !== 0) return;", " const installCommand = Array.isArray(result.install_command) ? result.install_command.join(' ') : '';",
" const transport = transportForUrl(details.url);", " const params = new URLSearchParams({",
" if (!transport) return;", " transport,",
" const key = `${details.tabId}:${details.url}`;", " url,",
" if (pendingByTab.get(details.tabId) === details.url) return;", " message: result.message || '',",
" let status;", " hint: result.setup_hint || '',",
" try {", " install: installCommand,",
" status = await helper('status', transport, details.url);", " });",
" } catch (error) {", " return browser.runtime.getURL(`${page}.html?${params.toString()}`);",
" 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 });",
"}", "}",
"", "",
"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) => {", "browser.runtime.onMessage.addListener((message) => {",
" if (!message || message.type !== 'ampb-ensure') return false;", " if (!message || message.type !== 'ampb-open') return false;",
" return helper('ensure', message.transport, message.url);", " 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 () => {", "document.getElementById('approve').addEventListener('click', async () => {",
" const status = document.getElementById('status');", " const status = document.getElementById('status');",
" status.textContent = `Starting ${label}...`;", " status.textContent = `Starting ${label}...`;",
" const result = await browser.runtime.sendMessage({ type: 'ampb-ensure', transport, url });", " const result = await browser.runtime.sendMessage({ type: 'ampb-open', transport, url, consent: true });",
" if (result && result.ready) {", " if (result && result.launched) {",
" status.textContent = `${label} is ready. Opening route...`;", " status.textContent = `${label} opened in its isolated browser profile.`;",
" window.location.href = url;", " const params = new URLSearchParams({ transport, url });",
" window.location.href = browser.runtime.getURL(`handoff.html?${params.toString()}`);",
" return;", " return;",
" }", " }",
" if (result && Array.isArray(result.install_command)) {", " 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]: def _endpoint_host_port(endpoint: str) -> tuple[str, int]:
parsed = urlparse(endpoint) parsed = urlparse(endpoint)
return parsed.hostname or "127.0.0.1", parsed.port or 0 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});" 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() port = _free_loopback_port()
token = secrets.token_urlsafe(24) token = secrets.token_urlsafe(24)
endpoint = f"http://127.0.0.1:{port}/" endpoint = f"http://127.0.0.1:{port}/"
@@ -687,7 +742,7 @@ def _route_helper_launch_plan(root: Path) -> RouteHelperLaunch:
"planned", "planned",
endpoint=endpoint, endpoint=endpoint,
token=token, 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, planned: RouteHelperLaunch | None = None,
watch_pid: int = 0, watch_pid: int = 0,
config_path: Path | None = None,
) -> RouteHelperLaunch: ) -> 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] 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: try:
process = subprocess.Popen(command, cwd=str(root), start_new_session=True) # noqa: S603 process = subprocess.Popen(command, cwd=str(root), start_new_session=True) # noqa: S603
except OSError as exc: 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="-") 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 = ( command = (
sys.executable, sys.executable,
"-m", "-m",
@@ -724,6 +793,8 @@ def _route_helper_command(*, root: Path, port: int, token: str, watch_pid: int =
) )
if watch_pid > 0: if watch_pid > 0:
command = (*command, "--watch-pid", str(watch_pid)) command = (*command, "--watch-pid", str(watch_pid))
if config_path:
command = (*command, "--config", str(config_path))
return command return command
@@ -739,6 +810,102 @@ def _execution_command(root: Path, spec: BrowserLaunchSpec) -> tuple[str, ...]:
return tuple(command) 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: def _stop_route_helper(route_helper: RouteHelperLaunch) -> str:
if route_helper.pid <= 0: if route_helper.pid <= 0:
return route_helper.status return route_helper.status
@@ -757,6 +924,16 @@ def _immediate_exit_code(process: subprocess.Popen, *, wait_seconds: float = 1.0
return None 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: def _free_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0)) 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: def _write_profile(root: Path, spec: BrowserLaunchSpec, *, route_helper: RouteHelperLaunch | None = None) -> None:
profile_path = _path_from(root, spec.profile_path) profile_path = _path_from(root, spec.profile_path)
profile_path.mkdir(parents=True, exist_ok=True) profile_path.mkdir(parents=True, exist_ok=True)
pac_url = "-" if spec.broker:
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:
helper = route_helper or RouteHelperLaunch("disabled") helper = route_helper or RouteHelperLaunch("disabled")
for asset in spec.extension_assets: for asset in spec.extension_assets:
asset_path = _path_from(root, asset.path) 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") asset_path.write_text(content, encoding="utf-8")
user_js_path = _path_from(root, spec.user_js_path) 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 = "// 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") 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_endpoint=transport_result.endpoint,
transport_setup_message=transport_result.message, 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 import time
from typing import Any from typing import Any
from .broker import BrokerRouteError, open_brokered_url
from .config import AppConfig, load_config from .config import AppConfig, load_config
from .launch import OpenPlan
from .routing import route_url from .routing import route_url
from .transport_manager import ManagedTransportResult, ensure_transport_ready, transport_status 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)) return _result_response(transport_status(transport, config=config, root=root))
if action == "ensure": if action == "ensure":
return _result_response(ensure_transport_ready(transport, config=config, root=root)) 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 '-'}") 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]: def _error_response(status: str, message: str) -> dict[str, Any]:
return { return {
"ok": False, "ok": False,
+25 -4
View File
@@ -1,6 +1,6 @@
# Architecture # 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, AMPB is a browser orchestration layer for privacy, resilient-network, content-addressed,
and alternate-web transports. It keeps the routing, transport management, and profile 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. 6. Start or install managed transports only after user consent.
7. Open the URL with the matching isolated profile. 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 ## Main Components
- `ampbrowser.routing`: deterministic URL-to-transport rules. - `ampbrowser.routing`: deterministic URL-to-transport rules.
- `ampbrowser.adapters`: transport ownership, setup, inspection, and health contracts. - `ampbrowser.adapters`: transport ownership, setup, inspection, and health contracts.
- `ampbrowser.transports`: local readiness checks for Tor, I2P, IPFS, Reticulum, and Gemini. - `ampbrowser.transports`: local readiness checks for Tor, I2P, IPFS, Reticulum, and Gemini.
- `ampbrowser.plan`: dry-run action planner for browser launches. - `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.launch`: side-effect-free launch specs and first-use consent state.
- `ampbrowser.platforms`: platform capability matrix for desktop, Android, and iOS. - `ampbrowser.platforms`: platform capability matrix for desktop, Android, and iOS.
- `ampbrowser.candidates`: evaluated transport candidates before active adapter support. - `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). 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 ## Relationship To AMPG
AMPG publishes protocol-specific site variants. AMPB opens those variants with the right AMPG publishes protocol-specific site variants and declares their expected transport,
transport and profile. Wownero is the shared fixture for static publishing, while profile, and isolation mode in fixture manifests. AMPB validates and enforces the browser
interactive targets exercise sessions, dynamic assets, forms, and script policy. 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 ## Transport Path
The first transport target is Tor through an AMPB-owned Arti/Tor runtime. The current 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 desktop vertical can launch a bundled Gecko runtime, start the isolated transport broker,
the helper tied to the browser process. I2P uses the same route, consent, state, and and keep its token-gated helper tied to the broker process. Tor and I2P destinations launch
health-check model. Reticulum follows after the resilient-network adapter is concrete. in their own browser profiles after route validation and consent. Reticulum follows after
the resilient-network adapter and renderer handoff are concrete.
## Desktop Shell ## Desktop Shell
@@ -41,4 +42,5 @@ approval. Use `--yes` for scripted verification without showing the dialog.
```sh ```sh
python3 -m ampbrowser shell http://example.onion/ python3 -m ampbrowser shell http://example.onion/
python3 -m ampbrowser shell http://example.onion/ --yes 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 # 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 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 each generated fixture URL routes to the expected AMPB transport and profile, uses
that its declared interaction policy fits that transport. 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 Route-expanded fixtures may include `route.match` and `route.fixture_path`, which identify
the route group that produced the fixture. the route group that produced the fixture.
@@ -20,8 +21,9 @@ manifest.
## Scope ## Scope
Fixture checks validate route selection and declared interaction policy only. They do not Fixture checks validate route selection, profile isolation expectations, and declared
verify daemon health, hidden service reachability, IPFS pinning, TLS, or content freshness. 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 The manifest must not contain private deployment notes, service keys, credentials, or
host inventory. 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 through a desktop dialog when possible, falls back to a terminal prompt when needed, and
launches through the same transport runner after approval. launches through the same transport runner after approval.
`ampbrowser open <url> --route-aware --launch` creates a desktop convenience profile for `ampbrowser open <clearnet-url> --broker --launch` creates the desktop entry profile. The
clearnet, Tor, and I2P QA. The profile uses a PAC file for `.onion` and `.i2p` routing, broker has direct clearnet networking and no mixed-transport PAC. Its extension cancels
installs the AMPB route-helper extension, and starts a token-gated loopback helper tied all `.onion` and `.i2p` requests before they leave the profile. For a top-level navigation,
to the browser process. If a user clicks an alternate-network link before its local it asks a token-gated loopback helper to open the URL in the isolated Tor or I2P profile.
transport is ready, the extension shows a setup page and asks the helper to start the Missing transports use the same first-use consent flow before the handoff. `--route-aware`
AMPB-managed transport after approval. This mode proves routing and first-use setup; it remains a compatibility alias for `--broker`.
is not the final per-transport storage isolation model.
## Examples ## 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/ --config examples/config.toml
python3 -m ampbrowser open http://example.b32.i2p/ --platform android python3 -m ampbrowser open http://example.b32.i2p/ --platform android
python3 -m ampbrowser open https://wownero.org/ --launch 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/ --launch
python3 -m ampbrowser open http://example.onion/ --yes --launch python3 -m ampbrowser open http://example.onion/ --yes --launch
python3 -m ampbrowser shell http://example.onion/ 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", provider_source="bundled-sidecar",
) )
def fake_execute(open_plan, *, root: Path): def fake_execute(open_plan, *, root: Path, config: AppConfig):
return replace( return replace(
open_plan, open_plan,
status="launched", status="launched",
@@ -108,15 +108,22 @@ class CliOpenTest(unittest.TestCase):
self.assertIn("prompt_shown=false", output) self.assertIn("prompt_shown=false", output)
self.assertIn("prompt_approved=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: with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout:
code = main(["open", "https://ampgateway.site/", "--route-aware"]) code = main(["open", "https://ampgateway.site/", "--route-aware"])
self.assertEqual(0, code) self.assertEqual(0, code)
output = stdout.getvalue() self.assertIn("broker=true", 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)
def test_hidden_helper_command_passes_watch_pid(self) -> None: def test_hidden_helper_command_passes_watch_pid(self) -> None:
with patch("ampbrowser.cli.serve_route_helper") as helper: with patch("ampbrowser.cli.serve_route_helper") as helper:
+1
View File
@@ -40,6 +40,7 @@ enabled = false
config = load_config(Path(tmp), path) config = load_config(Path(tmp), path)
self.assertEqual(str(path.resolve()), config.config_path)
self.assertEqual(".local/ampb", config.state_dir) self.assertEqual(".local/ampb", config.state_dir)
self.assertEqual("/opt/ampb/firefox", config.runtime_path) self.assertEqual("/opt/ampb/firefox", config.runtime_path)
self.assertEqual("adopt", config.transport_mode("tor")) self.assertEqual("adopt", config.transport_mode("tor"))
+6 -1
View File
@@ -26,7 +26,11 @@ class FixtureCheckTest(unittest.TestCase):
{ {
"protocol": "tor", "protocol": "tor",
"url": "http://wownero.onion/", "url": "http://wownero.onion/",
"checks": {"transport": "tor", "profile": "tor"}, "checks": {
"transport": "tor",
"profile": "tor",
"isolation": "transport-profile",
},
}, },
{ {
"protocol": "ipfs", "protocol": "ipfs",
@@ -45,6 +49,7 @@ class FixtureCheckTest(unittest.TestCase):
self.assertTrue(result.ok) self.assertTrue(result.ok)
self.assertEqual(3, len(result.checks)) 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].route_match)
self.assertEqual("/snapshot/", result.checks[2].fixture_path) self.assertEqual("/snapshot/", result.checks[2].fixture_path)
+85 -37
View File
@@ -6,11 +6,22 @@ import tempfile
from pathlib import Path from pathlib import Path
from ampbrowser.config import AppConfig 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 from ampbrowser.transports import TransportStatus
class PrepareOpenTest(unittest.TestCase): 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: def test_clearnet_is_ready_without_consent(self) -> None:
config = AppConfig(runtime_path="/opt/ampb/firefox", transport_modes={}) config = AppConfig(runtime_path="/opt/ampb/firefox", transport_modes={})
@@ -130,25 +141,25 @@ class PrepareOpenTest(unittest.TestCase):
self.assertTrue(user_js.exists()) self.assertTrue(user_js.exists())
self.assertIn('user_pref("network.proxy.type", 0);', user_js.read_text(encoding="utf-8")) 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: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
runtime = root / "firefox" runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8") runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={}) 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.assertIsNotNone(plan.launch_spec)
self.assertTrue(plan.launch_spec.route_aware) self.assertTrue(plan.launch_spec.broker)
self.assertEqual(".ampb/profiles/route-aware/ampb-proxy.pac", plan.launch_spec.pac_path) self.assertIn('user_pref("network.proxy.type", 0);', plan.launch_spec.prefs)
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))
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) 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._route_helper_launch_plan", return_value=planned_helper):
with patch("ampbrowser.launch.subprocess.Popen") as popen: with patch("ampbrowser.launch._start_route_helper", return_value=helper):
launched = execute_open(plan, root=root) with patch("ampbrowser.launch.subprocess.Popen") as popen:
launched = execute_open(plan, root=root)
self.assertEqual("launched", launched.status) self.assertEqual("launched", launched.status)
self.assertEqual("started", launched.route_helper_status) self.assertEqual("started", launched.route_helper_status)
@@ -159,33 +170,39 @@ class PrepareOpenTest(unittest.TestCase):
str(runtime), str(runtime),
"--new-instance", "--new-instance",
"--profile", "--profile",
str(root / ".ampb/profiles/route-aware"), str(root / ".ampb/profiles/broker"),
"https://ampgateway.site/", "https://ampgateway.site/",
), ),
cwd=str(root), cwd=str(root),
start_new_session=True, 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") user_js = (profile / "user.js").read_text(encoding="utf-8")
pac = (profile / "ampb-proxy.pac").read_text(encoding="utf-8") self.assertIn('user_pref("network.proxy.type", 0);', user_js)
self.assertIn((profile / "ampb-proxy.pac").resolve().as_uri(), user_js) self.assertFalse((profile / "ampb-proxy.pac").exists())
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)
extension = profile / "extensions/ampb-route-helper@such.software" extension = profile / "extensions/ampb-route-helper@such.software"
manifest = (extension / "manifest.json").read_text(encoding="utf-8") manifest = (extension / "manifest.json").read_text(encoding="utf-8")
background = (extension / "background.js").read_text(encoding="utf-8") background = (extension / "background.js").read_text(encoding="utf-8")
setup = (extension / "setup.html").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("install_command", background)
self.assertIn("http://127.0.0.1:", background) self.assertIn("http://127.0.0.1:", background)
self.assertNotIn("__AMPB_ROUTE_HELPER_URL__", background) self.assertNotIn("__AMPB_ROUTE_HELPER_URL__", background)
self.assertNotIn("__AMPB_ROUTE_HELPER_TOKEN__", background) self.assertNotIn("__AMPB_ROUTE_HELPER_TOKEN__", background)
self.assertIn("Set Up Transport", setup) self.assertIn("Set Up Transport", setup)
self.assertIn("install-command", 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: def test_execute_open_blocks_when_runtime_is_missing(self) -> None:
config = AppConfig(runtime_path="/missing/ampb/firefox", transport_modes={}) config = AppConfig(runtime_path="/missing/ampb/firefox", transport_modes={})
@@ -196,21 +213,50 @@ class PrepareOpenTest(unittest.TestCase):
self.assertEqual("runtime-missing", launched.status) self.assertEqual("runtime-missing", launched.status)
self.assertIn("browser runtime not found", launched.message) 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: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
runtime = root / "firefox" runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8") runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={}) 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) 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._route_helper_launch_plan", return_value=planned_helper):
with patch("ampbrowser.launch._stop_route_helper", return_value="stopped") as stop_helper: with patch("ampbrowser.launch._start_route_helper", return_value=helper):
with patch("ampbrowser.launch.subprocess.Popen") as popen: with patch("ampbrowser.launch._stop_route_helper", return_value="stopped") as stop_helper:
popen.return_value.pid = 9876 with patch("ampbrowser.launch.subprocess.Popen") as popen:
popen.return_value.poll.return_value = 1 popen.return_value.pid = 9876
launched = execute_open(plan, root=root) popen.return_value.poll.return_value = 1
launched = execute_open(plan, root=root)
self.assertEqual("browser-exited", launched.status) self.assertEqual("browser-exited", launched.status)
self.assertEqual(9876, launched.browser_pid) self.assertEqual(9876, launched.browser_pid)
@@ -218,20 +264,22 @@ class PrepareOpenTest(unittest.TestCase):
self.assertIn("browser exited immediately", launched.message) self.assertIn("browser exited immediately", launched.message)
stop_helper.assert_called_once_with(helper) 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: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
runtime = root / "firefox" runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8") runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={}) 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) 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._route_helper_launch_plan", return_value=planned_helper):
with patch("ampbrowser.launch.subprocess.Popen") as popen: with patch("ampbrowser.launch._start_route_helper", return_value=helper) as start_helper:
popen.return_value.pid = 9876 with patch("ampbrowser.launch.subprocess.Popen") as popen:
popen.return_value.poll.return_value = None popen.return_value.pid = 9876
launched = execute_open(plan, root=root) popen.return_value.poll.return_value = None
launched = execute_open(plan, root=root)
self.assertEqual("launched", launched.status) self.assertEqual("launched", launched.status)
start_helper.assert_called_once() start_helper.assert_called_once()
+52
View File
@@ -1,13 +1,16 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from ampbrowser.config import AppConfig from ampbrowser.config import AppConfig
from ampbrowser.launch import prepare_open
from ampbrowser.route_helper import handle_helper_message from ampbrowser.route_helper import handle_helper_message
from ampbrowser.transport_manager import ManagedTransportResult from ampbrowser.transport_manager import ManagedTransportResult
from ampbrowser.transports import TransportStatus
class RouteHelperTest(unittest.TestCase): class RouteHelperTest(unittest.TestCase):
@@ -81,6 +84,55 @@ class RouteHelperTest(unittest.TestCase):
self.assertFalse(response["ok"]) self.assertFalse(response["ok"])
self.assertEqual("unsupported-transport", response["status"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()