diff --git a/README.md b/README.md
index ee2a7e0..baded96 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Adaptive Multi-Protocol Browser
-> Status: draft | Updated 2026-07-07 | Applies to: AMPB contributors and operators
+> Status: draft | Updated 2026-07-11 | Applies to: AMPB contributors and operators
Adaptive Multi-Protocol Browser, or AMPB, is a transport-aware browser shell. It routes
URLs to the right network, adopts transports that are already healthy, and manages missing
@@ -11,8 +11,8 @@ app, Android targets bundled GeckoView/Fenix/Tor Browser Android-lineage code, a
uses an AMPB-owned shell with platform browser-engine constraints.
The first fixture is Wownero across clearnet, Tor, I2P, Gemini, and Reticulum-oriented
-targets generated by AMPG. Interactive fixtures include `ai-gen-bot` and the Medusa
-multi-tenant platform.
+targets generated by AMPG. Interactive fixtures should be described publicly by the
+capabilities they exercise, while project-specific target notes stay in private docs.
## Goals
@@ -21,6 +21,8 @@ multi-tenant platform.
- Prompt on first visit before installing or starting a missing transport.
- Manage approved transports through isolated AMPB state.
- Keep browser profiles isolated by transport.
+- Treat route-aware desktop launches as a convenience and QA mode until storage isolation
+ is proven for mixed clearnet, Tor, and I2P navigation.
- Present transport health in plain language.
## Non-goals
diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py
index 7f51a77..a64d3ce 100644
--- a/ampbrowser/cli.py
+++ b/ampbrowser/cli.py
@@ -15,7 +15,7 @@ from .plan import plan_url
from .platforms import PLATFORM_CHOICES
from .route_helper import serve_route_helper
from .routing import route_url
-from .transport_manager import ensure_transport_ready, stop_managed_transport, transport_status
+from .transport_manager import ensure_transport_ready, repair_managed_transport, stop_managed_transport, transport_status
from .transports import inspect_transports
@@ -80,6 +80,9 @@ def main(argv: list[str] | None = None) -> int:
transport_stop_parser = transport_subcommands.add_parser("stop", help="Stop an AMPB-owned transport.")
transport_stop_parser.add_argument("transport")
transport_stop_parser.add_argument("--config", type=Path, help="Path to an AMPB config file.")
+ transport_repair_parser = transport_subcommands.add_parser("repair", help="Reset AMPB-owned runtime state for a transport.")
+ transport_repair_parser.add_argument("transport")
+ transport_repair_parser.add_argument("--config", type=Path, help="Path to an AMPB config file.")
fixture_parser = subcommands.add_parser("fixture", help="Check AMPG fixture manifests.")
fixture_subcommands = fixture_parser.add_subparsers(dest="fixture_command", required=True)
@@ -286,9 +289,12 @@ def _cmd_transport(args) -> int:
result = ensure_transport_ready(args.transport, config=config, root=Path.cwd())
elif args.transport_command == "stop":
result = stop_managed_transport(args.transport, config=config, root=Path.cwd())
+ elif args.transport_command == "repair":
+ result = repair_managed_transport(args.transport, config=config, root=Path.cwd())
else:
return 1
command = shlex.join(result.command) if result.command else "-"
+ install_command = shlex.join(result.install_command) if result.install_command else "-"
print(
"AMPBROWSER_TRANSPORT "
f"transport={result.transport} "
@@ -301,6 +307,8 @@ def _cmd_transport(args) -> int:
f"endpoint={result.endpoint} "
f"state_dir={result.state_dir} "
f"command=\"{_safe(command)}\" "
+ f"install_command=\"{_safe(install_command)}\" "
+ f"setup_hint=\"{_safe(result.setup_hint)}\" "
f"message=\"{_safe(result.message)}\""
)
return 0
diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py
index c6654b3..2ec51ac 100644
--- a/ampbrowser/launch.py
+++ b/ampbrowser/launch.py
@@ -549,8 +549,9 @@ def _route_helper_background_js() -> str:
" }",
" if (status.ready) return;",
" pendingByTab.set(details.tabId, details.url);",
+ " const installCommand = Array.isArray(status.install_command) ? status.install_command.join(' ') : '';",
" const setupUrl = browser.runtime.getURL(",
- " `setup.html?transport=${encodeURIComponent(transport)}&url=${encodeURIComponent(details.url)}&message=${encodeURIComponent(status.message || '')}`",
+ " `setup.html?transport=${encodeURIComponent(transport)}&url=${encodeURIComponent(details.url)}&message=${encodeURIComponent(status.message || '')}&hint=${encodeURIComponent(status.setup_hint || '')}&install=${encodeURIComponent(installCommand)}`",
" );",
" await browser.tabs.update(details.tabId, { url: setupUrl });",
"}",
@@ -584,6 +585,7 @@ def _route_helper_setup_html() -> str:
'
AMPB needs to start a local transport before opening this route.
',
' ',
' ',
+ ' ',
' ',
' ',
" ",
@@ -604,13 +606,27 @@ def _route_helper_setup_js() -> str:
"const transport = params.get('transport') || '';",
"const url = params.get('url') || '';",
"const message = params.get('message') || '';",
+ "const hint = params.get('hint') || '';",
+ "const install = params.get('install') || '';",
"const label = transport === 'tor' ? 'Tor' : transport === 'i2p' ? 'I2P' : transport;",
"",
+ "function showInstallCommand(value) {",
+ " const installCommand = document.getElementById('install-command');",
+ " if (!value) {",
+ " installCommand.hidden = true;",
+ " installCommand.textContent = '';",
+ " return;",
+ " }",
+ " installCommand.hidden = false;",
+ " 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.`;",
"document.getElementById('url').textContent = url;",
- "document.getElementById('message').textContent = message;",
+ "document.getElementById('message').textContent = hint || message;",
"document.getElementById('approve').textContent = `Start ${label} and open`;",
+ "showInstallCommand(install);",
"",
"document.getElementById('approve').addEventListener('click', async () => {",
" const status = document.getElementById('status');",
@@ -621,6 +637,9 @@ def _route_helper_setup_js() -> str:
" window.location.href = url;",
" return;",
" }",
+ " if (result && Array.isArray(result.install_command)) {",
+ " showInstallCommand(result.install_command.join(' '));",
+ " }",
" status.textContent = (result && result.message) || `${label} did not become ready.`;",
"});",
"",
@@ -637,6 +656,7 @@ def _route_helper_setup_css() -> str:
".eyebrow { color: #36d1c4; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }",
"h1 { font-size: 2rem; margin: 0 0 12px; }",
"pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; border: 1px solid #444; }",
+ ".install-command { background: #1d1d1d; border-color: #36d1c4; color: #f7f1e8; }",
"button { font: inherit; font-weight: 700; padding: 12px 16px; border: 0; background: #36d1c4; color: #111; }",
".message, .status { color: #cfc7bb; }",
"",
diff --git a/ampbrowser/route_helper.py b/ampbrowser/route_helper.py
index e41a962..6c0c554 100644
--- a/ampbrowser/route_helper.py
+++ b/ampbrowser/route_helper.py
@@ -126,6 +126,8 @@ def _result_response(result: ManagedTransportResult) -> dict[str, Any]:
"state_dir": result.state_dir,
"message": result.message,
"command": list(result.command),
+ "setup_hint": result.setup_hint,
+ "install_command": list(result.install_command),
}
diff --git a/ampbrowser/transport_manager.py b/ampbrowser/transport_manager.py
index 0b44fad..bec680f 100644
--- a/ampbrowser/transport_manager.py
+++ b/ampbrowser/transport_manager.py
@@ -8,6 +8,7 @@ import signal
import shutil
import socket
import subprocess
+import sys
import time
from urllib.parse import urlparse
@@ -34,6 +35,12 @@ class I2PProvider:
source: str
+@dataclass(frozen=True)
+class SetupHint:
+ message: str
+ command: tuple[str, ...] = ()
+
+
@dataclass(frozen=True)
class ManagedTransportResult:
transport: str
@@ -46,6 +53,8 @@ class ManagedTransportResult:
command: tuple[str, ...]
message: str
provider_source: str = "-"
+ setup_hint: str = "-"
+ install_command: tuple[str, ...] = ()
@property
def ready(self) -> bool:
@@ -140,6 +149,8 @@ def transport_status(
_managed_state_dir(root, config, transport),
(),
f"{transport} transport is not running",
+ setup_hint=_stopped_setup_hint(transport, config),
+ install_command=_stopped_install_command(transport, config),
)
@@ -244,6 +255,66 @@ def stop_managed_transport(
)
+def repair_managed_transport(
+ transport: str,
+ *,
+ config: AppConfig,
+ root: Path,
+ wait_seconds: float = 5.0,
+) -> ManagedTransportResult:
+ adapter = adapter_for(transport)
+ if adapter is None:
+ return ManagedTransportResult(transport, "-", "unsupported", "-", False, 0, "-", (), "transport adapter not found")
+
+ state_dir = _managed_state_dir(root, config, transport)
+ state_path = Path(state_dir)
+ stop_result = stop_managed_transport(transport, config=config, root=root, wait_seconds=wait_seconds)
+ if stop_result.status in {"stop-failed", "stop-timeout"}:
+ return ManagedTransportResult(
+ transport,
+ stop_result.provider,
+ "repair-blocked",
+ stop_result.endpoint,
+ stop_result.owned,
+ stop_result.pid,
+ state_dir,
+ stop_result.command,
+ f"could not repair AMPB-owned {transport}: {stop_result.message}",
+ provider_source=stop_result.provider_source,
+ )
+
+ try:
+ shutil.rmtree(state_path)
+ except FileNotFoundError:
+ pass
+ except OSError as exc:
+ return ManagedTransportResult(
+ transport,
+ stop_result.provider,
+ "repair-failed",
+ stop_result.endpoint,
+ stop_result.owned,
+ stop_result.pid,
+ state_dir,
+ stop_result.command,
+ f"could not remove AMPB-owned {transport} runtime state: {exc}",
+ provider_source=stop_result.provider_source,
+ )
+
+ return ManagedTransportResult(
+ transport,
+ stop_result.provider,
+ "repaired",
+ stop_result.endpoint,
+ stop_result.owned,
+ stop_result.pid,
+ state_dir,
+ stop_result.command,
+ f"removed AMPB-owned {transport} runtime state; run `ampbrowser transport start {transport}` to rebuild it",
+ provider_source=stop_result.provider_source,
+ )
+
+
def _start_tor(
*,
config: AppConfig,
@@ -418,6 +489,7 @@ def _start_i2p(
provider = _i2p_provider(config)
state_dir = _managed_state_dir(root, config, "i2p")
if not provider:
+ setup = _i2p_setup_hint()
return ManagedTransportResult(
"i2p",
"-",
@@ -427,8 +499,9 @@ def _start_i2p(
0,
state_dir,
(),
- "I2P provider not found; on macOS run: brew install i2pd. "
- "Alternatively set AMPB_I2PD_BIN or transports.i2p.binary_path.",
+ "I2P provider not found. " + setup.message,
+ setup_hint=setup.message,
+ install_command=setup.command,
)
state_path = Path(state_dir)
@@ -507,6 +580,33 @@ def _i2p_provider(config: AppConfig) -> I2PProvider | None:
return None
+def _stopped_setup_hint(transport: str, config: AppConfig) -> str:
+ if transport == "i2p" and _i2p_provider(config) is None:
+ return _i2p_setup_hint().message
+ return "-"
+
+
+def _stopped_install_command(transport: str, config: AppConfig) -> tuple[str, ...]:
+ if transport == "i2p" and _i2p_provider(config) is None:
+ return _i2p_setup_hint().command
+ return ()
+
+
+def _i2p_setup_hint() -> SetupHint:
+ override = "Or set AMPB_I2PD_BIN or transports.i2p.binary_path to an i2pd binary."
+ if sys.platform == "darwin":
+ return SetupHint("Install i2pd with Homebrew: brew install i2pd. " + override, ("brew", "install", "i2pd"))
+ if shutil.which("pkg"):
+ return SetupHint("Install i2pd with pkg: pkg install i2pd. " + override, ("pkg", "install", "i2pd"))
+ if shutil.which("apt"):
+ return SetupHint("Install i2pd with apt: sudo apt install i2pd. " + override, ("sudo", "apt", "install", "i2pd"))
+ if shutil.which("dnf"):
+ return SetupHint("Install i2pd with dnf: sudo dnf install i2pd. " + override, ("sudo", "dnf", "install", "i2pd"))
+ if shutil.which("pacman"):
+ return SetupHint("Install i2pd with pacman: sudo pacman -S i2pd. " + override, ("sudo", "pacman", "-S", "i2pd"))
+ return SetupHint("Install i2pd with your package manager. " + override)
+
+
def _homebrew_i2pd_path() -> str:
brew = shutil.which("brew")
if not brew:
diff --git a/docs/autodocs.md b/docs/autodocs.md
index b954204..1894db4 100644
--- a/docs/autodocs.md
+++ b/docs/autodocs.md
@@ -1,6 +1,6 @@
# Autodocs
-> Status: draft | Updated 2026-07-07 | Applies to: AMPB maintainers
+> Status: draft | Updated 2026-07-11 | Applies to: AMPB maintainers
Public docs should stay concise and current. Code-owned behavior is documented from code,
while manual docs explain stable concepts and operator-facing choices.
@@ -41,3 +41,7 @@ Run:
```sh
python3 tools/docs_check.py
```
+
+The public docs gate checks status headers, internal links, repo-contained local links,
+private fixture target names, obvious private-key blocks, common API tokens, and assigned
+secret values. Private notes belong under ignored `docs/private/`.
diff --git a/docs/browser-builds.md b/docs/browser-builds.md
index a30768c..f3b1351 100644
--- a/docs/browser-builds.md
+++ b/docs/browser-builds.md
@@ -1,6 +1,6 @@
# Browser Builds
-> Status: draft | Updated 2026-07-07 | Applies to: AMPB desktop and mobile builders
+> Status: draft | Updated 2026-07-11 | Applies to: AMPB desktop and mobile builders
AMPB browser builds must keep source checkouts, object directories, toolchain caches, and
temporary artifacts outside this repository. The default local workspace is
@@ -99,8 +99,9 @@ system `tor` binary if one is present.
Managed I2P startup currently targets `i2pd`. AMPB adopts an existing HTTP proxy on
`127.0.0.1:4444` when available; otherwise `ampbrowser open --yes --launch`
uses `AMPB_I2PD_BIN`, configured `transports.i2p.binary_path`, or a system `i2pd` binary.
-The AMPB-owned process writes config, router data, logs, and ownership metadata under
-`.ampb/transports/i2p`.
+If no provider is found, AMPB returns a structured setup hint and install command for the
+CLI and route-helper page. The AMPB-owned process writes config, router data, logs, and
+ownership metadata under `.ampb/transports/i2p`.
## Source Plan
diff --git a/docs/desktop-vertical.md b/docs/desktop-vertical.md
index 1dce584..ba94097 100644
--- a/docs/desktop-vertical.md
+++ b/docs/desktop-vertical.md
@@ -1,6 +1,6 @@
# Desktop Vertical
-> Status: draft | Updated 2026-07-07 | Applies to: AMPB desktop builders
+> Status: draft | Updated 2026-07-11 | Applies to: AMPB desktop builders
The first AMPB browser proof is desktop-first with mobile constraints kept in view. The
vertical is a bundled Gecko desktop runtime, AMPB-owned profile state, and a managed Tor
@@ -26,10 +26,10 @@ artifacts are available for the selected checkout.
## Transport Path
-The first transport target is Tor through an AMPB-owned Arti/Tor runtime. The desktop
-vertical is not complete until AMPB can start the runtime, verify the SOCKS endpoint, and
-launch the bundled browser profile without clearnet fallback. I2P and Reticulum use the
-same route, consent, state, and health-check model after Tor works.
+The first transport target is Tor through an AMPB-owned Arti/Tor runtime. The current
+desktop vertical can launch a bundled Gecko runtime, start a route-aware helper, and keep
+the helper tied to the browser process. I2P uses the same route, consent, state, and
+health-check model. Reticulum follows after the resilient-network adapter is concrete.
## Desktop Shell
diff --git a/docs/open-flow.md b/docs/open-flow.md
index 5b18a81..1b6fea9 100644
--- a/docs/open-flow.md
+++ b/docs/open-flow.md
@@ -1,6 +1,6 @@
# Open Flow
-> Status: draft | Updated 2026-07-08 | Applies to: AMPB users and implementers
+> Status: draft | Updated 2026-07-11 | Applies to: AMPB users and implementers
`ampbrowser open` prepares a safe launch plan for one URL. It routes the URL, checks only
the selected transport, and reports whether the browser can open immediately or needs a
@@ -34,12 +34,13 @@ adopted daemon from an AMPB-owned managed process.
through a desktop dialog when possible, falls back to a terminal prompt when needed, and
launches through the same transport runner after approval.
-`ampbrowser open --route-aware --launch` creates one desktop profile for clearnet,
-Tor, and I2P. The profile uses a PAC file for `.onion` and `.i2p` routing, installs the
-AMPB route-helper extension, and starts a token-gated loopback helper tied to the browser
-process. If a user clicks an alternate-network link before its local transport is ready,
-the extension shows a setup page and asks the helper to start the AMPB-managed transport
-after approval.
+`ampbrowser open --route-aware --launch` creates a desktop convenience profile for
+clearnet, Tor, and I2P QA. The profile uses a PAC file for `.onion` and `.i2p` routing,
+installs the AMPB route-helper extension, and starts a token-gated loopback helper tied
+to the browser process. If a user clicks an alternate-network link before its local
+transport is ready, the extension shows a setup page and asks the helper to start the
+AMPB-managed transport after approval. This mode proves routing and first-use setup; it
+is not the final per-transport storage isolation model.
## Examples
diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md
index 53c08e1..b48b504 100644
--- a/docs/test-fixtures.md
+++ b/docs/test-fixtures.md
@@ -1,6 +1,6 @@
# Test Fixtures
-> Status: draft | Updated 2026-07-05 | Applies to: AMPB and AMPG compatibility testing
+> Status: draft | Updated 2026-07-11 | Applies to: AMPB and AMPG compatibility testing
Fixtures should cover static sites, protocol-specific publishing, and interactive web
apps without exposing private deployment notes.
@@ -13,9 +13,8 @@ HTML improvements that improve alternate renderers.
## Interactive Fixtures
-`ai-gen-bot` and `medusa-multi-tenant-platform` are stretch fixtures for dynamic behavior:
-sessions, forms, asset policy, script policy, tenant routing, and transport-specific
-degradation.
+Stretch fixtures cover dynamic behavior: sessions, forms, asset policy, script policy,
+tenant routing, payments, job-style workflows, and transport-specific degradation.
## Fixture Requirements
diff --git a/docs/transport-management.md b/docs/transport-management.md
index 133b890..cbcb71a 100644
--- a/docs/transport-management.md
+++ b/docs/transport-management.md
@@ -1,6 +1,6 @@
# Transport Management
-> Status: draft | Updated 2026-07-08 | Applies to: AMPB transport adapters
+> Status: draft | Updated 2026-07-11 | Applies to: AMPB transport adapters
Transport management follows one rule: use what is already healthy, and manage missing
transports only after the user opens a URL that needs them and approves setup.
@@ -13,9 +13,14 @@ With `--launch --yes`, AMPB can start configured Tor and I2P providers with AMPB
state before launching. Transport policy comes from `.ampb/config.toml` or an explicit
`--config` path.
-`ampbrowser transport start|status|stop ` exposes the same ownership model without
-launching a browser. It records only AMPB-owned processes and refuses to stop adopted
-user or system daemons.
+`ampbrowser transport start|status|stop|repair ` exposes the same ownership model
+without launching a browser. It records only AMPB-owned processes and refuses to stop
+adopted user or system daemons.
+
+Transport results include `setup_hint` and `install_command` when AMPB knows how the
+user can install a missing provider. I2P currently reports Homebrew, apt, dnf, pacman,
+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
helper accepts token-gated loopback requests from the AMPB profile extension, reports
@@ -43,12 +48,24 @@ Managed transports store runtime state under `.ampb/transports/` by defaul
leases, tunnels, and daemon logs in that tree are local state and must not be committed.
AMPB records owned process metadata in `.ampb/transports//ampb-owned.json`.
+If a managed transport reports ready but a route is flaky because of stale cache, bad
+guard selection, or corrupt local daemon state, reset only that transport with:
+
+```sh
+python3 -m ampbrowser transport repair tor
+python3 -m ampbrowser transport start tor
+```
+
+`repair` stops an AMPB-owned process when one is recorded, removes
+`.ampb/transports/`, and leaves `.ampb/profiles/` untouched. It refuses to
+erase runtime state if AMPB cannot stop a recorded live owned process.
+
## Current Adapters
- Tor: detect SOCKS on `127.0.0.1:9050`; managed start supports Arti, configured `tor`,
and system `tor` providers with AMPB-owned state.
-- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start supports configured or system
- `i2pd` providers with AMPB-owned state.
+- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start supports configured, bundled,
+ Homebrew, or system `i2pd` providers with AMPB-owned state.
- IPFS: detect HTTP gateway on `127.0.0.1:8080`; managed gateway start planned.
- Gemini: built-in route and render path; no daemon required for static browsing.
- Reticulum: resilient/private route contract exists; adapter and readiness model are planned.
diff --git a/tests/test_cli_open.py b/tests/test_cli_open.py
index e716f74..69fb27a 100644
--- a/tests/test_cli_open.py
+++ b/tests/test_cli_open.py
@@ -126,6 +126,55 @@ class CliOpenTest(unittest.TestCase):
helper.assert_called_once()
self.assertEqual(9876, helper.call_args.kwargs["watch_pid"])
+ def test_transport_command_prints_setup_hint(self) -> None:
+ result = ManagedTransportResult(
+ transport="i2p",
+ provider="-",
+ status="missing-provider",
+ endpoint="http://127.0.0.1:4444",
+ owned=False,
+ pid=0,
+ state_dir=".ampb/transports/i2p",
+ command=(),
+ message="I2P provider not found.",
+ setup_hint="Install i2pd with Homebrew: brew install i2pd.",
+ install_command=("brew", "install", "i2pd"),
+ )
+
+ with patch("ampbrowser.cli.transport_status", return_value=result):
+ with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout:
+ code = main(["transport", "status", "i2p"])
+
+ self.assertEqual(0, code)
+ output = stdout.getvalue()
+ self.assertIn('install_command="brew install i2pd"', output)
+ self.assertIn('setup_hint="Install i2pd with Homebrew: brew install i2pd."', output)
+
+ def test_transport_repair_command_prints_result(self) -> None:
+ result = ManagedTransportResult(
+ transport="tor",
+ provider="arti",
+ status="repaired",
+ endpoint="socks5://127.0.0.1:9050",
+ owned=True,
+ pid=1234,
+ state_dir=".ampb/transports/tor",
+ command=("/tmp/arti", "proxy"),
+ message="removed AMPB-owned tor runtime state",
+ provider_source="bundled-sidecar",
+ )
+
+ with patch("ampbrowser.cli.repair_managed_transport", return_value=result) as repair:
+ with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout:
+ code = main(["transport", "repair", "tor"])
+
+ self.assertEqual(0, code)
+ repair.assert_called_once()
+ output = stdout.getvalue()
+ self.assertIn("status=repaired", output)
+ self.assertIn("provider=arti", output)
+ self.assertIn('message="removed AMPB-owned tor runtime state"', output)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_launch.py b/tests/test_launch.py
index 7cbd203..c194975 100644
--- a/tests/test_launch.py
+++ b/tests/test_launch.py
@@ -180,10 +180,12 @@ class PrepareOpenTest(unittest.TestCase):
background = (extension / "background.js").read_text(encoding="utf-8")
setup = (extension / "setup.html").read_text(encoding="utf-8")
self.assertIn("webNavigation", manifest)
+ 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)
def test_execute_open_blocks_when_runtime_is_missing(self) -> None:
config = AppConfig(runtime_path="/missing/ampb/firefox", transport_modes={})
diff --git a/tests/test_public_private_split.py b/tests/test_public_private_split.py
new file mode 100644
index 0000000..331c010
--- /dev/null
+++ b/tests/test_public_private_split.py
@@ -0,0 +1,32 @@
+from pathlib import Path
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+PRIVATE_TARGET_TERMS = (
+ "wowrace",
+ "wowngeon",
+ "smirk-monorepo",
+ "medusa-multi-tenant-platform",
+ "ai-gen-bot",
+)
+
+
+class PublicPrivateSplitTest(unittest.TestCase):
+ def test_private_target_names_do_not_appear_in_public_docs(self) -> None:
+ failures = []
+ for path in sorted(ROOT.rglob("*.md")):
+ rel = path.relative_to(ROOT)
+ if rel.parts[0] in {".git", "docs"} and "private" in rel.parts:
+ continue
+ if "docs" in rel.parts and "generated" in rel.parts:
+ continue
+ text = path.read_text(encoding="utf-8").lower()
+ for term in PRIVATE_TARGET_TERMS:
+ if term in text:
+ failures.append(f"{rel}: contains private target term {term!r}")
+ self.assertEqual([], failures)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_route_helper.py b/tests/test_route_helper.py
index 6d9e7c2..604f43b 100644
--- a/tests/test_route_helper.py
+++ b/tests/test_route_helper.py
@@ -51,6 +51,8 @@ class RouteHelperTest(unittest.TestCase):
command=("/opt/i2pd",),
message="started managed i2pd transport",
provider_source="system-package",
+ setup_hint="Install i2pd with Homebrew: brew install i2pd.",
+ install_command=("brew", "install", "i2pd"),
)
with tempfile.TemporaryDirectory() as tmp:
@@ -64,6 +66,8 @@ class RouteHelperTest(unittest.TestCase):
self.assertTrue(response["ok"])
self.assertTrue(response["ready"])
self.assertEqual("started", response["status"])
+ self.assertEqual("Install i2pd with Homebrew: brew install i2pd.", response["setup_hint"])
+ self.assertEqual(["brew", "install", "i2pd"], response["install_command"])
ensure.assert_called_once()
def test_blocks_unsupported_routes(self) -> None:
diff --git a/tests/test_transport_manager.py b/tests/test_transport_manager.py
index 79dae01..5e85e89 100644
--- a/tests/test_transport_manager.py
+++ b/tests/test_transport_manager.py
@@ -6,7 +6,7 @@ import unittest
from unittest.mock import patch
from ampbrowser.config import AppConfig
-from ampbrowser.transport_manager import ensure_transport_ready, stop_managed_transport, transport_status
+from ampbrowser.transport_manager import ensure_transport_ready, repair_managed_transport, stop_managed_transport, transport_status
from ampbrowser.transports import TransportStatus
@@ -181,6 +181,51 @@ class TransportManagerTest(unittest.TestCase):
self.assertEqual("arti", result.provider)
self.assertFalse(state_path.exists())
+ def test_repair_managed_transport_removes_runtime_state(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ state_dir = root / ".ampb/transports/tor"
+ profile_dir = root / ".ampb/profiles/tor"
+ state_dir.mkdir(parents=True)
+ profile_dir.mkdir(parents=True)
+ state_path = state_dir / "ampb-owned.json"
+ state_path.write_text('{"pid": 1234, "provider": "arti", "endpoint": "socks5://127.0.0.1:9050"}\n', encoding="utf-8")
+ (state_dir / "arti.log").write_text("warn\n", encoding="utf-8")
+ (profile_dir / "user.js").write_text("prefs\n", encoding="utf-8")
+
+ live = [True]
+
+ def fake_alive(pid: int) -> bool:
+ return live[0]
+
+ def fake_kill(pid: int, sig: int) -> None:
+ live[0] = False
+
+ with patch("ampbrowser.transport_manager._pid_alive", side_effect=fake_alive):
+ with patch("ampbrowser.transport_manager.os.kill", side_effect=fake_kill):
+ result = repair_managed_transport("tor", config=AppConfig(transport_modes={}), root=root)
+
+ self.assertEqual("repaired", result.status)
+ self.assertFalse(state_dir.exists())
+ self.assertTrue((profile_dir / "user.js").exists())
+ self.assertIn("runtime state", result.message)
+
+ def test_repair_managed_transport_blocks_when_stop_fails(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ state_dir = root / ".ampb/transports/tor"
+ state_dir.mkdir(parents=True)
+ state_path = state_dir / "ampb-owned.json"
+ state_path.write_text('{"pid": 1234, "provider": "arti", "endpoint": "socks5://127.0.0.1:9050"}\n', encoding="utf-8")
+
+ with patch("ampbrowser.transport_manager._pid_alive", return_value=True):
+ with patch("ampbrowser.transport_manager.os.kill", side_effect=OSError("nope")):
+ result = repair_managed_transport("tor", config=AppConfig(transport_modes={}), root=root)
+
+ self.assertEqual("repair-blocked", result.status)
+ self.assertTrue(state_path.exists())
+ self.assertIn("could not repair", result.message)
+
def test_starts_configured_classic_tor_provider_with_ampb_state(self) -> None:
status = TransportStatus(
transport="tor",
@@ -270,14 +315,16 @@ class TransportManagerTest(unittest.TestCase):
)
with tempfile.TemporaryDirectory() as tmp:
- with patch("ampbrowser.transport_manager.shutil.which", return_value=None):
- with patch("ampbrowser.transport_manager._bundled_i2pd_path", return_value=""):
- result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
+ with patch("ampbrowser.transport_manager.sys.platform", "darwin"):
+ with patch("ampbrowser.transport_manager.shutil.which", return_value=None):
+ with patch("ampbrowser.transport_manager._bundled_i2pd_path", return_value=""):
+ result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
self.assertEqual("missing-provider", result.status)
self.assertFalse(result.ready)
self.assertIn("I2P provider not found", result.message)
- self.assertIn("brew install i2pd", result.message)
+ self.assertIn("brew install i2pd", result.setup_hint)
+ self.assertEqual(("brew", "install", "i2pd"), result.install_command)
def test_finds_homebrew_i2pd_provider_when_not_on_path(self) -> None:
status = TransportStatus(
diff --git a/tools/docs_check.py b/tools/docs_check.py
index 7e07e3d..9f6cf8a 100644
--- a/tools/docs_check.py
+++ b/tools/docs_check.py
@@ -8,6 +8,26 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IGNORED_PARTS = {".git", "__pycache__", "generated", "private"}
LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
+FORBIDDEN_PUBLIC_TERMS = (
+ "wowrace",
+ "wowngeon",
+ "smirk-monorepo",
+ "medusa-multi-tenant-platform",
+ "ai-gen-bot",
+)
+SECRET_PATTERNS = (
+ ("private key block", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
+ ("open ssh private key block", re.compile(r"-----BEGIN OPENSSH PRIVATE KEY-----")),
+ ("aws access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
+ ("github token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")),
+ ("slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
+ (
+ "assigned secret",
+ re.compile(
+ r"(?i)\b(api[_-]?key|secret|password|token)\s*=\s*['\"][^'\"]{8,}['\"]"
+ ),
+ ),
+)
def main() -> int:
@@ -17,6 +37,13 @@ def main() -> int:
rel = path.relative_to(ROOT)
if not _has_status_header(text):
failures.append(f"{rel}: missing status header in first six lines")
+ lower_text = text.lower()
+ for term in FORBIDDEN_PUBLIC_TERMS:
+ if term in lower_text:
+ failures.append(f"{rel}: public docs contain private target term {term!r}")
+ for label, pattern in SECRET_PATTERNS:
+ if pattern.search(text):
+ failures.append(f"{rel}: public docs may contain {label}")
failures.extend(_broken_links(path, text))
if failures:
for failure in failures: