Keep transports in one browser window

This commit is contained in:
jwinterm
2026-07-11 10:20:54 -04:00
parent 2c68c072ba
commit ce874fec3c
20 changed files with 346 additions and 423 deletions
+8 -7
View File
@@ -20,15 +20,16 @@ capabilities they exercise, while project-specific target notes stay in private
- Adopt existing Tor, I2P, Reticulum, and Gemini-capable tools when healthy.
- Prompt on first visit before installing or starting a missing transport.
- Manage approved transports through isolated AMPB state.
- Keep browser profiles isolated by transport.
- Hand alternate-network links from the clearnet broker to transport-specific browser
profiles before the destination request leaves the broker profile.
- Keep all transports in one visible browser window.
- Give every web tab an explicit, visible, fail-closed transport context.
- Isolate cookies and site data by transport container while routing the entire tab through
its selected transport.
- Present transport health in plain language.
## Non-goals
- Forking a browser engine before the orchestration model is proven.
- Mixing clearnet, Tor, I2P, and Reticulum cookies/storage in one profile.
- Treating the WebExtension container prototype as the final anonymity boundary.
- Mixing clearnet, Tor, I2P, and Reticulum site data in one transport context.
- Publishing private transport state, keys, or host inventory in public docs.
## Local Commands
@@ -67,7 +68,7 @@ python3 -m unittest discover -s tests
- [Architecture](docs/architecture.md)
- [Browser builds](docs/browser-builds.md)
- [Transport broker](docs/broker.md)
- [Transport contexts](docs/broker.md)
- [Candidate transports](docs/candidate-transports.md)
- [Configuration](docs/configuration.md)
- [Desktop vertical](docs/desktop-vertical.md)
@@ -84,6 +85,6 @@ python3 -m unittest discover -s tests
- [ ] Route URL to transport.
- [ ] Inspect whether transport can be adopted.
- [ ] Prompt before installing or starting a missing transport.
- [ ] Use isolated profile state for each transport.
- [ ] Use an isolated, visible transport context for every tab.
- [ ] Spawn/manage only after a dry-run plan.
- [ ] Keep private notes under `docs/private/`.
-41
View File
@@ -1,41 +0,0 @@
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)
+4 -4
View File
@@ -45,7 +45,7 @@ def main(argv: list[str] | None = None) -> int:
"--route-aware",
dest="broker",
action="store_true",
help="Launch a clearnet broker that hands Tor and I2P routes to isolated profiles.",
help="Launch one window with isolated clearnet, Tor, and I2P container tabs.",
)
open_mode = open_parser.add_mutually_exclusive_group()
open_mode.add_argument(
@@ -57,7 +57,7 @@ def main(argv: list[str] | None = None) -> int:
open_mode.add_argument(
"--launch",
action="store_true",
help="Create the isolated profile and launch the bundled browser when the route is ready.",
help="Create AMPB-owned browser state and launch the bundled browser when the route is ready.",
)
shell_parser = subcommands.add_parser("shell", help="Launch the desktop shell flow.")
@@ -354,8 +354,8 @@ def _cmd_fixture(args) -> int:
f"url={check.url} "
f"expected_transport={check.expected_transport} "
f"actual_transport={check.actual_transport} "
f"expected_profile={check.expected_profile} "
f"actual_profile={check.actual_profile} "
f"expected_context={check.expected_context} "
f"actual_context={check.actual_context} "
f"expected_isolation={check.expected_isolation} "
f"actual_isolation={check.actual_isolation} "
f"tier={check.tier} "
+14 -10
View File
@@ -9,7 +9,7 @@ from .interaction_policy import interaction_failures
from .routing import route_url
SUPPORTED_SCHEMAS = {"ampg.fixture-manifest.v1"}
SUPPORTED_SCHEMAS = {"ampg.fixture-manifest.v1", "ampg.fixture-manifest.v2"}
@dataclass(frozen=True)
@@ -21,8 +21,8 @@ class FixtureCheck:
url: str
expected_transport: str
actual_transport: str
expected_profile: str
actual_profile: str
expected_context: str
actual_context: str
expected_isolation: str
actual_isolation: str
tier: str
@@ -79,18 +79,22 @@ def _check_fixture(site_id: str, fixture: Any, path: Path) -> FixtureCheck:
if not isinstance(checks, dict):
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")
expected_context = (
_string(checks, "context")
if "context" in checks
else _string(checks, "profile")
)
expected_isolation = _optional_string(checks, "isolation", "transport-context")
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"
actual_isolation = "transport-context"
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 route.profile != expected_context:
failures.append(f"context expected {expected_context} got {route.profile}")
if actual_isolation != expected_isolation:
failures.append(f"isolation expected {expected_isolation} got {actual_isolation}")
failures.extend(
@@ -112,8 +116,8 @@ def _check_fixture(site_id: str, fixture: Any, path: Path) -> FixtureCheck:
url=route.normalized,
expected_transport=expected_transport,
actual_transport=route.transport,
expected_profile=expected_profile,
actual_profile=route.profile,
expected_context=expected_context,
actual_context=route.profile,
expected_isolation=expected_isolation,
actual_isolation=actual_isolation,
tier=interaction["tier"],
+222 -147
View File
@@ -107,7 +107,7 @@ def prepare_open(
profile_path=config.profile_path(BROKER_PROFILE),
proxy="-",
setup_steps=(),
message="broker entry URLs must use clearnet; alternate routes open in their isolated profile",
message="broker entry URLs must use clearnet; alternate routes continue in isolated container tabs",
broker=True,
)
profile_name = (
@@ -178,7 +178,7 @@ def prepare_open(
profile_path=profile_path,
proxy=proxy,
setup_steps=(),
message="ready to open with isolated profile",
message="ready to open in the one-window broker" if broker else "ready to open with isolated profile",
broker=broker,
launch_spec=launch_spec,
)
@@ -415,6 +415,14 @@ def _prefs_for(browse_plan: BrowsePlan, *, broker: bool = False) -> tuple[str, .
prefs.extend(
[
("network.proxy.type", 0),
("network.http.speculative-parallel-limit", 0),
("network.prefetch-next", False),
("network.dns.disablePrefetchFromHTTPS", True),
("browser.urlbar.speculativeConnect.enabled", False),
("media.peerconnection.enabled", False),
("network.http.http3.enable", False),
("network.trr.mode", 5),
("privacy.userContext.enabled", True),
("extensions.autoDisableScopes", 0),
("extensions.enabledScopes", 5),
("xpinstall.signatures.required", False),
@@ -461,8 +469,6 @@ 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()),
)
@@ -470,22 +476,20 @@ def _route_helper_manifest() -> str:
return json.dumps(
{
"manifest_version": 2,
"name": "AMPB Route Helper",
"name": "AMPB Transport Contexts",
"version": "0.1.0",
"description": "Hands alternate-network links to isolated AMPB transport profiles.",
"applications": {"gecko": {"id": ROUTE_HELPER_EXTENSION_ID}},
"description": "Keeps AMPB transports in isolated, fail-closed container tabs.",
"browser_specific_settings": {
"gecko": {"id": ROUTE_HELPER_EXTENSION_ID, "strict_min_version": "128.0"}
},
"permissions": [
"tabs",
"cookies",
"contextualIdentities",
"proxy",
"webRequest",
"webRequestBlocking",
"http://*.onion/*",
"https://*.onion/*",
"ws://*.onion/*",
"wss://*.onion/*",
"http://*.i2p/*",
"https://*.i2p/*",
"ws://*.i2p/*",
"wss://*.i2p/*",
"<all_urls>",
"http://127.0.0.1/*",
],
"background": {"scripts": ["background.js"]},
@@ -496,86 +500,197 @@ def _route_helper_manifest() -> str:
def _route_helper_background_js() -> str:
return "\n".join(
(
'"use strict";',
"",
f'const HELPER_URL = "{HELPER_URL_PLACEHOLDER}";',
f'const HELPER_TOKEN = "{HELPER_TOKEN_PLACEHOLDER}";',
"const pendingByTab = new Map();",
"",
"function transportForUrl(rawUrl) {",
" try {",
" const parsed = new URL(rawUrl);",
" const host = parsed.hostname.toLowerCase().replace(/\\.$/, '');",
" if (host.endsWith('.onion')) return 'tor';",
" if (host.endsWith('.i2p')) return 'i2p';",
" } catch (_error) {}",
" return '';",
"}",
"",
"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, consent }),",
" });",
" return response.json();",
"}",
"",
"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()}`);",
"}",
"",
"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-open') return false;",
" return helper('open', message.transport, message.url, message.consent === true);",
"});",
"",
)
tor_adapter = adapter_for("tor")
i2p_adapter = adapter_for("i2p")
tor_host, tor_port = _endpoint_host_port(
tor_adapter.endpoint if tor_adapter else "socks5://127.0.0.1:9050"
)
i2p_host, i2p_port = _endpoint_host_port(
i2p_adapter.endpoint if i2p_adapter else "http://127.0.0.1:4444"
)
lines = (
'"use strict";',
"",
f'const HELPER_URL = "{HELPER_URL_PLACEHOLDER}";',
f'const HELPER_TOKEN = "{HELPER_TOKEN_PLACEHOLDER}";',
f'const TOR_PROXY = {{ host: "{tor_host}", port: {tor_port} }};',
f'const I2P_PROXY = {{ host: "{i2p_host}", port: {i2p_port} }};',
"const CONTAINERS = {",
" clearnet: { name: 'AMPB Clearnet', color: 'blue', icon: 'circle' },",
" tor: { name: 'AMPB Tor', color: 'purple', icon: 'fingerprint' },",
" i2p: { name: 'AMPB I2P', color: 'orange', icon: 'circle' },",
"};",
"const storesByTransport = new Map();",
"const transportsByStore = new Map();",
"const pendingByTab = new Map();",
"const containersReady = initializeContainers();",
"",
"function transportForUrl(rawUrl) {",
" try {",
" const parsed = new URL(rawUrl);",
" const host = parsed.hostname.toLowerCase().replace(/\\.$/, '');",
" if (host.endsWith('.onion')) return 'tor';",
" if (host.endsWith('.i2p')) return 'i2p';",
" if (['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) return 'clearnet';",
" } catch (_error) {}",
" return '';",
"}",
"",
"function hostname(rawUrl) {",
" try { return new URL(rawUrl).hostname.toLowerCase().replace(/\\.$/, ''); } catch (_error) { return ''; }",
"}",
"",
"function transportForStore(cookieStoreId) {",
" return transportsByStore.get(cookieStoreId) || '';",
"}",
"",
"function isHelperRequest(details) {",
" const origin = details.originUrl || details.documentUrl || '';",
" return details.url.startsWith(HELPER_URL) && details.tabId < 0 && origin.startsWith(browser.runtime.getURL(''));",
"}",
"",
"async function initializeContainers() {",
" const identities = await browser.contextualIdentities.query({});",
" for (const [transport, metadata] of Object.entries(CONTAINERS)) {",
" let identity = identities.find((candidate) => candidate.name === metadata.name);",
" if (!identity) identity = await browser.contextualIdentities.create(metadata);",
" storesByTransport.set(transport, identity.cookieStoreId);",
" transportsByStore.set(identity.cookieStoreId, transport);",
" }",
"}",
"",
"async function helper(action, transport, url) {",
" const response = await fetch(HELPER_URL, {",
" method: 'POST',",
" headers: { 'Content-Type': 'application/json', 'X-AMPB-Token': HELPER_TOKEN },",
" body: JSON.stringify({ action, transport, url }),",
" });",
" return response.json();",
"}",
"",
"function setupUrl(mode, transport, url, result) {",
" const installCommand = Array.isArray(result.install_command) ? result.install_command.join(' ') : '';",
" const params = new URLSearchParams({",
" mode, transport, url, message: result.message || '', hint: result.setup_hint || '', install: installCommand,",
" });",
" return browser.runtime.getURL(`setup.html?${params.toString()}`);",
"}",
"",
"function isolationToken(details) {",
" const store = details.cookieStoreId || 'unknown';",
" const firstParty = hostname(details.documentUrl || details.originUrl || details.url) || 'unknown';",
" return `ampb:${store}:${firstParty}`.slice(0, 220);",
"}",
"",
"function torProxy(details) {",
" const token = isolationToken(details);",
" return {",
" type: 'socks', host: TOR_PROXY.host, port: TOR_PROXY.port, proxyDNS: true,",
" username: '<torS0X>0', password: token, connectionIsolationKey: token, failoverTimeout: 1,",
" };",
"}",
"",
"function i2pProxy() {",
" return { type: 'http', host: I2P_PROXY.host, port: I2P_PROXY.port, failoverTimeout: 1 };",
"}",
"",
"function blockedProxy() {",
" return { type: 'http', host: '127.0.0.1', port: 9, failoverTimeout: 1 };",
"}",
"",
"function proxyForRequest(details) {",
" if (isHelperRequest(details)) return { type: 'direct' };",
" if (details.url.startsWith(HELPER_URL)) return blockedProxy();",
" const target = transportForUrl(details.url);",
" const current = transportForStore(details.cookieStoreId);",
" if (target === 'tor' && current !== 'tor') return torProxy(details);",
" if (target === 'i2p' && current !== 'i2p') return i2pProxy();",
" if (current === 'tor') return torProxy(details);",
" if (current === 'i2p') return target === 'i2p' ? i2pProxy() : blockedProxy();",
" return { type: 'direct' };",
"}",
"",
"async function replaceTab(tabId, transport, url) {",
" await containersReady;",
" const oldTab = await browser.tabs.get(tabId);",
" const cookieStoreId = storesByTransport.get(transport);",
" if (!cookieStoreId) throw new Error(`No container for ${transport}`);",
" const newTab = await browser.tabs.create({",
" windowId: oldTab.windowId, index: oldTab.index, active: oldTab.active,",
" pinned: oldTab.pinned, cookieStoreId, url,",
" });",
" if (oldTab.mutedInfo && oldTab.mutedInfo.muted) await browser.tabs.update(newTab.id, { muted: true });",
" await browser.tabs.remove(tabId);",
"}",
"",
"async function routeTopLevel(details, transport, current) {",
" try {",
" await containersReady;",
" if (transport === 'clearnet') {",
" if (!current) await replaceTab(details.tabId, transport, details.url);",
" else await browser.tabs.update(details.tabId, {",
" url: setupUrl('switch', transport, details.url, { message: 'Switching to clearnet can reveal your network address.' }),",
" });",
" return;",
" }",
" const result = await helper('status', transport, details.url);",
" if (result.ready) await replaceTab(details.tabId, transport, details.url);",
" else await browser.tabs.update(details.tabId, { url: setupUrl('setup', transport, details.url, result) });",
" } catch (error) {",
" await browser.tabs.update(details.tabId, {",
" url: setupUrl('error', transport, details.url, { message: String(error) }),",
" });",
" } finally {",
" pendingByTab.delete(details.tabId);",
" }",
"}",
"",
"function interceptRequest(details) {",
" if (isHelperRequest(details)) return {};",
" if (details.url.startsWith(HELPER_URL)) return { cancel: true };",
" const target = transportForUrl(details.url);",
" if (!target) return {};",
" const current = transportForStore(details.cookieStoreId);",
" if (current === target) return {};",
" if (current === 'tor' && target === 'clearnet') 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 routeTopLevel(details, target, current);",
" return { cancel: true };",
"}",
"",
"browser.proxy.onRequest.addListener(proxyForRequest, { urls: ['<all_urls>'] });",
"browser.webRequest.onBeforeRequest.addListener(",
" interceptRequest, { urls: ['<all_urls>'] }, ['blocking']",
");",
"",
"browser.runtime.onMessage.addListener(async (message, sender) => {",
" if (!message || !sender.tab || sender.tab.id < 0) return false;",
" if (message.type === 'ampb-confirm-switch') {",
" setTimeout(() => void replaceTab(sender.tab.id, message.transport, message.url), 0);",
" return { ok: true, switching: true };",
" }",
" if (message.type !== 'ampb-ensure-and-switch') return false;",
" const result = await helper('ensure', message.transport, message.url);",
" if (result.ready) setTimeout(() => void replaceTab(sender.tab.id, message.transport, message.url), 0);",
" return { ...result, switching: result.ready };",
"});",
"browser.tabs.onRemoved.addListener((tabId) => pendingByTab.delete(tabId));",
"",
"async function normalizeOpenTabs() {",
" await containersReady;",
" const tabs = await browser.tabs.query({});",
" for (const tab of tabs) {",
" if (tab.id < 0 || transportForStore(tab.cookieStoreId)) continue;",
" const transport = transportForUrl(tab.url || '');",
" if (transport === 'clearnet') await replaceTab(tab.id, transport, tab.url);",
" }",
"}",
"void normalizeOpenTabs();",
"",
)
return "\n".join(lines)
def _route_helper_setup_html() -> str:
@@ -591,7 +706,7 @@ def _route_helper_setup_html() -> str:
"</head>",
"<body>",
' <main class="panel">',
' <p class="eyebrow">AMPB Route Helper</p>',
' <p class="eyebrow">AMPB</p>',
' <h1 id="title">Set Up Transport</h1>',
' <p id="body">AMPB needs to start a local transport before opening this route.</p>',
' <pre id="url"></pre>',
@@ -614,6 +729,7 @@ def _route_helper_setup_js() -> str:
'"use strict";',
"",
"const params = new URLSearchParams(window.location.search);",
"const mode = params.get('mode') || 'setup';",
"const transport = params.get('transport') || '';",
"const url = params.get('url') || '';",
"const message = params.get('message') || '';",
@@ -632,21 +748,23 @@ def _route_helper_setup_js() -> str:
" installCommand.textContent = value;",
"}",
"",
"document.getElementById('title').textContent = `Set up ${label}?`;",
"document.getElementById('body').textContent = `AMPB can start a managed ${label} session and keep its state isolated.`;",
"const switching = mode === 'switch';",
"document.getElementById('title').textContent = switching ? `Switch to ${label}?` : `Set up ${label}?`;",
"document.getElementById('body').textContent = switching",
" ? `This tab will continue in the isolated ${label} container.`",
" : `AMPB can start a managed ${label} session and continue in an isolated ${label} container tab.`;",
"document.getElementById('url').textContent = url;",
"document.getElementById('message').textContent = hint || message;",
"document.getElementById('approve').textContent = `Start ${label} and open`;",
"document.getElementById('approve').textContent = switching ? `Switch to ${label}` : `Start ${label} and continue`;",
"showInstallCommand(install);",
"",
"document.getElementById('approve').addEventListener('click', async () => {",
" const status = document.getElementById('status');",
" status.textContent = `Starting ${label}...`;",
" 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()}`);",
" status.textContent = switching ? `Switching to ${label}...` : `Starting ${label}...`;",
" const type = switching ? 'ampb-confirm-switch' : 'ampb-ensure-and-switch';",
" const result = await browser.runtime.sendMessage({ type, transport, url });",
" if (result && result.switching) {",
" status.textContent = `Continuing in the ${label} tab...`;",
" return;",
" }",
" if (result && Array.isArray(result.install_command)) {",
@@ -676,49 +794,6 @@ def _route_helper_setup_css() -> str:
)
def _route_helper_handoff_html() -> str:
return "\n".join(
(
"<!doctype html>",
'<html lang="en">',
"<head>",
' <meta charset="utf-8">',
' <meta name="viewport" content="width=device-width, initial-scale=1">',
" <title>Route Opened</title>",
' <link rel="stylesheet" href="setup.css">',
"</head>",
"<body>",
' <main class="panel">',
' <p class="eyebrow">AMPB Transport Broker</p>',
' <h1 id="title">Route opened</h1>',
' <p id="body">The destination is open in its isolated transport profile.</p>',
' <pre id="url"></pre>',
" </main>",
' <script src="handoff.js"></script>',
"</body>",
"</html>",
"",
)
)
def _route_helper_handoff_js() -> str:
return "\n".join(
(
'"use strict";',
"",
"const params = new URLSearchParams(window.location.search);",
"const transport = params.get('transport') || '';",
"const url = params.get('url') || '';",
"const label = transport === 'tor' ? 'Tor' : transport === 'i2p' ? 'I2P' : transport;",
"document.getElementById('title').textContent = `${label} route opened`;",
"document.getElementById('body').textContent = `This destination opened in the isolated ${label} profile. This clearnet profile did not load it.`;",
"document.getElementById('url').textContent = url;",
"",
)
)
def _endpoint_host_port(endpoint: str) -> tuple[str, int]:
parsed = urlparse(endpoint)
return parsed.hostname or "127.0.0.1", parsed.port or 0
+2 -2
View File
@@ -103,7 +103,7 @@ BROWSER_BACKENDS = (
"primary desktop runtime",
"planned",
"desktop",
"bundled Firefox/Gecko-lineage app with AMPB-owned profiles and transports",
"bundled Firefox/Gecko-lineage app with one window and AMPB-owned transport contexts",
"no system browser dependency; track Firefox ESR, Tor Browser, and Mullvad hardening choices",
"Default desktop runtime; users should not need Firefox installed.",
),
@@ -113,7 +113,7 @@ BROWSER_BACKENDS = (
"planned",
"android",
"bundled GeckoView/Fenix/Tor Browser Android-lineage app with foreground transports",
"no system browser dependency; app owns profile, proxy policy, and transport lifecycle",
"no system browser dependency; app owns context, proxy policy, and transport lifecycle",
"Default Android runtime for real mobile AMPB.",
),
BrowserBackend(
+2 -2
View File
@@ -24,8 +24,8 @@ class PlatformCapability:
PLATFORM_CAPABILITIES = (
PlatformCapability("clearnet", "desktop", "ready", "ready", "unsupported", "unsupported", "Use bundled AMPB Gecko desktop runtime with isolated profile."),
PlatformCapability("clearnet", "android", "ready", "ready", "unsupported", "unsupported", "Use bundled AMPB GeckoView Android runtime with isolated profile."),
PlatformCapability("clearnet", "desktop", "ready", "ready", "unsupported", "unsupported", "Use one bundled AMPB Gecko window with isolated transport contexts."),
PlatformCapability("clearnet", "android", "ready", "ready", "unsupported", "unsupported", "Use one bundled GeckoView app with isolated transport sessions."),
PlatformCapability("clearnet", "ios", "ready", "ready", "unsupported", "unsupported", "Use bundled AMPB iOS shell with platform WebKit constraints."),
PlatformCapability("tor", "desktop", "ready", "ready", "planned", "planned", "Adopt local Tor or prompt before managed Arti SOCKS setup."),
PlatformCapability("tor", "android", "planned", "planned", "planned", "planned", "Use an app-owned foreground service; prefer Arti/Tor runtime integration where available."),
-46
View File
@@ -8,9 +8,7 @@ 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
@@ -35,24 +33,6 @@ 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 '-'}")
@@ -151,32 +131,6 @@ 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,
+13 -16
View File
@@ -14,13 +14,11 @@ isolation decisions outside the browser engine until those contracts are stable.
4. Adopt healthy existing transports.
5. If the selected transport is missing, show a first-use prompt before setup.
6. Start or install managed transports only after user consent.
7. Open the URL with the matching isolated profile.
7. Continue in a tab carrying the matching transport context.
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.
AMPB has one visible browser window. Each web tab belongs to a named transport container.
A top-level route change replaces that tab in the same window; subresources cannot change
transport. The container controls site-data separation and fail-closed proxy policy.
## Main Components
@@ -28,8 +26,6 @@ profile and browser process.
- `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.
@@ -48,9 +44,9 @@ Browser equivalence until it tracks the relevant Tor Browser hardening, fingerpr
proxy, and update behavior. AMPB should not depend on system Firefox, system Chrome, or
the user's default browser.
The long-term browser can become a deeper Firefox, GeckoView, or Tor Browser fork once
the orchestration surface is reliable: profile isolation is predictable, transport adoption
is boring, and generated fixtures from AMPG open without manual proxy setup.
The release browser requires a reviewed Firefox/Gecko integration. Transport context must
be carried below WebExtensions through origin attributes, channel load information,
connection pools, storage, cache, service workers, and content-process selection.
Generated backend metadata lives in
[generated browser strategy](generated/browser-strategy.md).
@@ -59,16 +55,17 @@ The first implementation path is documented in [desktop vertical](desktop-vertic
## Isolation Boundaries
- Browser storage is isolated by transport profile.
- The prototype isolates cookies and site data with Firefox transport containers.
- 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
- A transport context remains sticky: ordinary navigation stays in that context 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.
managing it. Web content cannot invoke transport management directly.
These controls prevent accidental browser-state mixing. They do not by themselves provide
Tor Browser fingerprinting resistance or make non-anonymous transports anonymous.
Container controls do not separate all Firefox profile state and do not by themselves
provide Tor Browser fingerprinting resistance. Release anonymity claims require the native
Gecko boundary and tracked Tor Browser hardening.
## Relationship To AMPG
+32 -25
View File
@@ -1,39 +1,46 @@
# Transport Broker
# Transport Contexts
> 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.
AMPB presents clearnet, Tor, and I2P in one browser window. Every web tab has a visible
transport container. Changing transports replaces the tab in the same position and window;
it never opens a transport-specific browser window.
## Handoff
## Tab Flow
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.
1. AMPB routes a top-level URL before it loads.
2. The current tab continues when its transport context is compatible.
3. A different transport is started or adopted only after first-use consent.
4. AMPB creates the destination tab in the matching container and removes the old tab.
5. Firefox displays the container name and color on the tab and address bar.
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.
Subresources cannot switch transports. A mismatched image, frame, script, WebSocket, or
background request is blocked. Tor tabs may reach clearnet destinations through Tor. I2P
tabs are restricted to I2P destinations unless the user explicitly approves a switch to
clearnet.
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.
## Network Policy
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.
- Clearnet containers use direct networking.
- Tor containers use SOCKS with proxy-side DNS and a stable first-party isolation token.
- I2P containers use the local I2P HTTP proxy and fail closed for non-I2P destinations.
- No transport has a direct fallback when its proxy is unavailable.
- Speculative connections, prefetching, WebRTC, HTTP/3, and browser DNS-over-HTTPS are
disabled in the container prototype.
## Boundaries
The token-gated loopback helper can inspect, start, or adopt transports. It cannot open
browser windows or choose a tab's route.
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>`.
## Security Boundary
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.
Firefox containers separate cookies, logins, and site data, but they do not separate every
piece of profile state. The one-window WebExtension implementation is therefore a UX and
routing prototype, not a claim of Tor Browser anonymity equivalence.
The release-grade boundary belongs in Gecko. A native `TransportContextId` carried through
origin attributes and channel load information must partition storage, cache, permissions,
connections, DNS, service workers, and content processes while applying fail-closed network
policy below extension code.
## Command
+3 -3
View File
@@ -28,9 +28,9 @@ artifacts are available for the selected checkout.
The first transport target is Tor through an AMPB-owned Arti/Tor runtime. The current
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.
and keep its token-gated helper tied to the browser process. Clearnet, Tor, and I2P remain
in one visible window as transport container tabs. Reticulum follows after the
resilient-network adapter and inert document renderer are concrete.
## Desktop Shell
+8 -4
View File
@@ -3,10 +3,14 @@
> 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, uses
the declared transport-profile isolation contract, and fits that transport's interaction
that each generated fixture URL routes to the expected AMPB transport context, uses
the declared transport-context isolation contract, and fits that transport's interaction
policy.
`ampg.fixture-manifest.v2` uses `checks.context` for the logical browser context. AMPB can
still read v1 manifests for route compatibility, but v2 is required for the current
one-window isolation contract.
Route-expanded fixtures may include `route.match` and `route.fixture_path`, which identify
the route group that produced the fixture.
@@ -21,8 +25,8 @@ manifest.
## Scope
Fixture checks validate route selection, profile isolation expectations, and declared
interaction policy only. They do not verify process separation, daemon health, hidden
Fixture checks validate route selection, context isolation expectations, and declared
interaction policy only. They do not verify engine-level partitioning, daemon health, hidden
service reachability, IPFS pinning, TLS, or content freshness.
The manifest must not contain private deployment notes, service keys, credentials, or
+2 -2
View File
@@ -6,8 +6,8 @@ This file is generated from code. Do not edit it by hand.
| Backend | Role | Status | Platforms | Launch Mode | Privacy Posture | Note |
| --- | --- | --- | --- | --- | --- | --- |
| `ampb-gecko-desktop` | primary desktop runtime | `planned` | desktop | bundled Firefox/Gecko-lineage app with AMPB-owned profiles and transports | no system browser dependency; track Firefox ESR, Tor Browser, and Mullvad hardening choices | Default desktop runtime; users should not need Firefox installed. |
| `ampb-geckoview-android` | primary android runtime | `planned` | android | bundled GeckoView/Fenix/Tor Browser Android-lineage app with foreground transports | no system browser dependency; app owns profile, proxy policy, and transport lifecycle | Default Android runtime for real mobile AMPB. |
| `ampb-gecko-desktop` | primary desktop runtime | `planned` | desktop | bundled Firefox/Gecko-lineage app with one window and AMPB-owned transport contexts | no system browser dependency; track Firefox ESR, Tor Browser, and Mullvad hardening choices | Default desktop runtime; users should not need Firefox installed. |
| `ampb-geckoview-android` | primary android runtime | `planned` | android | bundled GeckoView/Fenix/Tor Browser Android-lineage app with foreground transports | no system browser dependency; app owns context, proxy policy, and transport lifecycle | Default Android runtime for real mobile AMPB. |
| `ampb-webkit-ios` | ios runtime | `planned` | ios | bundled iOS app shell with WebKit views and embedded transport runtimes | platform constrained; avoid clearnet fallback and isolate transport state | Default iOS path unless alternate browser-engine entitlements become practical. |
| `tor-browser-lineage` | tor-hardened target | `planned` | desktop, android | reuse or track Tor Browser patches after AMPB transport contracts are stable | highest Tor web target; do not claim equivalence until Tor Browser patches are tracked | Path toward real Tor Browser-grade behavior for .onion browsing. |
| `gemini-native-viewer` | alternate-web viewer | `planned` | desktop, android, ios | built-in fetch and render path | no web engine or shared browser storage required | Used for Gemini and similar lightweight document transports. |
+2 -2
View File
@@ -6,8 +6,8 @@ This file is generated from code. Do not edit it by hand.
| Transport | Platform | Browse | Adopt | Manage | Install | Note |
| --- | --- | --- | --- | --- | --- | --- |
| `clearnet` | `desktop` | `ready` | `ready` | `unsupported` | `unsupported` | Use bundled AMPB Gecko desktop runtime with isolated profile. |
| `clearnet` | `android` | `ready` | `ready` | `unsupported` | `unsupported` | Use bundled AMPB GeckoView Android runtime with isolated profile. |
| `clearnet` | `desktop` | `ready` | `ready` | `unsupported` | `unsupported` | Use one bundled AMPB Gecko window with isolated transport contexts. |
| `clearnet` | `android` | `ready` | `ready` | `unsupported` | `unsupported` | Use one bundled GeckoView app with isolated transport sessions. |
| `clearnet` | `ios` | `ready` | `ready` | `unsupported` | `unsupported` | Use bundled AMPB iOS shell with platform WebKit constraints. |
| `tor` | `desktop` | `ready` | `ready` | `planned` | `planned` | Adopt local Tor or prompt before managed Arti SOCKS setup. |
| `tor` | `android` | `planned` | `planned` | `planned` | `planned` | Use an app-owned foreground service; prefer Arti/Tor runtime integration where available. |
+5 -5
View File
@@ -34,11 +34,11 @@ 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 <clearnet-url> --broker --launch` creates the desktop entry profile. The
broker has direct clearnet networking and no mixed-transport PAC. Its extension cancels
all `.onion` and `.i2p` requests before they leave the profile. For a top-level navigation,
it asks a token-gated loopback helper to open the URL in the isolated Tor or I2P profile.
Missing transports use the same first-use consent flow before the handoff. `--route-aware`
`ampbrowser open <clearnet-url> --broker --launch` creates the single-window desktop entry
profile. Its extension assigns every web tab to a visible clearnet, Tor, or I2P container.
When a top-level URL needs another transport, AMPB starts or adopts it after first-use
consent, creates the destination tab in the matching container at the same position, and
removes the old tab. It never opens a transport-specific browser window. `--route-aware`
remains a compatibility alias for `--broker`.
## Examples
+1 -1
View File
@@ -22,7 +22,7 @@ user can install a missing provider. I2P currently reports Homebrew, apt, dnf, p
or pkg guidance where applicable, plus the `AMPB_I2PD_BIN` and
`transports.i2p.binary_path` override path.
Route-aware desktop launches add a local helper process for in-browser setup prompts. The
Single-window desktop launches add a local helper process for in-browser setup prompts. The
helper accepts token-gated loopback requests from the AMPB profile extension, reports
transport status, and starts only supported AMPB-managed transports. It does not expose a
public network listener or manage adopted daemons. The helper watches the browser process
-54
View File
@@ -1,54 +0,0 @@
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()
+8 -8
View File
@@ -15,28 +15,28 @@ class FixtureCheckTest(unittest.TestCase):
path.write_text(
json.dumps(
{
"schema": "ampg.fixture-manifest.v1",
"schema": "ampg.fixture-manifest.v2",
"site": {"id": "wownero", "domain": "wownero.org"},
"fixtures": [
{
"protocol": "clearnet",
"url": "https://wownero.org/",
"checks": {"transport": "clearnet", "profile": "clearnet"},
"checks": {"transport": "clearnet", "context": "clearnet", "isolation": "transport-context"},
},
{
"protocol": "tor",
"url": "http://wownero.onion/",
"checks": {
"transport": "tor",
"profile": "tor",
"isolation": "transport-profile",
"context": "tor",
"isolation": "transport-context",
},
},
{
"protocol": "ipfs",
"url": "ipfs://bafyexample",
"route": {"match": "/snapshot/*", "fixture_path": "/snapshot/"},
"checks": {"transport": "ipfs", "profile": "ipfs"},
"checks": {"transport": "ipfs", "context": "ipfs", "isolation": "transport-context"},
"interaction": {"tier": "static"},
},
],
@@ -49,7 +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("transport-context", result.checks[1].actual_isolation)
self.assertEqual("/snapshot/*", result.checks[2].route_match)
self.assertEqual("/snapshot/", result.checks[2].fixture_path)
@@ -115,13 +115,13 @@ def _check_one(transport: str, url: str, interaction: dict) -> object:
path.write_text(
json.dumps(
{
"schema": "ampg.fixture-manifest.v1",
"schema": "ampg.fixture-manifest.v2",
"site": {"id": "example", "domain": "example.test"},
"fixtures": [
{
"protocol": transport,
"url": url,
"checks": {"transport": transport, "profile": transport},
"checks": {"transport": transport, "context": transport, "isolation": "transport-context"},
"interaction": interaction,
}
],
+18 -5
View File
@@ -141,7 +141,7 @@ 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_broker_open_writes_direct_profile_and_handoff_extension(self) -> None:
def test_broker_open_writes_single_window_container_router(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runtime = root / "firefox"
@@ -153,6 +153,7 @@ class PrepareOpenTest(unittest.TestCase):
self.assertIsNotNone(plan.launch_spec)
self.assertTrue(plan.launch_spec.broker)
self.assertIn('user_pref("network.proxy.type", 0);', plan.launch_spec.prefs)
self.assertIn('user_pref("media.peerconnection.enabled", false);', 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)
@@ -185,17 +186,29 @@ class PrepareOpenTest(unittest.TestCase):
background = (extension / "background.js").read_text(encoding="utf-8")
setup = (extension / "setup.html").read_text(encoding="utf-8")
self.assertIn("webRequestBlocking", manifest)
self.assertIn("http://*.onion/*", manifest)
self.assertIn("wss://*.i2p/*", manifest)
self.assertIn("contextualIdentities", manifest)
self.assertIn('"proxy"', manifest)
self.assertIn("<all_urls>", manifest)
self.assertIn("cancel: true", background)
self.assertIn("helper('open'", background)
self.assertIn("browser.proxy.onRequest", background)
self.assertIn("cookieStoreId", background)
self.assertIn("browser.tabs.create", background)
self.assertIn("proxyDNS: true", background)
self.assertIn("'<torS0X>0'", background)
self.assertIn("connectionIsolationKey", background)
self.assertIn("blockedProxy", background)
self.assertIn("isHelperRequest", background)
self.assertIn("details.tabId < 0", background)
self.assertIn("helper('status'", background)
self.assertIn("helper('ensure'", background)
self.assertNotIn("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())
self.assertFalse((extension / "handoff.html").exists())
def test_broker_entry_rejects_alternate_transport_url(self) -> None:
plan = prepare_open("http://example.onion/", broker=True)
+2 -39
View File
@@ -1,16 +1,13 @@
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):
@@ -84,41 +81,7 @@ 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:
def test_open_action_is_not_exposed(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
response = handle_helper_message(
{
@@ -131,7 +94,7 @@ class RouteHelperTest(unittest.TestCase):
)
self.assertFalse(response["ok"])
self.assertEqual("route-mismatch", response["status"])
self.assertEqual("unsupported-action", response["status"])
if __name__ == "__main__":