evo-cli 0.33.0__tar.gz → 0.34.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. {evo_cli-0.33.0 → evo_cli-0.34.0}/PKG-INFO +1 -1
  2. evo_cli-0.34.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/openvpn.py +6 -0
  4. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/openvpn.py +42 -0
  5. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli.egg-info/PKG-INFO +1 -1
  6. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_openvpn.py +62 -5
  7. evo_cli-0.33.0/evo_cli/VERSION +0 -1
  8. {evo_cli-0.33.0 → evo_cli-0.34.0}/Containerfile +0 -0
  9. {evo_cli-0.33.0 → evo_cli-0.34.0}/HISTORY.md +0 -0
  10. {evo_cli-0.33.0 → evo_cli-0.34.0}/LICENSE +0 -0
  11. {evo_cli-0.33.0 → evo_cli-0.34.0}/MANIFEST.in +0 -0
  12. {evo_cli-0.33.0 → evo_cli-0.34.0}/README.md +0 -0
  13. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/__init__.py +0 -0
  14. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/__main__.py +0 -0
  15. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/base.py +0 -0
  16. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/cli.py +0 -0
  17. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/__init__.py +0 -0
  18. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/agent_toy.py +0 -0
  19. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/claude_code.py +0 -0
  20. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/cred.py +0 -0
  22. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/download.py +0 -0
  23. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/gh.py +0 -0
  26. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/__init__.py +0 -0
  27. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_dag.py +0 -0
  28. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_git.py +0 -0
  29. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_hub.py +0 -0
  30. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_mirror.py +0 -0
  31. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_model.py +0 -0
  32. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_mutate.py +0 -0
  33. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_paths.py +0 -0
  34. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_render.py +0 -0
  35. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/_server.py +0 -0
  36. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/check.py +0 -0
  37. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/clone.py +0 -0
  38. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/edit.py +0 -0
  39. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/pull.py +0 -0
  40. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/serve.py +0 -0
  41. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/show.py +0 -0
  42. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/views.py +0 -0
  43. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  44. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  45. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  46. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  47. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  48. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  49. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  50. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  51. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  52. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  53. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  54. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  55. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  56. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  57. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/harness/web/index.html +0 -0
  58. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/hwid.py +0 -0
  59. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/hwid_reset.py +0 -0
  60. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/image.py +0 -0
  61. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/localproxy.py +0 -0
  62. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/mcp.py +0 -0
  63. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/miniconda.py +0 -0
  64. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/netcheck.py +0 -0
  65. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/ngrok.py +0 -0
  66. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/opencode.py +0 -0
  67. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/plantuml.py +0 -0
  68. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/__init__.py +0 -0
  69. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/_dns.py +0 -0
  70. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/_dpi.py +0 -0
  71. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/_hosts.py +0 -0
  72. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/_page.py +0 -0
  73. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/_probe.py +0 -0
  74. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/_spoofdpi.py +0 -0
  75. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/check.py +0 -0
  76. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/fix.py +0 -0
  77. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/probe.py +0 -0
  78. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/route/status.py +0 -0
  79. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/serp.py +0 -0
  80. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/site2s.py +0 -0
  81. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/ssh.py +0 -0
  82. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/storage.py +0 -0
  83. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/sysmon.py +0 -0
  84. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/tts.py +0 -0
  85. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/update.py +0 -0
  86. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/commands/wifi.py +0 -0
  87. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/console.py +0 -0
  88. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/__init__.py +0 -0
  89. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/doctor.py +0 -0
  90. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/google_oauth.py +0 -0
  91. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/migrate.py +0 -0
  92. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/oauth_flow.py +0 -0
  93. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/registry.py +0 -0
  94. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/store.py +0 -0
  95. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/credentials/sync.py +0 -0
  96. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/imaging/__init__.py +0 -0
  97. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/imaging/align.py +0 -0
  98. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/imaging/core.py +0 -0
  99. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/imaging/creds.py +0 -0
  100. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/imaging/errors.py +0 -0
  101. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/imaging/gemini.py +0 -0
  102. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/imaging/ncnn.py +0 -0
  103. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/mcp_registry.py +0 -0
  104. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/serp/__init__.py +0 -0
  105. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/serp/client.py +0 -0
  106. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/serp/creds.py +0 -0
  107. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/serp/errors.py +0 -0
  108. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/serp/install.py +0 -0
  109. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/serp/render.py +0 -0
  110. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/__init__.py +0 -0
  111. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/chunking.py +0 -0
  112. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/core.py +0 -0
  113. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/creds.py +0 -0
  114. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/errors.py +0 -0
  115. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/gemini.py +0 -0
  116. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/http.py +0 -0
  117. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/openai.py +0 -0
  118. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/player.py +0 -0
  119. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli/tts/vbee.py +0 -0
  120. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli.egg-info/SOURCES.txt +0 -0
  121. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  122. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli.egg-info/entry_points.txt +0 -0
  123. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli.egg-info/requires.txt +0 -0
  124. {evo_cli-0.33.0 → evo_cli-0.34.0}/evo_cli.egg-info/top_level.txt +0 -0
  125. {evo_cli-0.33.0 → evo_cli-0.34.0}/pyproject.toml +0 -0
  126. {evo_cli-0.33.0 → evo_cli-0.34.0}/setup.cfg +0 -0
  127. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/__init__.py +0 -0
  128. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/fake_evo_agents.py +0 -0
  129. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  130. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  131. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_agent_toy.py +0 -0
  132. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_claude_code.py +0 -0
  133. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_cli.py +0 -0
  134. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_console.py +0 -0
  135. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_cred.py +0 -0
  136. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_download.py +0 -0
  137. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_fix_claude.py +0 -0
  138. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_gdrive.py +0 -0
  139. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_gh.py +0 -0
  140. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_harness.py +0 -0
  141. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_harness_clone.py +0 -0
  142. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_harness_dag.py +0 -0
  143. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_harness_hub.py +0 -0
  144. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_harness_show.py +0 -0
  145. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_image.py +0 -0
  146. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_mcp.py +0 -0
  147. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_ngrok.py +0 -0
  148. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_opencode.py +0 -0
  149. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_plantuml.py +0 -0
  150. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_route.py +0 -0
  151. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_serp.py +0 -0
  152. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_storage.py +0 -0
  153. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_sysmon.py +0 -0
  154. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_tts.py +0 -0
  155. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_update.py +0 -0
  156. {evo_cli-0.33.0 → evo_cli-0.34.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.33.0
3
+ Version: 0.34.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -0,0 +1 @@
1
+ 0.34.0
@@ -392,9 +392,15 @@ def connect(name, timeout, sudo_password_stdin, watch):
392
392
  password = ""
393
393
  click.echo(f"Connecting {name}; disconnect any external GUI VPN first. System DNS is not modified.")
394
394
  deadline = time.monotonic() + timeout + 20
395
+ shown = None
395
396
  try:
396
397
  while time.monotonic() < deadline:
397
398
  state = _ask(name)
399
+ if state.get("auth_url") and state["auth_url"] != shown:
400
+ shown = state["auth_url"]
401
+ click.echo(f"Sign in to finish connecting (opened in your browser): {shown}")
402
+ if state.get("auth_expires"):
403
+ deadline = max(deadline, time.monotonic() + state["auth_expires"] - time.time() + 20)
398
404
  if state["state"] == "CONNECTED":
399
405
  click.echo(f"{name}: connected, tunnel IP {state.get('vpn_ip', 'unknown')}")
400
406
  break
@@ -45,6 +45,7 @@ OPTIONS = {
45
45
  "connect-retry",
46
46
  "connect-retry-max",
47
47
  "connect-timeout",
48
+ "server-poll-timeout",
48
49
  "float",
49
50
  "tun-mtu",
50
51
  "mssfix",
@@ -522,6 +523,30 @@ def auth_commands(line, profile):
522
523
  return f'username "Auth" {quote(username)}\npassword "Auth" {quote(password)}\n'
523
524
 
524
525
 
526
+ def web_auth_url(message):
527
+ """The sign-in URL of a pending-auth INFOMSG (WEB_AUTH:flags:url or the older OPEN_URL:url), else None."""
528
+ if message.startswith("WEB_AUTH:"):
529
+ # No proxy is announced, so flags hold no colon.
530
+ url = message[9:].partition(":")[2]
531
+ elif message.startswith("OPEN_URL:"):
532
+ url = message[9:]
533
+ else:
534
+ return None
535
+ if urlparse(url).scheme != "https" or any(c.isspace() or not c.isprintable() for c in url):
536
+ raise CredentialError("VPN server sent an unusable sign-in URL.")
537
+ return url
538
+
539
+
540
+ def open_url(url):
541
+ """Open the sign-in page in the user's browser; the worker runs as the user, never as root."""
542
+ opener = shutil.which("open" if sys.platform == "darwin" else "xdg-open")
543
+ if opener:
544
+ try:
545
+ subprocess.Popen([opener, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
546
+ except OSError:
547
+ pass
548
+
549
+
525
550
  def worker(name, timeout):
526
551
  """Keep the management channel alive for reauthentication; never persist raw VPN logs."""
527
552
  import fcntl
@@ -592,6 +617,10 @@ def worker(name, timeout):
592
617
  "--verb",
593
618
  "3",
594
619
  ]
620
+ if not uses_credentials(profile["config"]):
621
+ # With no password to ask for, a server can add a sign-in step only through the browser (Access
622
+ # Server SAML); announce it, or such a server refuses the client.
623
+ command += ["--setenv", "IV_SSO", "openurl,webauth"]
595
624
  pinned = [] if takes_default_route(profile["config"]) else remotes
596
625
  if profile.get("otp_url"):
597
626
  pinned = [*pinned, urlparse(profile["otp_url"]).hostname]
@@ -662,6 +691,12 @@ def worker(name, timeout):
662
691
  if stamp.isdigit():
663
692
  clock = time.strftime("%H:%M:%S", time.localtime(int(stamp)))
664
693
  log.append(f"{clock} {rest.partition(',')[2]}")
694
+ elif line.startswith(">INFOMSG:"):
695
+ url = web_auth_url(line[9:])
696
+ if url:
697
+ state["auth_url"] = url
698
+ publish()
699
+ open_url(url)
665
700
  elif line.startswith(">FATAL:"):
666
701
  raise CredentialError("OpenVPN reported a fatal error; check config, TLS and permissions.")
667
702
  elif line.startswith(">STATE:"):
@@ -671,11 +706,18 @@ def worker(name, timeout):
671
706
  if fields[1] == "CONNECTED" and fields[2] == "SUCCESS":
672
707
  state["vpn_ip"] = fields[3]
673
708
  state["since"] = time.time()
709
+ state.pop("auth_url", None)
710
+ state.pop("auth_expires", None)
674
711
  # Once up, let OpenVPN retry for as long as the network is gone.
675
712
  deadline = float("inf")
676
713
  elif fields[1] != "CONNECTED":
677
714
  state.pop("vpn_ip", None)
678
715
  state.pop("since", None)
716
+ if fields[1] == "AUTH_PENDING":
717
+ # Signing in happens in the browser: allow the time the server gives it.
718
+ wait = int(fields[2]) if fields[2].isdigit() else 300
719
+ deadline = max(deadline, time.monotonic() + wait)
720
+ state["auth_expires"] = time.time() + wait
679
721
  if fields[1] == "RECONNECTING":
680
722
  state["reconnects"] = state.get("reconnects", 0) + 1
681
723
  if fields[1] in ("RECONNECTING", "EXITING"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.33.0
3
+ Version: 0.34.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -104,6 +104,11 @@ def test_rejects_unsafe_or_external_config(directive):
104
104
  vpn.validate_config(CONFIG + directive + "\n")
105
105
 
106
106
 
107
+ def test_config_accepts_access_server_timing():
108
+ # OpenVPN Access Server profiles carry server-poll-timeout, an alias of connect-timeout.
109
+ assert vpn.validate_config(CONFIG + "server-poll-timeout 4\n") == ["vpn.example 1194 udp"]
110
+
111
+
107
112
  def test_config_accepts_inline_key_without_parsing_it():
108
113
  assert vpn.validate_config(CONFIG + "lport 0\n<key>\nPRIVATE SECRET\n</key>\n") == ["vpn.example 1194 udp"]
109
114
 
@@ -349,6 +354,26 @@ def test_worker_pins_the_otp_host_even_for_a_full_tunnel(store, monkeypatch):
349
354
  assert pinned == [["otp.example"]]
350
355
 
351
356
 
357
+ @pytest.mark.parametrize(
358
+ "message,expected",
359
+ [
360
+ ("WEB_AUTH::https://vpn.example/a?b=1", "https://vpn.example/a?b=1"),
361
+ ("WEB_AUTH:external:https://vpn.example/a", "https://vpn.example/a"),
362
+ ("OPEN_URL:https://vpn.example/a", "https://vpn.example/a"),
363
+ ("CR_TEXT:E,R:Enter code", None),
364
+ ("some other notice", None),
365
+ ],
366
+ )
367
+ def test_web_auth_url(message, expected):
368
+ assert vpn.web_auth_url(message) == expected
369
+
370
+
371
+ @pytest.mark.parametrize("url", ["http://vpn.example/a", "file:///etc/passwd", "https://vpn.example/a b", "-a"])
372
+ def test_web_auth_url_rejects_anything_but_plain_https(url):
373
+ with pytest.raises(CredentialError):
374
+ vpn.web_auth_url("WEB_AUTH::" + url)
375
+
376
+
352
377
  def test_plain_password_escaping_and_no_unrequested_otp():
353
378
  text = vpn.auth_commands("Need 'Auth' username/password", {"username": "u", "password": 'p"\\'})
354
379
  assert text == 'username "Auth" "u"\npassword "Auth" "p\\"\\\\"\n'
@@ -462,7 +487,12 @@ with socket.socket(socket.AF_UNIX) as s:
462
487
  seen = hold()
463
488
  assert {b'state on\\n', b'log on\\n', b'bytecount 2\\n'} <= seen
464
489
  held = b'hold off\\n' not in seen
465
- auth()
490
+ if sys.argv[2] == 'webauth':
491
+ # Access Server SAML: no password prompt, a browser sign-in instead.
492
+ s.sendall(b'>STATE:1,AUTH_PENDING,120,,,,,\\n')
493
+ s.sendall(b'>INFOMSG:WEB_AUTH::https://vpn.example/auth/1\\n')
494
+ else:
495
+ auth()
466
496
  if sys.argv[2] == 'fail':
467
497
  s.sendall(b\">PASSWORD:Verification Failed: 'Auth'\\n\")
468
498
  else:
@@ -480,12 +510,15 @@ with socket.socket(socket.AF_UNIX) as s:
480
510
  """
481
511
 
482
512
 
483
- @pytest.mark.parametrize("mode", ["ok", "fail", "reconnect"])
513
+ @pytest.mark.parametrize("mode", ["ok", "fail", "reconnect", "webauth"])
484
514
  def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatch, mode):
485
- fail = mode == "fail"
515
+ fail, webauth = mode == "fail", mode == "webauth"
516
+ config = CERT_ONLY if webauth else CONFIG
486
517
  vpn.save_profile(
487
- "m1", {"config": CONFIG, "username": "test", "password": "password-secret", "totp": vpn.parse_totp(SECRET)}
518
+ "m1", {"config": config, "username": "test", "password": "password-secret", "totp": vpn.parse_totp(SECRET)}
488
519
  )
520
+ opened = []
521
+ monkeypatch.setattr(vpn, "open_url", opened.append)
489
522
  monkeypatch.setattr(sys, "stdin", io.StringIO("not-a-real-sudo-password\n"))
490
523
  monkeypatch.setattr(vpn.os, "geteuid", lambda: 0)
491
524
  monkeypatch.setattr(vpn.shutil, "which", lambda _: "/fake/openvpn")
@@ -497,6 +530,8 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
497
530
  # A soft restart (ping-restart, connection-reset) must reconnect, not exit.
498
531
  assert "--remap-usr1" not in command and "--connect-retry-max" not in command
499
532
  assert command[command.index("--ping") + 1] == "10"
533
+ # Only a profile without a password announces browser sign-in.
534
+ assert ("IV_SSO" in command) == webauth
500
535
  endpoint = command[command.index("--management") + 1]
501
536
  return popen([sys.executable, "-c", FAKE_OPENVPN, endpoint, mode], **kwargs)
502
537
 
@@ -511,7 +546,7 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
511
546
  if state["state"] == "CONNECTED" and (mode != "reconnect" or state.get("reconnects")):
512
547
  assert state["vpn_ip"] == "10.8.0.2"
513
548
  assert state["since"] and (state["bytes_in"], state["bytes_out"]) == (1024, 2048)
514
- assert not fail
549
+ assert not fail and "auth_url" not in state and "auth_expires" not in state
515
550
  if mode == "reconnect":
516
551
  assert state["reconnects"] == 1 and state["last_reason"] == "connection-reset"
517
552
  # Only the newest lines are kept, and a reply larger than one read arrives whole.
@@ -522,6 +557,7 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
522
557
  time.sleep(0.05)
523
558
  thread.join(5)
524
559
  assert not thread.is_alive()
560
+ assert opened == (["https://vpn.example/auth/1"] if webauth else [])
525
561
  assert vpn.request("m1")["state"] == "STOPPED"
526
562
  base = vpn.session_path("m1")
527
563
  state = json.loads(Path(str(base) + ".json").read_text())
@@ -609,6 +645,27 @@ def test_cert_only_profile_connects_and_lists_without_credentials(store, monkeyp
609
645
  assert rows == {"cert": "cert", "login": "no"}
610
646
 
611
647
 
648
+ def test_connect_shows_the_sign_in_url_once_and_waits_for_it(store, monkeypatch):
649
+ vpn.save_profile("sso", {"config": CERT_ONLY})
650
+ pending = {"state": "AUTH_PENDING", "auth_url": "https://vpn.example/auth/1", "auth_expires": time.time() + 120}
651
+ states = iter([{"state": "STOPPED"}, pending, pending])
652
+ monkeypatch.setattr(vpn, "request", lambda name, command="status": next(states, {"state": "CONNECTED"}))
653
+ monkeypatch.setattr("evo_cli.commands.openvpn.shutil.which", lambda _: "/fake/openvpn")
654
+ monkeypatch.setattr("evo_cli.commands.openvpn.os.geteuid", lambda: 0)
655
+
656
+ class Running:
657
+ def __init__(self):
658
+ self.stdin = io.BytesIO()
659
+
660
+ def poll(self):
661
+ return None
662
+
663
+ monkeypatch.setattr("evo_cli.commands.openvpn.subprocess.Popen", lambda command, **_: Running())
664
+ result = CliRunner().invoke(cli, ["openvpn", "connect", "sso", "--no-watch", "--timeout", "10"])
665
+ assert result.exit_code == 0, result.output
666
+ assert result.output.count("https://vpn.example/auth/1") == 1 and "connected" in result.output
667
+
668
+
612
669
  def test_pick_profile_by_number_single_or_prompt(store, monkeypatch):
613
670
  runner = CliRunner()
614
671
  totp = vpn.parse_totp(SECRET)
@@ -1 +0,0 @@
1
- 0.33.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes