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()