diff --git a/README.md b/README.md index 891c2ab..bc85498 100644 --- a/README.md +++ b/README.md @@ -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/`. diff --git a/ampbrowser/broker.py b/ampbrowser/broker.py deleted file mode 100644 index bc6f216..0000000 --- a/ampbrowser/broker.py +++ /dev/null @@ -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) diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index 3268213..7c05c92 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -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} " diff --git a/ampbrowser/fixtures.py b/ampbrowser/fixtures.py index a4e5548..57e718b 100644 --- a/ampbrowser/fixtures.py +++ b/ampbrowser/fixtures.py @@ -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"], diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py index 958ebfb..31d6c4c 100644 --- a/ampbrowser/launch.py +++ b/ampbrowser/launch.py @@ -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/*", + "", "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: '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: [''] });", + "browser.webRequest.onBeforeRequest.addListener(", + " interceptRequest, { 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: "", "", '
', - '

AMPB Route Helper

', + '

AMPB

', '

Set Up Transport

', '

AMPB needs to start a local transport before opening this route.

', '
',
@@ -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(
-        (
-            "",
-            '',
-            "",
-            '  ',
-            '  ',
-            "  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 diff --git a/ampbrowser/metadata.py b/ampbrowser/metadata.py index bb9f7eb..6f1ff6d 100644 --- a/ampbrowser/metadata.py +++ b/ampbrowser/metadata.py @@ -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( diff --git a/ampbrowser/platforms.py b/ampbrowser/platforms.py index aa3a5b7..46cf1e4 100644 --- a/ampbrowser/platforms.py +++ b/ampbrowser/platforms.py @@ -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."), diff --git a/ampbrowser/route_helper.py b/ampbrowser/route_helper.py index 986983d..6c0c554 100644 --- a/ampbrowser/route_helper.py +++ b/ampbrowser/route_helper.py @@ -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, diff --git a/docs/architecture.md b/docs/architecture.md index df3ae27..4f88a63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/broker.md b/docs/broker.md index 5ab4f94..3be2322 100644 --- a/docs/broker.md +++ b/docs/broker.md @@ -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/`. +## 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 diff --git a/docs/desktop-vertical.md b/docs/desktop-vertical.md index 162c82e..27a27af 100644 --- a/docs/desktop-vertical.md +++ b/docs/desktop-vertical.md @@ -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 diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index 556bb84..185c4f6 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -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 diff --git a/docs/generated/browser-strategy.md b/docs/generated/browser-strategy.md index 8025349..28a5a0b 100644 --- a/docs/generated/browser-strategy.md +++ b/docs/generated/browser-strategy.md @@ -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. | diff --git a/docs/generated/platform-capabilities.md b/docs/generated/platform-capabilities.md index b4b64f5..48e1811 100644 --- a/docs/generated/platform-capabilities.md +++ b/docs/generated/platform-capabilities.md @@ -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. | diff --git a/docs/open-flow.md b/docs/open-flow.md index dfd326a..e88708d 100644 --- a/docs/open-flow.md +++ b/docs/open-flow.md @@ -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 --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 --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 diff --git a/docs/transport-management.md b/docs/transport-management.md index cbcb71a..1e4d3c6 100644 --- a/docs/transport-management.md +++ b/docs/transport-management.md @@ -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 diff --git a/tests/test_broker.py b/tests/test_broker.py deleted file mode 100644 index c1236c6..0000000 --- a/tests/test_broker.py +++ /dev/null @@ -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() diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 9576fe8..0fca463 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -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, } ], diff --git a/tests/test_launch.py b/tests/test_launch.py index a5b14ec..95f86be 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -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("", 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("'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) diff --git a/tests/test_route_helper.py b/tests/test_route_helper.py index a06cd04..0412853 100644 --- a/tests/test_route_helper.py +++ b/tests/test_route_helper.py @@ -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__":