mirror of
https://github.com/Such-Software/adaptive-multi-protocol-browser.git
synced 2026-10-17 14:31:46 +02:00
Add dry-run open consent flow
This commit is contained in:
@@ -33,6 +33,8 @@ python3 -m ampbrowser route http://example.b32.i2p/
|
|||||||
python3 -m ampbrowser route gemini://wownero.org/
|
python3 -m ampbrowser route gemini://wownero.org/
|
||||||
python3 -m ampbrowser inspect
|
python3 -m ampbrowser inspect
|
||||||
python3 -m ampbrowser plan gemini://wownero.org/
|
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
|
||||||
python3 -m ampbrowser docs generate --check
|
python3 -m ampbrowser docs generate --check
|
||||||
python3 tools/docs_check.py
|
python3 tools/docs_check.py
|
||||||
@@ -43,6 +45,7 @@ python3 -m unittest discover -s tests
|
|||||||
|
|
||||||
- [Architecture](docs/architecture.md)
|
- [Architecture](docs/architecture.md)
|
||||||
- [Configuration](docs/configuration.md)
|
- [Configuration](docs/configuration.md)
|
||||||
|
- [Open flow](docs/open-flow.md)
|
||||||
- [Transport management](docs/transport-management.md)
|
- [Transport management](docs/transport-management.md)
|
||||||
- [Test fixtures](docs/test-fixtures.md)
|
- [Test fixtures](docs/test-fixtures.md)
|
||||||
- [Autodocs and drift gates](docs/autodocs.md)
|
- [Autodocs and drift gates](docs/autodocs.md)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .docsgen import generate_docs
|
from .docsgen import generate_docs
|
||||||
|
from .launch import prepare_open
|
||||||
from .plan import plan_url
|
from .plan import plan_url
|
||||||
from .routing import route_url
|
from .routing import route_url
|
||||||
from .transports import inspect_transports
|
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 = subcommands.add_parser("plan", help="Plan how a URL would be opened.")
|
||||||
plan_parser.add_argument("url")
|
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.")
|
subcommands.add_parser("inspect", help="Inspect local transport readiness.")
|
||||||
|
|
||||||
docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.")
|
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)
|
return _cmd_route(args.url)
|
||||||
if args.command == "plan":
|
if args.command == "plan":
|
||||||
return _cmd_plan(args.url)
|
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":
|
if args.command == "inspect":
|
||||||
return _cmd_inspect()
|
return _cmd_inspect()
|
||||||
if args.command == "docs":
|
if args.command == "docs":
|
||||||
@@ -76,6 +93,28 @@ def _cmd_plan(url: str) -> int:
|
|||||||
return 0
|
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:
|
def _cmd_inspect() -> int:
|
||||||
for status in inspect_transports():
|
for status in inspect_transports():
|
||||||
print(
|
print(
|
||||||
@@ -99,3 +138,7 @@ def _cmd_docs(args) -> int:
|
|||||||
print(f"AMPBROWSER_DOCS status=ok mode={mode} changed={changed_text}")
|
print(f"AMPBROWSER_DOCS status=ok mode={mode} changed={changed_text}")
|
||||||
return 0
|
return 0
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(value: str) -> str:
|
||||||
|
return value.replace('"', "'")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -21,6 +21,7 @@ browser engine until those contracts are stable.
|
|||||||
- `ampbrowser.routing`: deterministic URL-to-transport rules.
|
- `ampbrowser.routing`: deterministic URL-to-transport rules.
|
||||||
- `ampbrowser.transports`: local readiness checks for Tor, I2P, Reticulum, and Gemini.
|
- `ampbrowser.transports`: local readiness checks for Tor, I2P, Reticulum, and Gemini.
|
||||||
- `ampbrowser.plan`: dry-run action planner for browser launches.
|
- `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.
|
- `ampbrowser.docsgen`: generated public documentation from code-owned metadata.
|
||||||
|
|
||||||
## Browser Strategy
|
## Browser Strategy
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
Transport management follows one rule: use what is already healthy, and manage missing
|
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.
|
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
|
## Lifecycle
|
||||||
|
|
||||||
1. Inspect known local endpoints.
|
1. Inspect known local endpoints.
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user