Harden browser docs and transport recovery

This commit is contained in:
jwinterm
2026-07-11 08:59:21 -04:00
parent 97a43f8572
commit 4379b99245
17 changed files with 354 additions and 39 deletions
+9 -1
View File
@@ -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
+22 -2
View File
@@ -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:
' <p id="body">AMPB needs to start a local transport before opening this route.</p>',
' <pre id="url"></pre>',
' <p id="message" class="message"></p>',
' <pre id="install-command" class="install-command" hidden></pre>',
' <button id="approve" type="button">Start and open</button>',
' <p id="status" class="status"></p>',
" </main>",
@@ -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; }",
"",
+2
View File
@@ -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),
}
+102 -2
View File
@@ -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: