From ff287b8b4bb694be69a9c7382b9e1c33f7651a94 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 5 Jul 2026 20:14:46 -0400 Subject: [PATCH] Initial AMPB scaffold --- .gitignore | 4 ++ README.md | 57 +++++++++++++++++++ ampbrowser/__init__.py | 5 ++ ampbrowser/__main__.py | 5 ++ ampbrowser/cli.py | 101 ++++++++++++++++++++++++++++++++++ ampbrowser/docsgen.py | 59 ++++++++++++++++++++ ampbrowser/metadata.py | 60 ++++++++++++++++++++ ampbrowser/plan.py | 44 +++++++++++++++ ampbrowser/routing.py | 39 +++++++++++++ ampbrowser/transports.py | 96 ++++++++++++++++++++++++++++++++ docs/architecture.md | 41 ++++++++++++++ docs/autodocs.md | 39 +++++++++++++ docs/configuration.md | 49 +++++++++++++++++ docs/generated/route-rules.md | 13 +++++ docs/generated/transports.md | 12 ++++ docs/test-fixtures.md | 25 +++++++++ docs/transport-management.md | 34 ++++++++++++ pyproject.toml | 12 ++++ tests/test_docsgen.py | 24 ++++++++ tests/test_plan.py | 65 ++++++++++++++++++++++ tests/test_routing.py | 42 ++++++++++++++ tools/docs_check.py | 70 +++++++++++++++++++++++ 22 files changed, 896 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ampbrowser/__init__.py create mode 100644 ampbrowser/__main__.py create mode 100644 ampbrowser/cli.py create mode 100644 ampbrowser/docsgen.py create mode 100644 ampbrowser/metadata.py create mode 100644 ampbrowser/plan.py create mode 100644 ampbrowser/routing.py create mode 100644 ampbrowser/transports.py create mode 100644 docs/architecture.md create mode 100644 docs/autodocs.md create mode 100644 docs/configuration.md create mode 100644 docs/generated/route-rules.md create mode 100644 docs/generated/transports.md create mode 100644 docs/test-fixtures.md create mode 100644 docs/transport-management.md create mode 100644 pyproject.toml create mode 100644 tests/test_docsgen.py create mode 100644 tests/test_plan.py create mode 100644 tests/test_routing.py create mode 100644 tools/docs_check.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e58a68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.ampb/ +/docs/private/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fb2bee --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Adaptive Multi-Protocol Browser + +> Status: draft | Updated 2026-07-05 | 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 +transports when configured to do so. + +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. + +## Goals + +- Route URLs by protocol and address family. +- 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. +- 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. +- Publishing private transport state, keys, or host inventory in public docs. + +## Local Commands + +```sh +python3 -m ampbrowser route https://wownero.org/ +python3 -m ampbrowser route http://example.b32.i2p/ +python3 -m ampbrowser route gemini://wownero.org/ +python3 -m ampbrowser inspect +python3 -m ampbrowser plan gemini://wownero.org/ +python3 -m ampbrowser docs generate +python3 -m ampbrowser docs generate --check +python3 tools/docs_check.py +python3 -m unittest discover -s tests +``` + +## Documentation + +- [Architecture](docs/architecture.md) +- [Configuration](docs/configuration.md) +- [Transport management](docs/transport-management.md) +- [Test fixtures](docs/test-fixtures.md) +- [Autodocs and drift gates](docs/autodocs.md) + +## Reusable checklist + +- [ ] 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. +- [ ] Spawn/manage only after a dry-run plan. +- [ ] Keep private notes under `docs/private/`. diff --git a/ampbrowser/__init__.py b/ampbrowser/__init__.py new file mode 100644 index 0000000..ac6ac1f --- /dev/null +++ b/ampbrowser/__init__.py @@ -0,0 +1,5 @@ +"""Adaptive Multi-Protocol Browser.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/ampbrowser/__main__.py b/ampbrowser/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/ampbrowser/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py new file mode 100644 index 0000000..fbb2408 --- /dev/null +++ b/ampbrowser/cli.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from .docsgen import generate_docs +from .plan import plan_url +from .routing import route_url +from .transports import inspect_transports + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="ampbrowser") + subcommands = parser.add_subparsers(dest="command", required=True) + + route_parser = subcommands.add_parser("route", help="Route a URL to a transport.") + route_parser.add_argument("url") + + plan_parser = subcommands.add_parser("plan", help="Plan how a URL would be opened.") + plan_parser.add_argument("url") + + subcommands.add_parser("inspect", help="Inspect local transport readiness.") + + docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.") + docs_subcommands = docs_parser.add_subparsers(dest="docs_command", required=True) + docs_generate = docs_subcommands.add_parser("generate", help="Generate docs from code.") + docs_generate.add_argument("--check", action="store_true") + + args = parser.parse_args(argv) + try: + if args.command == "route": + return _cmd_route(args.url) + if args.command == "plan": + return _cmd_plan(args.url) + if args.command == "inspect": + return _cmd_inspect() + if args.command == "docs": + return _cmd_docs(args) + except Exception as exc: # noqa: BLE001 + print(f"AMPBROWSER status=error message={exc}", file=sys.stderr) + return 1 + return 1 + + +def _cmd_route(url: str) -> int: + route = route_url(url) + print( + "AMPBROWSER_ROUTE " + f"url={route.normalized} " + f"transport={route.transport} " + f"profile={route.profile} " + f"reason=\"{route.reason}\"" + ) + return 0 + + +def _cmd_plan(url: str) -> int: + plan = plan_url(url) + endpoint = plan.status.endpoint if plan.status else "-" + running = str(plan.status.running).lower() if plan.status else "false" + installed = str(plan.status.installed).lower() if plan.status else "false" + prompt = plan.prompt.replace('"', "'") if plan.prompt else "-" + print( + "AMPBROWSER_PLAN " + f"url={plan.route.normalized} " + f"transport={plan.route.transport} " + f"profile={plan.route.profile} " + f"installed={installed} " + f"running={running} " + f"endpoint={endpoint} " + f"requires_consent={str(plan.requires_consent).lower()} " + f"action=\"{plan.action}\" " + f"prompt=\"{prompt}\"" + ) + return 0 + + +def _cmd_inspect() -> int: + for status in inspect_transports(): + print( + "AMPBROWSER_INSPECT " + f"transport={status.transport} " + f"installed={str(status.installed).lower()} " + f"running={str(status.running).lower()} " + f"adoptable={str(status.adoptable).lower()} " + f"manage_supported={str(status.manage_supported).lower()} " + f"endpoint={status.endpoint} " + f"note=\"{status.note}\"" + ) + return 0 + + +def _cmd_docs(args) -> int: + if args.docs_command == "generate": + changed = generate_docs(Path.cwd(), check=args.check) + changed_text = ",".join(str(path) for path in changed) if changed else "-" + mode = "check" if args.check else "write" + print(f"AMPBROWSER_DOCS status=ok mode={mode} changed={changed_text}") + return 0 + return 1 diff --git a/ampbrowser/docsgen.py b/ampbrowser/docsgen.py new file mode 100644 index 0000000..e38bdd8 --- /dev/null +++ b/ampbrowser/docsgen.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path + +from .metadata import ROUTE_RULES, TRANSPORT_DEFINITIONS + + +GENERATED_DIR = Path("docs/generated") + + +def generate_docs(root: Path, *, check: bool = False) -> list[Path]: + docs = { + GENERATED_DIR / "route-rules.md": _route_rules_doc(), + GENERATED_DIR / "transports.md": _transports_doc(), + } + changed: list[Path] = [] + for rel_path, content in docs.items(): + path = root / rel_path + if path.exists() and path.read_text(encoding="utf-8") == content: + continue + changed.append(rel_path) + if not check: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + if check and changed: + names = ", ".join(str(path) for path in changed) + raise RuntimeError(f"generated docs are stale: {names}") + return changed + + +def _header(title: str) -> str: + return ( + f"# {title}\n\n" + "> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB\n\n" + "This file is generated from code. Do not edit it by hand.\n\n" + ) + + +def _route_rules_doc() -> str: + rows = [ + "| Match | Transport | Profile | Note |", + "| --- | --- | --- | --- |", + ] + for rule in ROUTE_RULES: + rows.append(f"| `{rule.match}` | `{rule.transport}` | `{rule.profile}` | {rule.note} |") + return _header("Generated Route Rules") + "\n".join(rows) + "\n" + + +def _transports_doc() -> str: + rows = [ + "| Transport | Adopt Check | Managed State | Profile | Note |", + "| --- | --- | --- | --- | --- |", + ] + for transport in TRANSPORT_DEFINITIONS: + rows.append( + f"| `{transport.name}` | {transport.adopt_check} | `{transport.managed_state}` | " + f"`{transport.profile}` | {transport.note} |" + ) + return _header("Generated Transports") + "\n".join(rows) + "\n" diff --git a/ampbrowser/metadata.py b/ampbrowser/metadata.py new file mode 100644 index 0000000..b4bea4d --- /dev/null +++ b/ampbrowser/metadata.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RouteRule: + match: str + transport: str + profile: str + note: str + + +@dataclass(frozen=True) +class TransportDefinition: + name: str + adopt_check: str + managed_state: str + profile: str + note: str + + +ROUTE_RULES = ( + RouteRule("*.onion", "tor", "tor", "route through Tor SOCKS"), + RouteRule("*.i2p", "i2p", "i2p", "route through I2P HTTP proxy"), + RouteRule("gemini://*", "gemini", "gemini", "fetch and render Gemtext"), + RouteRule("rns://*, lxmf://*, nomad://*", "reticulum", "reticulum", "route through Reticulum adapter"), + RouteRule("http://*, https://*", "clearnet", "clearnet", "ordinary web profile"), +) + +TRANSPORT_DEFINITIONS = ( + TransportDefinition( + "tor", + "SOCKS on 127.0.0.1:9050", + ".ampb/transports/tor", + "tor", + "Adopt existing Tor when healthy; otherwise prompt before managed setup.", + ), + TransportDefinition( + "i2p", + "HTTP proxy on 127.0.0.1:4444", + ".ampb/transports/i2p", + "i2p", + "Adopt existing i2pd/I2P router when healthy; otherwise prompt before managed setup.", + ), + TransportDefinition( + "gemini", + "built-in renderer available", + ".ampb/transports/gemini", + "gemini", + "No daemon required for static Gemtext browsing.", + ), + TransportDefinition( + "reticulum", + "RNS tools and configured interfaces", + ".ampb/transports/reticulum", + "reticulum", + "Adapter planned; physical/link-layer setup may require operator config.", + ), +) diff --git a/ampbrowser/plan.py b/ampbrowser/plan.py new file mode 100644 index 0000000..41f2e5a --- /dev/null +++ b/ampbrowser/plan.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .routing import Route, route_url +from .transports import TransportStatus, inspect_transport + + +@dataclass(frozen=True) +class BrowsePlan: + route: Route + status: TransportStatus | None + action: str + requires_consent: bool = False + prompt: str = "" + + +def plan_url(raw_url: str) -> BrowsePlan: + route = route_url(raw_url) + status = inspect_transport(route.transport) + if route.transport in {"clearnet", "unknown"}: + return BrowsePlan(route=route, status=status, action="open with clearnet profile") + if status and status.adoptable: + return BrowsePlan(route=route, status=status, action="adopt existing transport") + if status and status.manage_supported: + action = "prompt to start managed transport" + prompt = ( + f"{route.transport} is not running. AMPB can start a managed {route.transport} " + f"transport for {route.normalized}." + ) + if not status.installed: + action = "prompt to install and start managed transport" + prompt = ( + f"{route.transport} is not installed or running. AMPB can install and start " + f"a managed {route.transport} transport for {route.normalized}." + ) + return BrowsePlan( + route=route, + status=status, + action=action, + requires_consent=True, + prompt=prompt, + ) + return BrowsePlan(route=route, status=status, action="blocked until adapter is configured") diff --git a/ampbrowser/routing.py b/ampbrowser/routing.py new file mode 100644 index 0000000..6f28930 --- /dev/null +++ b/ampbrowser/routing.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import urlparse + + +@dataclass(frozen=True) +class Route: + original: str + normalized: str + transport: str + profile: str + reason: str + + +def route_url(raw_url: str) -> Route: + normalized = _normalize_url(raw_url) + parsed = urlparse(normalized) + scheme = parsed.scheme.lower() + host = (parsed.hostname or "").lower() + + if scheme == "gemini": + return Route(raw_url, normalized, "gemini", "gemini", "gemini scheme") + if scheme in {"rns", "lxmf", "nomad"}: + return Route(raw_url, normalized, "reticulum", "reticulum", f"{scheme} scheme") + if host.endswith(".onion"): + return Route(raw_url, normalized, "tor", "tor", ".onion host") + if host.endswith(".i2p"): + return Route(raw_url, normalized, "i2p", "i2p", ".i2p host") + if scheme in {"http", "https"}: + return Route(raw_url, normalized, "clearnet", "clearnet", "http(s) URL") + return Route(raw_url, normalized, "unknown", "unknown", "no route rule matched") + + +def _normalize_url(raw_url: str) -> str: + value = raw_url.strip() + if "://" not in value and not value.startswith(("rns:", "lxmf:")): + value = "https://" + value + return value diff --git a/ampbrowser/transports.py b/ampbrowser/transports.py new file mode 100644 index 0000000..e992d6e --- /dev/null +++ b/ampbrowser/transports.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +import shutil +import socket + + +@dataclass(frozen=True) +class TransportStatus: + transport: str + installed: bool + running: bool + endpoint: str + adoptable: bool + manage_supported: bool + note: str + + +TRANSPORTS = ("tor", "i2p", "reticulum", "gemini") + + +def inspect_transports() -> list[TransportStatus]: + return [ + _inspect_tor(), + _inspect_i2p(), + _inspect_reticulum(), + _inspect_gemini(), + ] + + +def inspect_transport(name: str) -> TransportStatus | None: + for status in inspect_transports(): + if status.transport == name: + return status + return None + + +def _inspect_tor() -> TransportStatus: + installed = shutil.which("tor") is not None + running = _can_connect("127.0.0.1", 9050) + return TransportStatus( + transport="tor", + installed=installed, + running=running, + endpoint="socks5://127.0.0.1:9050", + adoptable=running, + manage_supported=True, + note="Tor SOCKS proxy", + ) + + +def _inspect_i2p() -> TransportStatus: + installed = shutil.which("i2pd") is not None or shutil.which("i2prouter") is not None + running = _can_connect("127.0.0.1", 4444) + return TransportStatus( + transport="i2p", + installed=installed, + running=running, + endpoint="http://127.0.0.1:4444", + adoptable=running, + manage_supported=True, + note="I2P HTTP proxy", + ) + + +def _inspect_reticulum() -> TransportStatus: + installed = any(shutil.which(command) for command in ("rnsd", "rnstatus", "rnodeconf")) + return TransportStatus( + transport="reticulum", + installed=installed, + running=False, + endpoint="rns://local", + adoptable=False, + manage_supported=False, + note="Reticulum adapter is planned; interface readiness needs explicit config", + ) + + +def _inspect_gemini() -> TransportStatus: + return TransportStatus( + transport="gemini", + installed=True, + running=True, + endpoint="builtin://gemtext-renderer", + adoptable=True, + manage_supported=True, + note="Built-in Gemtext fetch/render path", + ) + + +def _can_connect(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=0.2): + return True + except OSError: + return False diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..353f92d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,41 @@ +# Architecture + +> Status: draft | Updated 2026-07-05 | Applies to: AMPB contributors and integrators + +AMPB is a browser orchestration layer for privacy and alternate-network transports. It +keeps the routing, transport management, and profile isolation decisions outside the +browser engine until those contracts are stable. + +## Operating Model + +1. Normalize the requested URL. +2. Select a transport from scheme and host rules. +3. Inspect whether the required local transport is already running. +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. + +## Main Components + +- `ampbrowser.routing`: deterministic URL-to-transport rules. +- `ampbrowser.transports`: local readiness checks for Tor, I2P, Reticulum, and Gemini. +- `ampbrowser.plan`: dry-run action planner for browser launches. +- `ampbrowser.docsgen`: generated public documentation from code-owned metadata. + +## Browser Strategy + +The first implementation should be a launcher and profile manager, not a browser-engine +fork. Tor Browser, Mullvad Browser, Firefox profiles, Chromium profiles, terminal Gemini +clients, and future Reticulum-native viewers can all sit behind the same route and +transport contracts. + +The long-term browser can become more integrated once the orchestration surface is boring: +profile isolation is reliable, transport adoption is predictable, and generated fixtures +from AMPG can be opened without manual proxy setup. + +## Relationship To AMPG + +AMPG publishes protocol-specific site variants. AMPB opens those variants with the right +transport and profile. Wownero is the shared fixture for static publishing, while +interactive targets exercise sessions, dynamic assets, forms, and script policy. diff --git a/docs/autodocs.md b/docs/autodocs.md new file mode 100644 index 0000000..c5e7df4 --- /dev/null +++ b/docs/autodocs.md @@ -0,0 +1,39 @@ +# Autodocs + +> Status: draft | Updated 2026-07-05 | 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. + +## Generated Docs + +Run: + +```sh +python3 -m ampbrowser docs generate +``` + +Check for drift: + +```sh +python3 -m ampbrowser docs generate --check +``` + +Generated files live under `docs/generated/` and must not be edited by hand. + +## Manual Docs + +Manual public docs must include a status header near the top and should avoid strategy +notes, checkpointing, timelines, private addresses, local hostnames, credentials, and +deployment inventory. + +Private notes belong in `docs/private/`. That directory is ignored and should remain +absent from commits. + +## Link And Header Check + +Run: + +```sh +python3 tools/docs_check.py +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1696813 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,49 @@ +# Configuration + +> Status: draft | Updated 2026-07-05 | Applies to: AMPB users and package maintainers + +AMPB should work without a config file for common local setups. Explicit configuration is +for changing defaults, pinning binary paths, choosing managed state directories, and +restricting which transports are allowed. + +Transport setup is lazy. AMPB does not install, start, or configure a transport until the +user opens a URL that needs it and approves the first-use prompt. + +## Default Behavior + +- Clearnet URLs use a clearnet profile. +- `.onion` hosts use Tor through `127.0.0.1:9050` when available. +- `.i2p` hosts use I2P through `127.0.0.1:4444` when available. +- `gemini://` URLs use the Gemini profile. +- Reticulum-family URLs are routed to the Reticulum adapter once configured. + +## Future Config Shape + +```toml +[browser] +default_engine = "system" +state_dir = ".ampb" + +[transports.tor] +enabled = true +mode = "adopt-or-manage" +socks_endpoint = "127.0.0.1:9050" + +[transports.i2p] +enabled = true +mode = "adopt-or-manage" +http_proxy = "127.0.0.1:4444" + +[transports.reticulum] +enabled = false +mode = "adopt" + +[profiles] +isolate_by_transport = true +``` + +## Public And Private Files + +Public docs describe behavior, configuration, and supported fixtures. Private strategy, +deployment notes, host inventory, generated keys, and local address books belong under +`docs/private/`, which is ignored by git. diff --git a/docs/generated/route-rules.md b/docs/generated/route-rules.md new file mode 100644 index 0000000..92fac5a --- /dev/null +++ b/docs/generated/route-rules.md @@ -0,0 +1,13 @@ +# Generated Route Rules + +> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB + +This file is generated from code. Do not edit it by hand. + +| Match | Transport | Profile | Note | +| --- | --- | --- | --- | +| `*.onion` | `tor` | `tor` | route through Tor SOCKS | +| `*.i2p` | `i2p` | `i2p` | route through I2P HTTP proxy | +| `gemini://*` | `gemini` | `gemini` | fetch and render Gemtext | +| `rns://*, lxmf://*, nomad://*` | `reticulum` | `reticulum` | route through Reticulum adapter | +| `http://*, https://*` | `clearnet` | `clearnet` | ordinary web profile | diff --git a/docs/generated/transports.md b/docs/generated/transports.md new file mode 100644 index 0000000..ec9d805 --- /dev/null +++ b/docs/generated/transports.md @@ -0,0 +1,12 @@ +# Generated Transports + +> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB + +This file is generated from code. Do not edit it by hand. + +| Transport | Adopt Check | Managed State | Profile | Note | +| --- | --- | --- | --- | --- | +| `tor` | SOCKS on 127.0.0.1:9050 | `.ampb/transports/tor` | `tor` | Adopt existing Tor when healthy; otherwise prompt before managed setup. | +| `i2p` | HTTP proxy on 127.0.0.1:4444 | `.ampb/transports/i2p` | `i2p` | Adopt existing i2pd/I2P router when healthy; otherwise prompt before managed setup. | +| `gemini` | built-in renderer available | `.ampb/transports/gemini` | `gemini` | No daemon required for static Gemtext browsing. | +| `reticulum` | RNS tools and configured interfaces | `.ampb/transports/reticulum` | `reticulum` | Adapter planned; physical/link-layer setup may require operator config. | diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md new file mode 100644 index 0000000..eb9bde6 --- /dev/null +++ b/docs/test-fixtures.md @@ -0,0 +1,25 @@ +# Test Fixtures + +> Status: draft | Updated 2026-07-05 | Applies to: AMPB and AMPG compatibility testing + +Fixtures should cover static sites, protocol-specific publishing, and interactive web +apps without exposing private deployment notes. + +## Primary Fixture + +Wownero is the first shared fixture between AMPG and AMPB. It exercises ordinary +clearnet pages, Tor and I2P HTML variants, Gemini output, static assets, and semantic +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. + +## Fixture Requirements + +- Every fixture has a public description of what behavior it exercises. +- Private addresses, tunnel keys, host inventory, and credentials stay out of public docs. +- Browser checks prefer dry-run plans before live launch. +- Static output from AMPG should be openable by AMPB without manual proxy setup. diff --git a/docs/transport-management.md b/docs/transport-management.md new file mode 100644 index 0000000..964d080 --- /dev/null +++ b/docs/transport-management.md @@ -0,0 +1,34 @@ +# Transport Management + +> Status: draft | Updated 2026-07-05 | 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. + +## Lifecycle + +1. Inspect known local endpoints. +2. Adopt running transports that match the selected URL. +3. For missing transports, show a prompt that names what will be installed or started. +4. Resolve a configured binary and state directory only after consent. +5. Start the daemon with AMPB-owned state. +6. Wait for the health endpoint. +7. Launch the browser profile only after the transport is ready. +8. Stop only AMPB-owned daemons. + +## Ownership + +AMPB never stops a daemon it did not start. Adopted transports remain owned by the user, +system service, or package manager that launched them. + +Managed transports store runtime state under `.ampb/transports/` by default. Keys, +leases, tunnels, and daemon logs in that tree are local state and must not be committed. + +## Current Adapters + +- Tor: detect SOCKS on `127.0.0.1:9050`; managed start planned. +- I2P: detect HTTP proxy on `127.0.0.1:4444`; managed start planned. +- Gemini: built-in route and render path; no daemon required for static browsing. +- Reticulum: route contract exists; adapter and readiness model are planned. + +Generated transport metadata lives in [generated transports](generated/transports.md). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c7818dd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "adaptive-multi-protocol-browser" +version = "0.1.0" +description = "Transport-aware browser orchestration shell." +requires-python = ">=3.11" +license = "Apache-2.0" + +[project.scripts] +ampbrowser = "ampbrowser.cli:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_docsgen.py b/tests/test_docsgen.py new file mode 100644 index 0000000..af7e415 --- /dev/null +++ b/tests/test_docsgen.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from ampbrowser.docsgen import generate_docs + + +class DocsGenTest(unittest.TestCase): + def test_generate_docs_writes_and_then_checks_clean(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + changed = generate_docs(root) + + self.assertEqual( + {Path("docs/generated/route-rules.md"), Path("docs/generated/transports.md")}, + set(changed), + ) + self.assertEqual([], generate_docs(root, check=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plan.py b/tests/test_plan.py new file mode 100644 index 0000000..039ab5f --- /dev/null +++ b/tests/test_plan.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from ampbrowser.plan import plan_url +from ampbrowser.transports import TransportStatus + + +class PlanUrlTest(unittest.TestCase): + def test_gemini_uses_builtin_transport(self) -> None: + plan = plan_url("gemini://wownero.org/") + + self.assertEqual("gemini", plan.route.transport) + self.assertEqual("adopt existing transport", plan.action) + self.assertIsNotNone(plan.status) + self.assertTrue(plan.status.adoptable) + + def test_clearnet_opens_with_clearnet_profile(self) -> None: + plan = plan_url("wownero.org") + + self.assertEqual("https://wownero.org", plan.route.normalized) + self.assertEqual("clearnet", plan.route.transport) + self.assertEqual("open with clearnet profile", plan.action) + self.assertFalse(plan.requires_consent) + + def test_missing_transport_prompts_before_install_or_start(self) -> None: + status = TransportStatus( + transport="i2p", + installed=False, + running=False, + endpoint="http://127.0.0.1:4444", + adoptable=False, + manage_supported=True, + note="I2P HTTP proxy", + ) + + with patch("ampbrowser.plan.inspect_transport", return_value=status): + plan = plan_url("http://example.b32.i2p/") + + self.assertEqual("prompt to install and start managed transport", plan.action) + self.assertTrue(plan.requires_consent) + self.assertIn("install and start", plan.prompt) + + def test_stopped_transport_prompts_before_start(self) -> None: + status = TransportStatus( + transport="tor", + installed=True, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + + with patch("ampbrowser.plan.inspect_transport", return_value=status): + plan = plan_url("http://example.onion/") + + self.assertEqual("prompt to start managed transport", plan.action) + self.assertTrue(plan.requires_consent) + self.assertIn("can start", plan.prompt) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..2f102c1 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import unittest + +from ampbrowser.routing import route_url + + +class RouteUrlTest(unittest.TestCase): + def test_defaults_plain_host_to_https_clearnet(self) -> None: + route = route_url("wownero.org") + + self.assertEqual("https://wownero.org", route.normalized) + self.assertEqual("clearnet", route.transport) + self.assertEqual("clearnet", route.profile) + + def test_routes_onion_hosts_to_tor(self) -> None: + route = route_url("http://example.onion/") + + self.assertEqual("tor", route.transport) + self.assertEqual("tor", route.profile) + + def test_routes_i2p_hosts_to_i2p(self) -> None: + route = route_url("http://example.b32.i2p/") + + self.assertEqual("i2p", route.transport) + self.assertEqual("i2p", route.profile) + + def test_routes_gemini_scheme(self) -> None: + route = route_url("gemini://wownero.org/") + + self.assertEqual("gemini", route.transport) + self.assertEqual("gemini", route.profile) + + def test_routes_reticulum_family_scheme(self) -> None: + route = route_url("rns://wownero") + + self.assertEqual("reticulum", route.transport) + self.assertEqual("reticulum", route.profile) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/docs_check.py b/tools/docs_check.py new file mode 100644 index 0000000..7e07e3d --- /dev/null +++ b/tools/docs_check.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +IGNORED_PARTS = {".git", "__pycache__", "generated", "private"} +LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)") + + +def main() -> int: + failures: list[str] = [] + for path in _markdown_files(): + text = path.read_text(encoding="utf-8") + rel = path.relative_to(ROOT) + if not _has_status_header(text): + failures.append(f"{rel}: missing status header in first six lines") + failures.extend(_broken_links(path, text)) + if failures: + for failure in failures: + print(failure, file=sys.stderr) + return 1 + print("AMPBROWSER_DOCS_CHECK status=ok") + return 0 + + +def _markdown_files() -> list[Path]: + paths: list[Path] = [] + for path in ROOT.rglob("*.md"): + rel_parts = set(path.relative_to(ROOT).parts) + if rel_parts & IGNORED_PARTS: + continue + paths.append(path) + return sorted(paths) + + +def _has_status_header(text: str) -> bool: + return any(line.startswith("> Status:") for line in text.splitlines()[:6]) + + +def _broken_links(path: Path, text: str) -> list[str]: + failures: list[str] = [] + for match in LINK_RE.finditer(text): + target = match.group(1) + if _is_external_or_anchor(target): + continue + target_path = (path.parent / target.split("#", 1)[0]).resolve() + try: + target_path.relative_to(ROOT) + except ValueError: + failures.append(f"{path.relative_to(ROOT)}: link escapes repo: {target}") + continue + if not target_path.exists(): + failures.append(f"{path.relative_to(ROOT)}: broken link: {target}") + return failures + + +def _is_external_or_anchor(target: str) -> bool: + return ( + target.startswith("#") + or "://" in target + or target.startswith("mailto:") + or target.startswith("tel:") + ) + + +if __name__ == "__main__": + raise SystemExit(main())