mirror of
https://github.com/Such-Software/adaptive-multi-protocol-browser.git
synced 2026-10-17 06:21:26 +02:00
Add isolated transport broker
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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} "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user