Model browser transport provider sources

This commit is contained in:
jwinterm
2026-07-08 10:13:15 -04:00
parent 42131a5ea0
commit e255093203
11 changed files with 264 additions and 16 deletions
+2
View File
@@ -218,6 +218,7 @@ def _print_open_plan(prefix: str, open_plan, extra_fields: tuple[str, ...] = ())
f"setup_prompt_approval_command=\"{_safe(open_plan.setup_prompt_approval_command)}\" "
f"transport_setup_status={open_plan.transport_setup_status} "
f"transport_setup_provider={open_plan.transport_setup_provider} "
f"transport_setup_provider_source={open_plan.transport_setup_provider_source} "
f"transport_setup_owned={str(open_plan.transport_setup_owned).lower()} "
f"transport_setup_pid={open_plan.transport_setup_pid} "
f"transport_setup_endpoint={open_plan.transport_setup_endpoint} "
@@ -259,6 +260,7 @@ def _cmd_transport(args) -> int:
"AMPBROWSER_TRANSPORT "
f"transport={result.transport} "
f"provider={result.provider} "
f"provider_source={result.provider_source} "
f"status={result.status} "
f"ready={str(result.ready).lower()} "
f"owned={str(result.owned).lower()} "
+27
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from .adapters import ADAPTERS
from .candidates import CANDIDATE_TRANSPORTS
from .metadata import BROWSER_BACKENDS, ROUTE_RULES, TRANSPORT_DEFINITIONS
from .metadata import PROVIDER_SOURCE_DEFINITIONS, TRANSPORT_PROVIDER_DEFINITIONS
from .platforms import PLATFORM_CAPABILITIES
@@ -19,6 +20,7 @@ def generate_docs(root: Path, *, check: bool = False) -> list[Path]:
GENERATED_DIR / "candidate-transports.md": _candidate_transports_doc(),
GENERATED_DIR / "platform-capabilities.md": _platform_capabilities_doc(),
GENERATED_DIR / "browser-strategy.md": _browser_strategy_doc(),
GENERATED_DIR / "provider-sources.md": _provider_sources_doc(),
}
changed: list[Path] = []
for rel_path, content in docs.items():
@@ -118,3 +120,28 @@ def _browser_strategy_doc() -> str:
f"{backend.launch_mode} | {backend.privacy_posture} | {backend.note} |"
)
return _header("Generated Browser Strategy") + "\n".join(rows) + "\n"
def _provider_sources_doc() -> str:
sections = [_header("Generated Provider Sources")]
sections.append("## Source Types\n")
sections.append("| Source | Discovery | Lifecycle | Platforms | Note |")
sections.append("| --- | --- | --- | --- | --- |")
for source in PROVIDER_SOURCE_DEFINITIONS:
sections.append(
f"| `{source.source}` | {source.discovery} | {source.lifecycle} | "
f"{source.platforms} | {source.note} |"
)
sections.append("")
sections.append("## Transport Providers\n")
sections.append("| Transport | Provider | Sources | Endpoint | Status | Note |")
sections.append("| --- | --- | --- | --- | --- | --- |")
for provider in TRANSPORT_PROVIDER_DEFINITIONS:
sources = ", ".join(f"`{source}`" for source in provider.sources)
sections.append(
f"| `{provider.transport}` | `{provider.provider}` | {sources} | "
f"`{provider.endpoint}` | `{provider.status}` | {provider.note} |"
)
sections.append("")
return "\n".join(sections)
+1
View File
@@ -41,6 +41,7 @@ class OpenPlan:
setup_prompt_approval_command: str = "-"
transport_setup_status: str = "-"
transport_setup_provider: str = "-"
transport_setup_provider_source: str = "-"
transport_setup_owned: bool = False
transport_setup_pid: int = 0
transport_setup_endpoint: str = "-"
+107
View File
@@ -31,6 +31,25 @@ class BrowserBackend:
note: str
@dataclass(frozen=True)
class ProviderSourceDefinition:
source: str
discovery: str
lifecycle: str
platforms: str
note: str
@dataclass(frozen=True)
class TransportProviderDefinition:
transport: str
provider: str
sources: tuple[str, ...]
endpoint: str
status: str
note: str
ROUTE_RULES = (
RouteRule("*.onion", "tor", "tor", "route through Tor SOCKS"),
RouteRule("*.i2p", "i2p", "i2p", "route through I2P HTTP proxy"),
@@ -125,3 +144,91 @@ BROWSER_BACKENDS = (
"Used for Gemini and similar lightweight document transports.",
),
)
PROVIDER_SOURCE_DEFINITIONS = (
ProviderSourceDefinition(
"configured",
"explicit config or environment variable",
"operator override; AMPB owns profile policy but not the binary provenance",
"desktop, android, ios",
"Used for development, custom bundles, and reviewed advanced installs.",
),
ProviderSourceDefinition(
"bundled-sidecar",
"provider binary shipped with AMPB or the build workspace",
"AMPB-owned process and isolated state",
"desktop, android",
"Default desktop target for Tor, I2P, IPFS, Reticulum, and similar daemon-style transports.",
),
ProviderSourceDefinition(
"embedded-library",
"transport library linked into the app process",
"AMPB-owned foreground session and app-private state",
"android, ios",
"Preferred mobile shape when platform policy makes sidecar daemons awkward.",
),
ProviderSourceDefinition(
"system-adopted",
"healthy local proxy or service already running",
"operator-owned service; AMPB only routes browser traffic to it",
"desktop, android",
"Keeps power users and existing deployments working without duplicate daemons.",
),
ProviderSourceDefinition(
"system-package",
"known package manager install such as brew, apt, pkg, or platform provider",
"installed only after user consent, then run with AMPB-owned state",
"desktop, android",
"Fallback when the app did not ship that provider yet.",
),
ProviderSourceDefinition(
"builtin-renderer",
"browser-native renderer or fetch path",
"AMPB-owned code path with no external daemon",
"desktop, android, ios",
"Used for Gemini and other lightweight document transports.",
),
)
TRANSPORT_PROVIDER_DEFINITIONS = (
TransportProviderDefinition(
"tor",
"arti/tor",
("configured", "bundled-sidecar", "embedded-library", "system-adopted", "system-package"),
"socks5://127.0.0.1:9050",
"active",
"Desktop uses managed Arti/Tor sidecars today; mobile should prefer embedded or app-owned foreground providers.",
),
TransportProviderDefinition(
"i2p",
"i2pd/i2p-router",
("configured", "bundled-sidecar", "embedded-library", "system-adopted", "system-package"),
"http://127.0.0.1:4444",
"active",
"Desktop can adopt or manage i2pd; mobile provider packaging is planned behind the same contract.",
),
TransportProviderDefinition(
"gemini",
"gemini-native-viewer",
("builtin-renderer",),
"builtin://gemtext-renderer",
"planned",
"No daemon is required once the built-in fetch/render path exists.",
),
TransportProviderDefinition(
"ipfs",
"kubo/ipfs",
("configured", "bundled-sidecar", "system-adopted", "system-package"),
"http://127.0.0.1:8080",
"planned",
"Content-addressed browsing uses the same provider lifecycle without treating IPFS as anonymity.",
),
TransportProviderDefinition(
"reticulum",
"rnsd/rnstatus",
("configured", "bundled-sidecar", "system-adopted", "system-package"),
"rns://local",
"planned",
"Reticulum browsing may still need operator-owned physical or link-layer interface setup.",
),
)
+1
View File
@@ -23,6 +23,7 @@ def launch_open_plan(open_plan: OpenPlan, *, config: AppConfig, root: Path) -> O
message=transport_result.message,
transport_setup_status=transport_result.status,
transport_setup_provider=transport_result.provider,
transport_setup_provider_source=transport_result.provider_source,
transport_setup_owned=transport_result.owned,
transport_setup_pid=transport_result.pid,
transport_setup_endpoint=transport_result.endpoint,
+54 -10
View File
@@ -16,6 +16,7 @@ from .config import AppConfig
ARTI_RELATIVE_PATH = Path("providers/arti/bin/arti")
I2PD_RELATIVE_PATH = Path("providers/i2pd/bin/i2pd")
OWNED_STATE_FILE = "ampb-owned.json"
@@ -23,12 +24,14 @@ OWNED_STATE_FILE = "ampb-owned.json"
class TorProvider:
kind: str
binary: str
source: str
@dataclass(frozen=True)
class I2PProvider:
kind: str
binary: str
source: str
@dataclass(frozen=True)
@@ -42,6 +45,7 @@ class ManagedTransportResult:
state_dir: str
command: tuple[str, ...]
message: str
provider_source: str = "-"
@property
def ready(self) -> bool:
@@ -76,6 +80,7 @@ def ensure_transport_ready(
_managed_state_dir(root, config, transport),
(),
f"adopted existing {transport} transport",
provider_source="system-adopted",
)
if transport == "tor":
@@ -122,6 +127,7 @@ def transport_status(
_managed_state_dir(root, config, transport),
(),
f"{transport} transport is running but not AMPB-owned",
provider_source="system-adopted",
)
return ManagedTransportResult(
@@ -149,6 +155,7 @@ def stop_managed_transport(
endpoint = str(state.get("endpoint") or _endpoint_for(transport))
state_dir = _managed_state_dir(root, config, transport)
provider = str(state.get("provider") or "-")
provider_source = str(state.get("provider_source") or "-")
command = tuple(str(part) for part in state.get("command", ()))
pid = _state_pid(state)
@@ -163,6 +170,7 @@ def stop_managed_transport(
state_dir,
(),
f"no AMPB-owned {transport} process is recorded",
provider_source=provider_source,
)
if not _pid_alive(pid):
@@ -181,6 +189,7 @@ def stop_managed_transport(
if cleaned
else f"could not remove stale AMPB-owned {transport} state"
),
provider_source=provider_source,
)
try:
@@ -196,6 +205,7 @@ def stop_managed_transport(
state_dir,
command,
f"could not stop AMPB-owned {transport}: {exc}",
provider_source=provider_source,
)
deadline = time.monotonic() + wait_seconds
@@ -216,6 +226,7 @@ def stop_managed_transport(
if cleaned
else f"stopped AMPB-owned {transport} but could not remove state"
),
provider_source=provider_source,
)
time.sleep(0.1)
@@ -229,6 +240,7 @@ def stop_managed_transport(
state_dir,
command,
f"AMPB-owned {transport} did not stop before timeout",
provider_source=provider_source,
)
@@ -273,6 +285,7 @@ def _start_tor(
state_dir,
command,
f"managed {provider.kind} could not start: {exc}",
provider_source=provider.source,
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
@@ -286,6 +299,7 @@ def _start_tor(
state_dir,
command,
f"started managed {provider.kind} transport",
provider_source=provider.source,
)
_write_owned_state(root, config, result)
return result
@@ -301,31 +315,49 @@ def _start_tor(
state_dir,
command,
f"managed {provider.kind} did not become ready at {endpoint}",
provider_source=provider.source,
)
def _tor_provider(config: AppConfig) -> TorProvider | None:
arti_path = os.environ.get("AMPB_ARTI_BIN") or _bundled_arti_path()
arti_path = os.environ.get("AMPB_ARTI_BIN")
if arti_path:
return TorProvider("arti", arti_path)
return TorProvider("arti", arti_path, "configured")
tor_path = os.environ.get("AMPB_TOR_BIN")
if tor_path:
return TorProvider("tor", tor_path)
return TorProvider("tor", tor_path, "configured")
config_path = config.transport_binary("tor")
if config_path:
return TorProvider(_infer_provider_kind(config_path), config_path)
return TorProvider(_infer_provider_kind(config_path), config_path, "configured")
bundled_arti = _bundled_arti_path()
if bundled_arti:
return TorProvider("arti", bundled_arti, "bundled-sidecar")
system_tor = shutil.which("tor")
if system_tor:
return TorProvider("tor", system_tor)
return TorProvider("tor", system_tor, "system-package")
return None
def _bundled_arti_path() -> str:
return _bundled_provider_path(ARTI_RELATIVE_PATH)
def _bundled_i2pd_path() -> str:
return _bundled_provider_path(I2PD_RELATIVE_PATH)
def _bundled_provider_path(relative_path: Path) -> str:
provider_root = os.environ.get("AMPB_PROVIDER_ROOT")
if provider_root:
candidate = Path(provider_root) / relative_path
if candidate.exists() and candidate.is_file():
return str(candidate)
build_root = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build"))
candidate = build_root / ARTI_RELATIVE_PATH
candidate = build_root / relative_path
if candidate.exists() and candidate.is_file():
return str(candidate)
return ""
@@ -418,6 +450,7 @@ def _start_i2p(
state_dir,
command,
f"managed {provider.kind} could not start: {exc}",
provider_source=provider.source,
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
@@ -431,6 +464,7 @@ def _start_i2p(
state_dir,
command,
f"started managed {provider.kind} transport",
provider_source=provider.source,
)
_write_owned_state(root, config, result)
return result
@@ -446,25 +480,30 @@ def _start_i2p(
state_dir,
command,
f"managed {provider.kind} did not become ready at {endpoint}",
provider_source=provider.source,
)
def _i2p_provider(config: AppConfig) -> I2PProvider | None:
env_path = os.environ.get("AMPB_I2PD_BIN")
if env_path:
return I2PProvider("i2pd", env_path)
return I2PProvider("i2pd", env_path, "configured")
config_path = config.transport_binary("i2p")
if config_path:
return I2PProvider("i2pd", config_path)
return I2PProvider("i2pd", config_path, "configured")
bundled_i2pd = _bundled_i2pd_path()
if bundled_i2pd:
return I2PProvider("i2pd", bundled_i2pd, "bundled-sidecar")
system_i2pd = shutil.which("i2pd")
if system_i2pd:
return I2PProvider("i2pd", system_i2pd)
return I2PProvider("i2pd", system_i2pd, "system-package")
homebrew_i2pd = _homebrew_i2pd_path()
if homebrew_i2pd:
return I2PProvider("i2pd", homebrew_i2pd)
return I2PProvider("i2pd", homebrew_i2pd, "system-package")
return None
@@ -575,6 +614,7 @@ def _write_owned_state(root: Path, config: AppConfig, result: ManagedTransportRe
"pid": result.pid,
"state_dir": result.state_dir,
"command": list(result.command),
"provider_source": result.provider_source,
"started_at": int(time.time()),
}
state_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
@@ -595,6 +635,7 @@ def _owned_transport_result(
pid = _state_pid(state)
provider = str(state.get("provider") or "-")
provider_source = str(state.get("provider_source") or "-")
endpoint = str(state.get("endpoint") or _endpoint_for(transport))
state_dir = str(state.get("state_dir") or _managed_state_dir(root, config, transport))
command = tuple(str(part) for part in state.get("command", ()))
@@ -615,6 +656,7 @@ def _owned_transport_result(
if cleaned
else f"could not remove stale AMPB-owned {transport} state"
),
provider_source=provider_source,
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
@@ -628,6 +670,7 @@ def _owned_transport_result(
state_dir,
command,
f"using running AMPB-owned {transport}",
provider_source=provider_source,
)
return ManagedTransportResult(
@@ -640,6 +683,7 @@ def _owned_transport_result(
state_dir,
command,
f"AMPB-owned {transport} process is running but endpoint is not ready",
provider_source=provider_source,
)
+3 -3
View File
@@ -21,9 +21,9 @@ python3 -m ampbrowser docs generate --check
Generated files live under `docs/generated/` and must not be edited by hand. They cover
route rules, active transports, browser backend strategy, adapter contracts, candidate
transports, and platform capabilities. Browser source trees, object directories, caches,
and artifacts belong under `/tmp/ampb-browser-build` or another explicit external build
workspace, not this repo.
transports, provider sources, and platform capabilities. Browser source trees, object
directories, caches, and artifacts belong under `/tmp/ampb-browser-build` or another
explicit external build workspace, not this repo.
## Manual Docs
+27
View File
@@ -0,0 +1,27 @@
# Generated Provider Sources
> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB
This file is generated from code. Do not edit it by hand.
## Source Types
| Source | Discovery | Lifecycle | Platforms | Note |
| --- | --- | --- | --- | --- |
| `configured` | explicit config or environment variable | operator override; AMPB owns profile policy but not the binary provenance | desktop, android, ios | Used for development, custom bundles, and reviewed advanced installs. |
| `bundled-sidecar` | provider binary shipped with AMPB or the build workspace | AMPB-owned process and isolated state | desktop, android | Default desktop target for Tor, I2P, IPFS, Reticulum, and similar daemon-style transports. |
| `embedded-library` | transport library linked into the app process | AMPB-owned foreground session and app-private state | android, ios | Preferred mobile shape when platform policy makes sidecar daemons awkward. |
| `system-adopted` | healthy local proxy or service already running | operator-owned service; AMPB only routes browser traffic to it | desktop, android | Keeps power users and existing deployments working without duplicate daemons. |
| `system-package` | known package manager install such as brew, apt, pkg, or platform provider | installed only after user consent, then run with AMPB-owned state | desktop, android | Fallback when the app did not ship that provider yet. |
| `builtin-renderer` | browser-native renderer or fetch path | AMPB-owned code path with no external daemon | desktop, android, ios | Used for Gemini and other lightweight document transports. |
## Transport Providers
| Transport | Provider | Sources | Endpoint | Status | Note |
| --- | --- | --- | --- | --- | --- |
| `tor` | `arti/tor` | `configured`, `bundled-sidecar`, `embedded-library`, `system-adopted`, `system-package` | `socks5://127.0.0.1:9050` | `active` | Desktop uses managed Arti/Tor sidecars today; mobile should prefer embedded or app-owned foreground providers. |
| `i2p` | `i2pd/i2p-router` | `configured`, `bundled-sidecar`, `embedded-library`, `system-adopted`, `system-package` | `http://127.0.0.1:4444` | `active` | Desktop can adopt or manage i2pd; mobile provider packaging is planned behind the same contract. |
| `gemini` | `gemini-native-viewer` | `builtin-renderer` | `builtin://gemtext-renderer` | `planned` | No daemon is required once the built-in fetch/render path exists. |
| `ipfs` | `kubo/ipfs` | `configured`, `bundled-sidecar`, `system-adopted`, `system-package` | `http://127.0.0.1:8080` | `planned` | Content-addressed browsing uses the same provider lifecycle without treating IPFS as anonymity. |
| `reticulum` | `rnsd/rnstatus` | `configured`, `bundled-sidecar`, `system-adopted`, `system-package` | `rns://local` | `planned` | Reticulum browsing may still need operator-owned physical or link-layer interface setup. |
+2
View File
@@ -60,6 +60,7 @@ class CliOpenTest(unittest.TestCase):
state_dir=".ampb/transports/tor",
command=("/tmp/arti", "proxy"),
message="started managed arti transport",
provider_source="bundled-sidecar",
)
def fake_execute(open_plan, *, root: Path):
@@ -85,6 +86,7 @@ class CliOpenTest(unittest.TestCase):
self.assertIn("browser_pid=5678", output)
self.assertIn("transport_setup_status=started", output)
self.assertIn("transport_setup_provider=arti", output)
self.assertIn("transport_setup_provider_source=bundled-sidecar", output)
self.assertIn("transport_setup_owned=true", output)
self.assertIn("transport_setup_pid=1234", output)
self.assertIn("transport_setup_endpoint=socks5://127.0.0.1:9050", output)
+1
View File
@@ -19,6 +19,7 @@ class DocsGenTest(unittest.TestCase):
Path("docs/generated/browser-strategy.md"),
Path("docs/generated/candidate-transports.md"),
Path("docs/generated/platform-capabilities.md"),
Path("docs/generated/provider-sources.md"),
Path("docs/generated/route-rules.md"),
Path("docs/generated/transports.md"),
},
+36
View File
@@ -27,6 +27,7 @@ class TransportManagerTest(unittest.TestCase):
self.assertEqual("ready", result.status)
self.assertEqual("-", result.provider)
self.assertEqual("system-adopted", result.provider_source)
self.assertFalse(result.owned)
self.assertTrue(result.ready)
self.assertEqual(0, result.pid)
@@ -73,6 +74,7 @@ class TransportManagerTest(unittest.TestCase):
self.assertEqual("started", result.status)
self.assertEqual("arti", result.provider)
self.assertEqual("bundled-sidecar", result.provider_source)
self.assertTrue(result.owned)
self.assertEqual(1234, result.pid)
self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir)
@@ -84,6 +86,7 @@ class TransportManagerTest(unittest.TestCase):
state_path = root / ".ampb/transports/tor/ampb-owned.json"
self.assertTrue(state_path.exists())
self.assertIn('"provider": "arti"', state_path.read_text(encoding="utf-8"))
self.assertIn('"provider_source": "bundled-sidecar"', state_path.read_text(encoding="utf-8"))
popen.assert_called_once()
def test_reuses_recorded_ampb_owned_transport(self) -> None:
@@ -122,6 +125,7 @@ class TransportManagerTest(unittest.TestCase):
self.assertEqual("ready", result.status)
self.assertEqual("arti", result.provider)
self.assertEqual("-", result.provider_source)
self.assertTrue(result.owned)
self.assertEqual(1234, result.pid)
popen.assert_not_called()
@@ -199,6 +203,7 @@ class TransportManagerTest(unittest.TestCase):
self.assertEqual("started", result.status)
self.assertEqual("tor", result.provider)
self.assertEqual("configured", result.provider_source)
self.assertTrue(result.owned)
self.assertEqual(1234, result.pid)
self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir)
@@ -226,6 +231,7 @@ class TransportManagerTest(unittest.TestCase):
self.assertEqual("start-failed", result.status)
self.assertEqual("tor", result.provider)
self.assertEqual("configured", result.provider_source)
self.assertFalse(result.ready)
self.assertIn("could not start", result.message)
@@ -265,6 +271,7 @@ 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)
self.assertEqual("missing-provider", result.status)
@@ -298,6 +305,7 @@ class TransportManagerTest(unittest.TestCase):
return "/opt/homebrew/bin/brew" if name == "brew" else None
with patch("ampbrowser.transport_manager.shutil.which", side_effect=which):
with patch("ampbrowser.transport_manager._bundled_i2pd_path", return_value=""):
with patch("ampbrowser.transport_manager.subprocess.run", return_value=Result()):
with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=True):
with patch("ampbrowser.transport_manager.subprocess.Popen") as popen:
@@ -305,8 +313,34 @@ class TransportManagerTest(unittest.TestCase):
result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=root, status=status)
self.assertEqual("started", result.status)
self.assertEqual("system-package", result.provider_source)
self.assertEqual(str(i2pd), result.command[0])
def test_starts_bundled_i2pd_provider_before_system_provider(self) -> None:
status = TransportStatus(
transport="i2p",
installed=True,
running=False,
endpoint="http://127.0.0.1:4444",
adoptable=False,
manage_supported=True,
note="I2P HTTP proxy",
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "providers/i2pd/bin/i2pd"
with patch("ampbrowser.transport_manager._bundled_i2pd_path", return_value=str(bundled)):
with patch("ampbrowser.transport_manager.shutil.which", return_value="/usr/bin/i2pd"):
with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=True):
with patch("ampbrowser.transport_manager.subprocess.Popen") as popen:
popen.return_value.pid = 1234
result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=root, status=status)
self.assertEqual("started", result.status)
self.assertEqual("bundled-sidecar", result.provider_source)
self.assertEqual(str(bundled), result.command[0])
def test_starts_configured_i2pd_provider_with_ampb_state(self) -> None:
status = TransportStatus(
transport="i2p",
@@ -328,6 +362,7 @@ class TransportManagerTest(unittest.TestCase):
self.assertEqual("started", result.status)
self.assertEqual("i2pd", result.provider)
self.assertEqual("configured", result.provider_source)
self.assertTrue(result.owned)
self.assertEqual(1234, result.pid)
self.assertEqual(str(root / ".ampb/transports/i2p"), result.state_dir)
@@ -346,6 +381,7 @@ class TransportManagerTest(unittest.TestCase):
state_path = root / ".ampb/transports/i2p/ampb-owned.json"
self.assertTrue(state_path.exists())
self.assertIn('"provider": "i2pd"', state_path.read_text(encoding="utf-8"))
self.assertIn('"provider_source": "configured"', state_path.read_text(encoding="utf-8"))
popen.assert_called_once()
def test_terminates_owned_i2pd_process_on_timeout(self) -> None: