mirror of
https://github.com/Such-Software/adaptive-multi-protocol-browser.git
synced 2026-10-17 14:31:46 +02:00
Initial AMPB scaffold
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/.ampb/
|
||||
/docs/private/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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/`.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Adaptive Multi-Protocol Browser."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.",
|
||||
),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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. |
|
||||
@@ -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.
|
||||
@@ -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/<name>` 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).
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user