Add local Arti provider bootstrap

This commit is contained in:
jwinterm
2026-07-07 20:02:50 -04:00
parent 7bf833125d
commit d9fcb21d06
7 changed files with 235 additions and 71 deletions
+14 -2
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import shutil
import socket
@@ -34,7 +36,7 @@ class TransportAdapter:
manage_supported: bool = True
def inspect(self) -> TransportStatus:
installed = self.builtin_running or any(shutil.which(command) for command in self.commands)
installed = self.builtin_running or any(_command_available(command) for command in self.commands)
running = self.builtin_running or (
bool(self.connect_host) and bool(self.connect_port) and _can_connect(self.connect_host, self.connect_port)
)
@@ -84,7 +86,7 @@ ADAPTERS: dict[str, TransportAdapter] = {
start_strategy="start managed Arti SOCKS proxy",
stop_policy="stop only AMPB-owned Tor daemon",
note="Tor via existing SOCKS proxy or managed Arti/Tor runtime",
commands=("arti", "tor"),
commands=("ampb-bundled-arti", "arti", "tor"),
connect_host="127.0.0.1",
connect_port=9050,
),
@@ -148,6 +150,16 @@ def adapter_for(name: str) -> TransportAdapter | None:
return ADAPTERS.get(name)
def _command_available(command: str) -> bool:
if command == "ampb-bundled-arti":
path = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build")) / "providers/arti/bin/arti"
return path.exists() and path.is_file()
if "/" in command:
path = Path(command)
return path.exists() and path.is_file()
return bool(shutil.which(command))
def _can_connect(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=0.2):
+2
View File
@@ -153,6 +153,7 @@ def _cmd_open(
status="ready" if transport_result.ready else transport_result.status,
message=transport_result.message,
transport_setup_status=transport_result.status,
transport_setup_provider=transport_result.provider,
transport_setup_message=transport_result.message,
)
open_plan = execute_open(open_plan, root=Path.cwd())
@@ -181,6 +182,7 @@ def _cmd_open(
f"runtime_path={runtime_path} "
f"user_js_path={user_js_path} "
f"transport_setup_status={open_plan.transport_setup_status} "
f"transport_setup_provider={open_plan.transport_setup_provider} "
f"transport_setup_message=\"{_safe(open_plan.transport_setup_message)}\" "
f"launch_command=\"{_safe(launch_command)}\" "
f"setup_steps=\"{_safe(setup_steps)}\" "
+1
View File
@@ -34,6 +34,7 @@ class OpenPlan:
message: str
launch_spec: BrowserLaunchSpec | None = None
transport_setup_status: str = "-"
transport_setup_provider: str = "-"
transport_setup_message: str = "-"
+132 -60
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
import os
import shutil
@@ -13,9 +14,19 @@ from .adapters import TransportStatus, adapter_for
from .config import AppConfig
ARTI_RELATIVE_PATH = Path("providers/arti/bin/arti")
@dataclass(frozen=True)
class TorProvider:
kind: str
binary: str
@dataclass(frozen=True)
class ManagedTransportResult:
transport: str
provider: str
status: str
endpoint: str
owned: bool
@@ -39,12 +50,13 @@ def ensure_transport_ready(
) -> ManagedTransportResult:
adapter = adapter_for(transport)
if adapter is None:
return ManagedTransportResult(transport, "unsupported", "-", False, 0, "-", (), "transport adapter not found")
return ManagedTransportResult(transport, "-", "unsupported", "-", False, 0, "-", (), "transport adapter not found")
status = status or adapter.inspect()
if status.adoptable:
return ManagedTransportResult(
transport,
"-",
"ready",
status.endpoint,
False,
@@ -57,6 +69,7 @@ def ensure_transport_ready(
if transport != "tor":
return ManagedTransportResult(
transport,
"-",
"unsupported",
status.endpoint,
False,
@@ -76,28 +89,130 @@ def _start_tor(
endpoint: str,
wait_seconds: float,
) -> ManagedTransportResult:
binary = _transport_binary(config, "tor")
provider = _tor_provider(config)
state_dir = _managed_state_dir(root, config, "tor")
if not binary:
if not provider:
return ManagedTransportResult(
"tor",
"-",
"missing-provider",
endpoint,
False,
0,
state_dir,
(),
"Tor provider not found; set AMPB_TOR_BIN or transports.tor.binary_path",
"Tor provider not found; run tools/browser-tor-provider-build.sh or set AMPB_ARTI_BIN, AMPB_TOR_BIN, or transports.tor.binary_path",
)
state_path = Path(state_dir)
data_dir = state_path / "data"
log_path = state_path / "tor.log"
data_dir.mkdir(parents=True, exist_ok=True)
state_path.mkdir(parents=True, exist_ok=True)
_chmod_private(state_path)
command = _tor_command(provider, endpoint=endpoint, state_path=state_path)
try:
with (state_path / f"{provider.kind}.log").open("ab") as log_file:
process = subprocess.Popen(command, stdout=log_file, stderr=log_file, start_new_session=True) # noqa: S603
except OSError as exc:
return ManagedTransportResult(
"tor",
provider.kind,
"start-failed",
endpoint,
False,
0,
state_dir,
command,
f"managed {provider.kind} could not start: {exc}",
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
return ManagedTransportResult(
"tor",
provider.kind,
"started",
endpoint,
True,
process.pid,
state_dir,
command,
f"started managed {provider.kind} transport",
)
process.terminate()
return ManagedTransportResult(
"tor",
provider.kind,
"start-timeout",
endpoint,
True,
process.pid,
state_dir,
command,
f"managed {provider.kind} did not become ready at {endpoint}",
)
def _tor_provider(config: AppConfig) -> TorProvider | None:
arti_path = os.environ.get("AMPB_ARTI_BIN") or _bundled_arti_path()
if arti_path:
return TorProvider("arti", arti_path)
tor_path = os.environ.get("AMPB_TOR_BIN")
if tor_path:
return TorProvider("tor", tor_path)
config_path = config.transport_binary("tor")
if config_path:
return TorProvider(_infer_provider_kind(config_path), config_path)
system_tor = shutil.which("tor")
if system_tor:
return TorProvider("tor", system_tor)
return None
def _bundled_arti_path() -> str:
build_root = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build"))
candidate = build_root / ARTI_RELATIVE_PATH
if candidate.exists() and candidate.is_file():
return str(candidate)
return ""
def _infer_provider_kind(binary_path: str) -> str:
name = Path(binary_path).name.lower()
if name.startswith("arti"):
return "arti"
return "tor"
def _tor_command(provider: TorProvider, *, endpoint: str, state_path: Path) -> tuple[str, ...]:
host, port = _endpoint_host_port(endpoint)
command = (
binary,
if provider.kind == "arti":
state_dir = state_path / "arti-state"
cache_dir = state_path / "arti-cache"
state_dir.mkdir(parents=True, exist_ok=True)
cache_dir.mkdir(parents=True, exist_ok=True)
_chmod_private(state_dir)
_chmod_private(cache_dir)
return (
provider.binary,
"proxy",
"-p",
str(port),
"-o",
f"storage.state_dir={json.dumps(str(state_dir))}",
"-o",
f"storage.cache_dir={json.dumps(str(cache_dir))}",
"-l",
"warn",
)
data_dir = state_path / "tor-data"
data_dir.mkdir(parents=True, exist_ok=True)
_chmod_private(data_dir)
return (
provider.binary,
"--SocksPort",
f"{host}:{port}",
"--DataDirectory",
@@ -105,64 +220,21 @@ def _start_tor(
"--ClientOnly",
"1",
"--Log",
f"notice file {log_path}",
f"notice file {state_path / 'tor.log'}",
)
try:
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # noqa: S603
except OSError as exc:
return ManagedTransportResult(
"tor",
"start-failed",
endpoint,
False,
0,
state_dir,
command,
f"managed Tor could not start: {exc}",
)
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
return ManagedTransportResult(
"tor",
"started",
endpoint,
True,
process.pid,
state_dir,
command,
"started managed Tor transport",
)
process.terminate()
return ManagedTransportResult(
"tor",
"start-timeout",
endpoint,
True,
process.pid,
state_dir,
command,
f"managed Tor did not become ready at {endpoint}",
)
def _transport_binary(config: AppConfig, transport: str) -> str:
env_name = f"AMPB_{transport.upper()}_BIN"
env_path = os.environ.get(env_name)
if env_path:
return env_path
config_path = config.transport_binary(transport)
if config_path:
return config_path
if transport == "tor":
return shutil.which("tor") or ""
return ""
def _managed_state_dir(root: Path, config: AppConfig, transport: str) -> str:
return str(root / config.state_dir / "transports" / transport)
def _chmod_private(path: Path) -> None:
try:
path.chmod(0o700)
except OSError:
pass
def _wait_for_endpoint(endpoint: str, *, timeout_seconds: float) -> bool:
deadline = time.monotonic() + timeout_seconds
while time.monotonic() <= deadline:
+18
View File
@@ -76,6 +76,24 @@ A successful source build produces a bundled app at
Configure and build modes may install Mozilla build Python helpers such as `zstandard`
into mach virtualenvs under `/tmp/ampb-browser-build/cache/mozbuild`.
## Tor Provider Probe
Build a local Arti provider for managed Tor startup:
```sh
sh tools/browser-tor-provider-build.sh
```
The provider script keeps Cargo output and the installed Arti binary under the external
workspace:
- `/tmp/ampb-browser-build/providers/arti/bin/arti`
- `/tmp/ampb-browser-build/logs/arti-cargo-install.log`
`ampbrowser open <onion-url> --yes --launch` will prefer this local Arti provider, then
`AMPB_ARTI_BIN`, `AMPB_TOR_BIN`, configured `transports.tor.binary_path`, and finally a
system `tor` binary if one is present.
## Source Plan
Print the canonical source targets and paths:
+48 -9
View File
@@ -26,6 +26,7 @@ class TransportManagerTest(unittest.TestCase):
result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
self.assertEqual("ready", result.status)
self.assertEqual("-", result.provider)
self.assertFalse(result.owned)
self.assertTrue(result.ready)
self.assertEqual(0, result.pid)
@@ -43,13 +44,46 @@ class TransportManagerTest(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
with patch("ampbrowser.transport_manager.shutil.which", return_value=None):
result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""):
result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=Path(tmp), status=status)
self.assertEqual("missing-provider", result.status)
self.assertFalse(result.ready)
self.assertIn("Tor provider not found", result.message)
def test_starts_configured_tor_provider_with_ampb_state(self) -> None:
def test_starts_bundled_arti_provider_with_ampb_state(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 tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
arti = root / "providers/arti/bin/arti"
with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=True):
with patch("ampbrowser.transport_manager.subprocess.Popen") as popen:
with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=str(arti)):
popen.return_value.pid = 1234
result = ensure_transport_ready("tor", config=AppConfig(transport_modes={}), root=root, status=status)
self.assertEqual("started", result.status)
self.assertEqual("arti", result.provider)
self.assertTrue(result.owned)
self.assertEqual(1234, result.pid)
self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir)
self.assertEqual(str(arti), result.command[0])
self.assertIn("proxy", result.command)
self.assertIn("-p", result.command)
self.assertIn('storage.state_dir="' + str(root / ".ampb/transports/tor/arti-state") + '"', result.command)
self.assertIn('storage.cache_dir="' + str(root / ".ampb/transports/tor/arti-cache") + '"', result.command)
popen.assert_called_once()
def test_starts_configured_classic_tor_provider_with_ampb_state(self) -> None:
status = TransportStatus(
transport="tor",
installed=True,
@@ -65,16 +99,18 @@ class TransportManagerTest(unittest.TestCase):
root = Path(tmp)
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("tor", config=config, root=root, status=status)
with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""):
popen.return_value.pid = 1234
result = ensure_transport_ready("tor", config=config, root=root, status=status)
self.assertEqual("started", result.status)
self.assertEqual("tor", result.provider)
self.assertTrue(result.owned)
self.assertEqual(1234, result.pid)
self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir)
self.assertEqual("/opt/ampb/tor", result.command[0])
self.assertIn("--DataDirectory", result.command)
self.assertIn(str(root / ".ampb/transports/tor/data"), result.command)
self.assertIn(str(root / ".ampb/transports/tor/tor-data"), result.command)
popen.assert_called_once()
def test_reports_tor_spawn_failure(self) -> None:
@@ -90,10 +126,12 @@ class TransportManagerTest(unittest.TestCase):
config = AppConfig(transport_modes={}, transport_binaries={"tor": "/opt/ampb/tor"})
with tempfile.TemporaryDirectory() as tmp:
with patch("ampbrowser.transport_manager.subprocess.Popen", side_effect=OSError("nope")):
result = ensure_transport_ready("tor", config=config, root=Path(tmp), status=status)
with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""):
with patch("ampbrowser.transport_manager.subprocess.Popen", side_effect=OSError("nope")):
result = ensure_transport_ready("tor", config=config, root=Path(tmp), status=status)
self.assertEqual("start-failed", result.status)
self.assertEqual("tor", result.provider)
self.assertFalse(result.ready)
self.assertIn("could not start", result.message)
@@ -112,8 +150,9 @@ class TransportManagerTest(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=False):
with patch("ampbrowser.transport_manager.subprocess.Popen") as popen:
popen.return_value.pid = 1234
result = ensure_transport_ready("tor", config=config, root=Path(tmp), status=status)
with patch("ampbrowser.transport_manager._bundled_arti_path", return_value=""):
popen.return_value.pid = 1234
result = ensure_transport_ready("tor", config=config, root=Path(tmp), status=status)
self.assertEqual("start-timeout", result.status)
self.assertFalse(result.ready)
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
root="${AMPB_BROWSER_BUILD_ROOT:-/tmp/ampb-browser-build}"
provider_root="$root/providers/arti"
log="$root/logs/arti-cargo-install.log"
mkdir -p "$root/logs"
mkdir -p "$root/providers"
cargo install arti --locked --root "$provider_root" >"$log" 2>&1
binary="$provider_root/bin/arti"
if [ ! -x "$binary" ]; then
printf 'missing arti binary after install: %s\n' "$binary" >&2
exit 1
fi
version="$("$binary" --version | head -n 1)"
printf 'AMPB_TOR_PROVIDER status=ok kind=arti binary=%s log=%s version="%s"\n' "$binary" "$log" "$version"