From 237d1e4fcb42fe9679a256bf585128cb1e4f943b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 5 Jul 2026 20:23:12 -0400 Subject: [PATCH] Add dry-run open consent flow --- README.md | 3 ++ ampbrowser/cli.py | 43 ++++++++++++++++ ampbrowser/launch.py | 95 ++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + docs/open-flow.md | 29 +++++++++++ docs/transport-management.md | 3 ++ tests/test_launch.py | 59 ++++++++++++++++++++++ 7 files changed, 233 insertions(+) create mode 100644 ampbrowser/launch.py create mode 100644 docs/open-flow.md create mode 100644 tests/test_launch.py diff --git a/README.md b/README.md index 7fb2bee..ad20214 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index fbb2408..aef5fe3 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -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('"', "'") diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py new file mode 100644 index 0000000..1222a89 --- /dev/null +++ b/ampbrowser/launch.py @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index 353f92d..3b00640 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/open-flow.md b/docs/open-flow.md new file mode 100644 index 0000000..8295172 --- /dev/null +++ b/docs/open-flow.md @@ -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 +``` diff --git a/docs/transport-management.md b/docs/transport-management.md index 964d080..e0635b1 100644 --- a/docs/transport-management.md +++ b/docs/transport-management.md @@ -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. diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 0000000..4692d4e --- /dev/null +++ b/tests/test_launch.py @@ -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()