mirror of
https://github.com/Such-Software/adaptive-multi-protocol-browser.git
synced 2026-10-17 14:31:46 +02:00
Keep transports in one browser window
This commit is contained in:
@@ -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/`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user