Add dry-run open consent flow

This commit is contained in:
jwinterm
2026-07-05 20:23:12 -04:00
parent ff287b8b4b
commit 237d1e4fcb
7 changed files with 233 additions and 0 deletions
+3
View File
@@ -33,6 +33,8 @@ 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 open https://wownero.org/
python3 -m ampbrowser open http://example.b32.i2p/ --yes
python3 -m ampbrowser docs generate
python3 -m ampbrowser docs generate --check
python3 tools/docs_check.py
@@ -43,6 +45,7 @@ python3 -m unittest discover -s tests
- [Architecture](docs/architecture.md)
- [Configuration](docs/configuration.md)
- [Open flow](docs/open-flow.md)
- [Transport management](docs/transport-management.md)
- [Test fixtures](docs/test-fixtures.md)
- [Autodocs and drift gates](docs/autodocs.md)
+43
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import sys
from .docsgen import generate_docs
from .launch import prepare_open
from .plan import plan_url
from .routing import route_url
from .transports import inspect_transports
@@ -20,6 +21,20 @@ def main(argv: list[str] | None = None) -> int:
plan_parser = subcommands.add_parser("plan", help="Plan how a URL would be opened.")
plan_parser.add_argument("url")
open_parser = subcommands.add_parser("open", help="Prepare a transport-aware open plan.")
open_parser.add_argument("url")
open_parser.add_argument(
"--yes",
action="store_true",
help="Approve first-use setup in the dry-run plan.",
)
open_parser.add_argument(
"--dry-run",
action="store_true",
default=True,
help="Print the open plan without side effects. This is the current default.",
)
subcommands.add_parser("inspect", help="Inspect local transport readiness.")
docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.")
@@ -33,6 +48,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_route(args.url)
if args.command == "plan":
return _cmd_plan(args.url)
if args.command == "open":
return _cmd_open(args.url, consent=args.yes, dry_run=args.dry_run)
if args.command == "inspect":
return _cmd_inspect()
if args.command == "docs":
@@ -76,6 +93,28 @@ def _cmd_plan(url: str) -> int:
return 0
def _cmd_open(url: str, *, consent: bool, dry_run: bool) -> int:
open_plan = prepare_open(url, consent=consent, dry_run=dry_run)
browse_plan = open_plan.browse_plan
setup_steps = "|".join(open_plan.setup_steps) if open_plan.setup_steps else "-"
message = _safe(open_plan.message)
print(
"AMPBROWSER_OPEN "
f"url={browse_plan.route.normalized} "
f"transport={browse_plan.route.transport} "
f"profile={browse_plan.route.profile} "
f"status={open_plan.status} "
f"dry_run={str(open_plan.dry_run).lower()} "
f"requires_consent={str(browse_plan.requires_consent).lower()} "
f"consent_granted={str(open_plan.consent_granted).lower()} "
f"profile_path={open_plan.profile_path} "
f"proxy={open_plan.proxy} "
f"setup_steps=\"{_safe(setup_steps)}\" "
f"message=\"{message}\""
)
return 0
def _cmd_inspect() -> int:
for status in inspect_transports():
print(
@@ -99,3 +138,7 @@ def _cmd_docs(args) -> int:
print(f"AMPBROWSER_DOCS status=ok mode={mode} changed={changed_text}")
return 0
return 1
def _safe(value: str) -> str:
return value.replace('"', "'")
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
from .plan import BrowsePlan, plan_url
PROFILE_ROOT = ".ampb/profiles"
@dataclass(frozen=True)
class OpenPlan:
browse_plan: BrowsePlan
status: str
dry_run: bool
consent_granted: bool
profile_path: str
proxy: str
setup_steps: tuple[str, ...]
message: str
def prepare_open(raw_url: str, *, consent: bool = False, dry_run: bool = True) -> OpenPlan:
browse_plan = plan_url(raw_url)
profile_path = f"{PROFILE_ROOT}/{browse_plan.route.profile}"
proxy = _proxy_for(browse_plan)
if browse_plan.action.startswith("blocked"):
return OpenPlan(
browse_plan=browse_plan,
status="blocked",
dry_run=dry_run,
consent_granted=consent,
profile_path=profile_path,
proxy=proxy,
setup_steps=(),
message=browse_plan.action,
)
if browse_plan.requires_consent:
setup_steps = _setup_steps(browse_plan)
if not consent:
return OpenPlan(
browse_plan=browse_plan,
status="consent-required",
dry_run=dry_run,
consent_granted=False,
profile_path=profile_path,
proxy=proxy,
setup_steps=setup_steps,
message=browse_plan.prompt,
)
return OpenPlan(
browse_plan=browse_plan,
status="setup-approved",
dry_run=dry_run,
consent_granted=True,
profile_path=profile_path,
proxy=proxy,
setup_steps=setup_steps,
message="first-use setup approved; execution is not implemented yet",
)
return OpenPlan(
browse_plan=browse_plan,
status="ready",
dry_run=dry_run,
consent_granted=consent,
profile_path=profile_path,
proxy=proxy,
setup_steps=(),
message="ready to open with isolated profile",
)
def _proxy_for(browse_plan: BrowsePlan) -> str:
if browse_plan.route.transport in {"tor", "i2p"} and browse_plan.status:
return browse_plan.status.endpoint
return "-"
def _setup_steps(browse_plan: BrowsePlan) -> tuple[str, ...]:
if not browse_plan.status:
return ()
steps: list[str] = []
if not browse_plan.status.installed:
steps.append(f"install {browse_plan.route.transport}")
steps.extend(
[
f"start managed {browse_plan.route.transport}",
f"wait for {browse_plan.status.endpoint}",
]
)
return tuple(steps)
+1
View File
@@ -21,6 +21,7 @@ browser engine until those contracts are stable.
- `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.launch`: side-effect-free launch specs and first-use consent state.
- `ampbrowser.docsgen`: generated public documentation from code-owned metadata.
## Browser Strategy
+29
View File
@@ -0,0 +1,29 @@
# Open Flow
> Status: draft | Updated 2026-07-05 | Applies to: AMPB users and implementers
`ampbrowser open` prepares a safe launch plan for one URL. It routes the URL, checks only
the selected transport, and reports whether the browser can open immediately or needs a
first-use setup prompt.
## Consent Model
AMPB does not install or start Tor, I2P, Reticulum, or other optional transports when the
application starts. Setup is lazy:
1. The user opens a URL.
2. AMPB selects the required transport.
3. If the transport is already running, AMPB adopts it.
4. If the transport is missing, AMPB shows a prompt naming what will be installed or started.
5. AMPB proceeds only after approval.
Current `open` output is a dry-run launch spec. It is intentionally side-effect free while
the transport adapters and browser launcher are being stabilized.
## Examples
```sh
python3 -m ampbrowser open https://wownero.org/
python3 -m ampbrowser open http://example.b32.i2p/
python3 -m ampbrowser open http://example.b32.i2p/ --yes
```
+3
View File
@@ -5,6 +5,9 @@
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.
`ampbrowser open` currently emits a dry-run launch spec. The spec includes the selected
profile, proxy endpoint, required setup steps, and whether user consent is still needed.
## Lifecycle
1. Inspect known local endpoints.
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from ampbrowser.launch import prepare_open
from ampbrowser.transports import TransportStatus
class PrepareOpenTest(unittest.TestCase):
def test_clearnet_is_ready_without_consent(self) -> None:
plan = prepare_open("wownero.org")
self.assertEqual("ready", plan.status)
self.assertFalse(plan.browse_plan.requires_consent)
self.assertEqual(".ampb/profiles/clearnet", plan.profile_path)
self.assertEqual("-", plan.proxy)
def test_missing_i2p_requires_consent_before_setup(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 = prepare_open("http://example.b32.i2p/")
self.assertEqual("consent-required", plan.status)
self.assertFalse(plan.consent_granted)
self.assertEqual(("install i2p", "start managed i2p", "wait for http://127.0.0.1:4444"), plan.setup_steps)
self.assertIn("install and start", plan.message)
def test_yes_approves_setup_without_executing_it(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 = prepare_open("http://example.onion/", consent=True)
self.assertEqual("setup-approved", plan.status)
self.assertTrue(plan.dry_run)
self.assertTrue(plan.consent_granted)
self.assertEqual(("start managed tor", "wait for socks5://127.0.0.1:9050"), plan.setup_steps)
if __name__ == "__main__":
unittest.main()