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: