diff --git a/README.md b/README.md index baded96..891c2ab 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/ampbrowser/broker.py b/ampbrowser/broker.py new file mode 100644 index 0000000..bc6f216 --- /dev/null +++ b/ampbrowser/broker.py @@ -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) diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index a64d3ce..3268213 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -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} " diff --git a/ampbrowser/config.py b/ampbrowser/config.py index d9fdf46..e22e486 100644 --- a/ampbrowser/config.py +++ b/ampbrowser/config.py @@ -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, diff --git a/ampbrowser/desktop_shell.py b/ampbrowser/desktop_shell.py index 08e5a98..9bc3ba9 100644 --- a/ampbrowser/desktop_shell.py +++ b/ampbrowser/desktop_shell.py @@ -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), diff --git a/ampbrowser/fixtures.py b/ampbrowser/fixtures.py index 8b4f948..a4e5548 100644 --- a/ampbrowser/fixtures.py +++ b/ampbrowser/fixtures.py @@ -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"], diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py index 2ec51ac..958ebfb 100644 --- a/ampbrowser/launch.py +++ b/ampbrowser/launch.py @@ -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( + ( + "", + '', + "", + ' ', + ' ', + " Route Opened", + ' ', + "", + "", + '
', + '

AMPB Transport Broker

', + '

Route opened

', + '

The destination is open in its isolated transport profile.

', + '
',
+            "  
", + ' ', + "", + "", + "", + ) + ) + + +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") diff --git a/ampbrowser/open_runner.py b/ampbrowser/open_runner.py index c87e656..cbb2e17 100644 --- a/ampbrowser/open_runner.py +++ b/ampbrowser/open_runner.py @@ -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) diff --git a/ampbrowser/route_helper.py b/ampbrowser/route_helper.py index 6c0c554..986983d 100644 --- a/ampbrowser/route_helper.py +++ b/ampbrowser/route_helper.py @@ -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, diff --git a/docs/architecture.md b/docs/architecture.md index 20b9cf3..df3ae27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/broker.md b/docs/broker.md new file mode 100644 index 0000000..5ab4f94 --- /dev/null +++ b/docs/broker.md @@ -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 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`. diff --git a/docs/desktop-vertical.md b/docs/desktop-vertical.md index ba94097..162c82e 100644 --- a/docs/desktop-vertical.md +++ b/docs/desktop-vertical.md @@ -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 ``` diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index 030fbac..556bb84 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -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. diff --git a/docs/open-flow.md b/docs/open-flow.md index 1b6fea9..dfd326a 100644 --- a/docs/open-flow.md +++ b/docs/open-flow.md @@ -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 --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 --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/ diff --git a/tests/test_broker.py b/tests/test_broker.py new file mode 100644 index 0000000..c1236c6 --- /dev/null +++ b/tests/test_broker.py @@ -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() diff --git a/tests/test_cli_open.py b/tests/test_cli_open.py index 69fb27a..4184b82 100644 --- a/tests/test_cli_open.py +++ b/tests/test_cli_open.py @@ -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: diff --git a/tests/test_config.py b/tests/test_config.py index ab56573..688d27e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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")) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 166a530..9576fe8 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -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) diff --git a/tests/test_launch.py b/tests/test_launch.py index c194975..a5b14ec 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -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() diff --git a/tests/test_route_helper.py b/tests/test_route_helper.py index 604f43b..a06cd04 100644 --- a/tests/test_route_helper.py +++ b/tests/test_route_helper.py @@ -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()