lager-cli 0.32.4__tar.gz → 0.32.5__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 (204) hide show
  1. {lager_cli-0.32.4/lager_cli.egg-info → lager_cli-0.32.5}/PKG-INFO +1 -1
  2. {lager_cli-0.32.4 → lager_cli-0.32.5}/__init__.py +1 -1
  3. {lager_cli-0.32.4 → lager_cli-0.32.5}/battery/websocket_client.py +30 -13
  4. {lager_cli-0.32.4 → lager_cli-0.32.5}/box_storage.py +67 -21
  5. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/boxes.py +26 -1
  6. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/config.py +7 -0
  7. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/diagnose.py +7 -0
  8. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/lock.py +7 -0
  9. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/uart.py +4 -0
  10. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/websocket_client.py +31 -17
  11. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/service_client.py +11 -0
  12. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/update.py +7 -0
  13. {lager_cli-0.32.4 → lager_cli-0.32.5}/context/session.py +10 -1
  14. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/version_skew.py +9 -0
  15. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/ws_diagnose.py +18 -1
  16. {lager_cli-0.32.4 → lager_cli-0.32.5}/gateway_auth.py +75 -0
  17. {lager_cli-0.32.4 → lager_cli-0.32.5/lager_cli.egg-info}/PKG-INFO +1 -1
  18. {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/SOURCES.txt +1 -0
  19. {lager_cli-0.32.4 → lager_cli-0.32.5}/status.py +8 -0
  20. {lager_cli-0.32.4 → lager_cli-0.32.5}/supply/websocket_client.py +30 -13
  21. {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_box_lager_imports.py +48 -48
  22. {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_gateway_auth.py +160 -0
  23. lager_cli-0.32.5/tests/test_gateway_callsites.py +177 -0
  24. lager_cli-0.32.5/tests/test_io_imports.py +266 -0
  25. lager_cli-0.32.4/tests/test_io_imports.py +0 -351
  26. {lager_cli-0.32.4 → lager_cli-0.32.5}/LICENSE +0 -0
  27. {lager_cli-0.32.4 → lager_cli-0.32.5}/MANIFEST.in +0 -0
  28. {lager_cli-0.32.4 → lager_cli-0.32.5}/README.md +0 -0
  29. {lager_cli-0.32.4 → lager_cli-0.32.5}/__main__.py +0 -0
  30. {lager_cli-0.32.4 → lager_cli-0.32.5}/address_utils.py +0 -0
  31. {lager_cli-0.32.4 → lager_cli-0.32.5}/battery/__init__.py +0 -0
  32. {lager_cli-0.32.4 → lager_cli-0.32.5}/battery/battery_tui.py +0 -0
  33. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/__init__.py +0 -0
  34. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/__init__.py +0 -0
  35. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_host_ops.py +0 -0
  36. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_mount_prep.py +0 -0
  37. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_pip_validation.py +0 -0
  38. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_shim_verbs.py +0 -0
  39. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/_ssh.py +0 -0
  40. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/box_group.py +0 -0
  41. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/dut.py +0 -0
  42. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/hello.py +0 -0
  43. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/instruments.py +0 -0
  44. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/labjack_pins.py +0 -0
  45. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/net_tui.py +0 -0
  46. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/nets.py +0 -0
  47. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/ssh.py +0 -0
  48. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/box/ssh_setup.py +0 -0
  49. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/__init__.py +0 -0
  50. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/ble.py +0 -0
  51. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/blufi.py +0 -0
  52. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/i2c.py +0 -0
  53. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/router.py +0 -0
  54. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/spi.py +0 -0
  55. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/usb.py +0 -0
  56. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/communication/wifi.py +0 -0
  57. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/__init__.py +0 -0
  58. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/arm.py +0 -0
  59. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/__init__.py +0 -0
  60. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/commands.py +0 -0
  61. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/gdb.py +0 -0
  62. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/net_cache.py +0 -0
  63. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/service_helper.py +0 -0
  64. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/debug/tunnel.py +0 -0
  65. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/devenv.py +0 -0
  66. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/development/python.py +0 -0
  67. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/__init__.py +0 -0
  68. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/adc.py +0 -0
  69. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/dac.py +0 -0
  70. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/energy.py +0 -0
  71. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/gpi.py +0 -0
  72. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/gpo.py +0 -0
  73. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/logic.py +0 -0
  74. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/scope.py +0 -0
  75. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/thermocouple.py +0 -0
  76. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/measurement/watt.py +0 -0
  77. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/__init__.py +0 -0
  78. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/battery.py +0 -0
  79. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/battery_model_csv.py +0 -0
  80. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/eload.py +0 -0
  81. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/solar.py +0 -0
  82. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/power/supply.py +0 -0
  83. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/__init__.py +0 -0
  84. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/binaries.py +0 -0
  85. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/defaults.py +0 -0
  86. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/exec_.py +0 -0
  87. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/install.py +0 -0
  88. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/install_wheel.py +0 -0
  89. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/login.py +0 -0
  90. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/logs.py +0 -0
  91. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/uninstall.py +0 -0
  92. {lager_cli-0.32.4 → lager_cli-0.32.5}/commands/utility/webcam.py +0 -0
  93. {lager_cli-0.32.4 → lager_cli-0.32.5}/config.py +0 -0
  94. {lager_cli-0.32.4 → lager_cli-0.32.5}/context/__init__.py +0 -0
  95. {lager_cli-0.32.4 → lager_cli-0.32.5}/context/ci_detection.py +0 -0
  96. {lager_cli-0.32.4 → lager_cli-0.32.5}/context/constants.py +0 -0
  97. {lager_cli-0.32.4 → lager_cli-0.32.5}/context/core.py +0 -0
  98. {lager_cli-0.32.4 → lager_cli-0.32.5}/context/error_handlers.py +0 -0
  99. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/__init__.py +0 -0
  100. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/group_usage.py +0 -0
  101. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/matchers.py +0 -0
  102. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/net_group.py +0 -0
  103. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/net_helpers.py +0 -0
  104. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/net_storage.py +0 -0
  105. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/param_types.py +0 -0
  106. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/ssh_utils.py +0 -0
  107. {lager_cli-0.32.4 → lager_cli-0.32.5}/core/utils.py +0 -0
  108. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/__init__.py +0 -0
  109. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/__init__.py +0 -0
  110. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/configure_docker_dns.py +0 -0
  111. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/configure_docker_dns.sh +0 -0
  112. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/convert_to_sparse_checkout.sh +0 -0
  113. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/setup_and_deploy_box.sh +0 -0
  114. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/scripts/setup_ssh_key.sh +0 -0
  115. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/security/__init__.py +0 -0
  116. {lager_cli-0.32.4 → lager_cli-0.32.5}/deployment/security/secure_box_firewall.sh +0 -0
  117. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/__init__.py +0 -0
  118. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/__init__.py +0 -0
  119. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/construct_utils.py +0 -0
  120. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/exceptions.py +0 -0
  121. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/py3compat.py +0 -0
  122. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/common/utils.py +0 -0
  123. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/__init__.py +0 -0
  124. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/adapters.py +0 -0
  125. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/core.py +0 -0
  126. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/debug.py +0 -0
  127. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/construct/macros.py +0 -0
  128. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/__init__.py +0 -0
  129. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/abbrevtable.py +0 -0
  130. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/aranges.py +0 -0
  131. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/callframe.py +0 -0
  132. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/compileunit.py +0 -0
  133. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/constants.py +0 -0
  134. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/descriptions.py +0 -0
  135. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/die.py +0 -0
  136. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/dwarf_expr.py +0 -0
  137. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/dwarfinfo.py +0 -0
  138. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/enums.py +0 -0
  139. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/lineprogram.py +0 -0
  140. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/locationlists.py +0 -0
  141. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/namelut.py +0 -0
  142. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/ranges.py +0 -0
  143. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/dwarf/structs.py +0 -0
  144. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/__init__.py +0 -0
  145. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/constants.py +0 -0
  146. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/decoder.py +0 -0
  147. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/ehabiinfo.py +0 -0
  148. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/ehabi/structs.py +0 -0
  149. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/__init__.py +0 -0
  150. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/constants.py +0 -0
  151. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/descriptions.py +0 -0
  152. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/dynamic.py +0 -0
  153. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/elffile.py +0 -0
  154. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/enums.py +0 -0
  155. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/gnuversions.py +0 -0
  156. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/hash.py +0 -0
  157. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/notes.py +0 -0
  158. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/relocation.py +0 -0
  159. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/sections.py +0 -0
  160. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/segments.py +0 -0
  161. {lager_cli-0.32.4 → lager_cli-0.32.5}/elftools/elf/structs.py +0 -0
  162. {lager_cli-0.32.4 → lager_cli-0.32.5}/errors.py +0 -0
  163. {lager_cli-0.32.4 → lager_cli-0.32.5}/exceptions.py +0 -0
  164. {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/__init__.py +0 -0
  165. {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/box_config.py +0 -0
  166. {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/measurement/__init__.py +0 -0
  167. {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/measurement/scope.py +0 -0
  168. {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/measurement/scope_stream.py +0 -0
  169. {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/power/__init__.py +0 -0
  170. {lager_cli-0.32.4 → lager_cli-0.32.5}/impl/power/enable_disable.py +0 -0
  171. {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/dependency_links.txt +0 -0
  172. {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/entry_points.txt +0 -0
  173. {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/requires.txt +0 -0
  174. {lager_cli-0.32.4 → lager_cli-0.32.5}/lager_cli.egg-info/top_level.txt +0 -0
  175. {lager_cli-0.32.4 → lager_cli-0.32.5}/main.py +0 -0
  176. {lager_cli-0.32.4 → lager_cli-0.32.5}/pyproject.toml +0 -0
  177. {lager_cli-0.32.4 → lager_cli-0.32.5}/safe_unpickle.py +0 -0
  178. {lager_cli-0.32.4 → lager_cli-0.32.5}/setup.cfg +0 -0
  179. {lager_cli-0.32.4 → lager_cli-0.32.5}/setup.py +0 -0
  180. {lager_cli-0.32.4 → lager_cli-0.32.5}/simple_hdlc.py +0 -0
  181. {lager_cli-0.32.4 → lager_cli-0.32.5}/sort_utils.py +0 -0
  182. {lager_cli-0.32.4 → lager_cli-0.32.5}/supply/__init__.py +0 -0
  183. {lager_cli-0.32.4 → lager_cli-0.32.5}/supply/supply_tui.py +0 -0
  184. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/__init__.py +0 -0
  185. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/core/__init__.py +0 -0
  186. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/core/executor.py +0 -0
  187. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/main.py +0 -0
  188. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/__init__.py +0 -0
  189. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/completer.py +0 -0
  190. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/display.py +0 -0
  191. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/logo.py +0 -0
  192. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/repl.py +0 -0
  193. {lager_cli-0.32.4 → lager_cli-0.32.5}/terminal/ui/themes.py +0 -0
  194. {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_box_storage.py +0 -0
  195. {lager_cli-0.32.4 → lager_cli-0.32.5}/tests/test_update_gate.py +0 -0
  196. {lager_cli-0.32.4 → lager_cli-0.32.5}/update_check.py +0 -0
  197. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16.py +0 -0
  198. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16DNP.py +0 -0
  199. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16Kermit.py +0 -0
  200. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC16SICK.py +0 -0
  201. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRC32.py +0 -0
  202. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/CRCCCITT.py +0 -0
  203. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/PyCRC/__init__.py +0 -0
  204. {lager_cli-0.32.4 → lager_cli-0.32.5}/vendor/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: lager-cli
3
- Version: 0.32.4
3
+ Version: 0.32.5
4
4
  Summary: Lager CLI - Box and Docker connectivity
5
5
  Home-page: https://github.com/lagerdata/lager
6
6
  Author: Lager Data
@@ -7,4 +7,4 @@ Lager CLI
7
7
  A Command Line Interface for Lager Data
8
8
  """
9
9
 
10
- __version__ = '0.32.4'
10
+ __version__ = '0.32.5'
@@ -132,19 +132,36 @@ class BatteryWebSocketClient:
132
132
  Returns:
133
133
  True if connected successfully, False otherwise
134
134
  """
135
- try:
136
- from ..gateway_auth import auth_headers_for_url
137
- self.sio.connect(
138
- self.box_url,
139
- namespaces=['/battery'],
140
- wait_timeout=timeout,
141
- headers=auth_headers_for_url(self.box_url)
142
- )
143
- return True
144
- except Exception as e:
145
- if self.on_error:
146
- self.on_error(f"Could not connect to box at {self.box_url}: {str(e)}")
147
- return False
135
+ from ..gateway_auth import (
136
+ auth_headers_for_url, plain_error_text, ws_handshake_recovery,
137
+ )
138
+ headers = auth_headers_for_url(self.box_url)
139
+ for attempt in (0, 1):
140
+ try:
141
+ self.sio.connect(
142
+ self.box_url,
143
+ namespaces=['/battery'],
144
+ wait_timeout=timeout,
145
+ headers=headers
146
+ )
147
+ return True
148
+ except Exception as e:
149
+ # The handshake exception hides the HTTP response, so probe
150
+ # the box over HTTP: a gated box's first contact records the
151
+ # mapping and hands back the token for one retry; a genuine
152
+ # denial becomes the actionable message.
153
+ retry_headers, denial = ws_handshake_recovery(self.box_url, headers)
154
+ if denial is not None:
155
+ if self.on_error:
156
+ self.on_error(plain_error_text(denial))
157
+ return False
158
+ if attempt == 0 and retry_headers:
159
+ headers = retry_headers
160
+ continue
161
+ if self.on_error:
162
+ self.on_error(f"Could not connect to box at {self.box_url}: {str(e)}")
163
+ return False
164
+ return False
148
165
 
149
166
  def start_monitoring(self) -> bool:
150
167
  """
@@ -583,43 +583,49 @@ def _gateway_kwargs(ip):
583
583
 
584
584
  def _resend_with_auth(prepared, headers):
585
585
  """Re-send an already-prepared request with extra headers merged in.
586
- Returns the new response, or None if the resend itself failed."""
586
+ Returns the new response, or None if the resend itself failed.
587
+
588
+ Sent with ``stream=True`` so callers that stream the original request
589
+ (pip/python output) keep streaming on the retried response; non-streaming
590
+ callers read ``.json()``/``.text`` which consumes it identically."""
587
591
  import requests
588
592
  req = prepared.copy()
589
593
  for key, value in headers.items():
590
594
  req.headers[key] = value
591
595
  try:
592
- return requests.Session().send(req, timeout=30, stream=False)
596
+ return requests.Session().send(req, timeout=30, stream=True)
593
597
  except requests.RequestException:
594
598
  return None
595
599
 
596
600
 
597
- def _check_gateway(resp, ip):
598
- """Resolve a gateway response, returning the response the caller should use.
599
-
600
- On a plain (un-gated) box this is a passthrough. On a gated box:
601
+ def _resolve_gateway(resp, ip):
602
+ """Record-and-retry core shared by :func:`_check_gateway` and
603
+ :func:`check_gateway_status` — the single implementation of gateway
604
+ discovery. Returns ``(resp, denied)``:
601
605
 
606
+ - Non-gateway responses (no discovery header) pass through untouched
607
+ with ``denied=False`` — plain boxes are never affected.
602
608
  - First contact — we sent no token (the box→auth-server link is only
603
609
  learned from this very 401's discovery header) but we already hold a
604
610
  session for that server: record the mapping, attach the token, and
605
- retry the request once. The caller gets the authenticated response and
606
- never sees the round trip. A plain box never receives the token,
607
- because only a gateway sends the discovery header.
608
- - Genuine denials (revoked session, no access grant, auth server down)
609
- raise the actionable `lager login` / "ask your admin" error as before.
610
-
611
- Callers should adopt the return value: ``resp = _check_gateway(resp, ip)``.
611
+ retry the request once. On success the retried, authenticated response
612
+ is returned with ``denied=False``. A plain box never receives the
613
+ token, because only a gateway sends the discovery header.
614
+ - Anything else is a genuine denial: the mapping is still recorded (so
615
+ the next attempt authenticates) and ``denied=True`` tells the caller
616
+ to handle ``resp`` as a gateway denial.
612
617
  """
613
618
  from .gateway_auth import (
614
- DISCOVERY_HEADER, handle_gateway_denial,
615
- record_box_auth_server, auth_headers_for_box,
619
+ DISCOVERY_HEADER, record_box_auth_server, auth_headers_for_box,
616
620
  )
617
- if not (resp.status_code in (401, 403, 503) and DISCOVERY_HEADER in resp.headers):
618
- return resp
621
+ # getattr: tolerate response-shaped fakes without a headers attribute.
622
+ resp_headers = getattr(resp, 'headers', None) or {}
623
+ if not (resp.status_code in (401, 403, 503) and DISCOVERY_HEADER in resp_headers):
624
+ return resp, False
619
625
 
626
+ record_box_auth_server(ip, resp_headers[DISCOVERY_HEADER])
620
627
  sent_auth = 'Authorization' in getattr(resp.request, 'headers', {})
621
628
  if resp.status_code == 401 and not sent_auth:
622
- record_box_auth_server(ip, resp.headers[DISCOVERY_HEADER])
623
629
  headers = auth_headers_for_box(ip)
624
630
  if headers:
625
631
  retried = _resend_with_auth(resp.request, headers)
@@ -627,13 +633,47 @@ def _check_gateway(resp, ip):
627
633
  gated = (retried.status_code in (401, 403, 503)
628
634
  and DISCOVERY_HEADER in retried.headers)
629
635
  if not gated:
630
- return retried # transparently authenticated
631
- resp = retried # still refused — report on the retry
636
+ return retried, False # transparently authenticated
637
+ resp = retried # still refused — report on the retry
638
+
639
+ return resp, True
640
+
641
+
642
+ def _check_gateway(resp, ip):
643
+ """Resolve a gateway response, returning the response the caller should use.
644
+
645
+ On a plain (un-gated) box this is a passthrough. On a gated box the
646
+ first contact is retried transparently (see :func:`_resolve_gateway`);
647
+ genuine denials (revoked session, no access grant, auth server down)
648
+ raise the actionable `lager login` / "ask your admin" error as before.
632
649
 
633
- handle_gateway_denial(resp, ip) # raises the actionable error
650
+ Callers should adopt the return value: ``resp = _check_gateway(resp, ip)``.
651
+ """
652
+ from .gateway_auth import handle_gateway_denial
653
+ resp, denied = _resolve_gateway(resp, ip)
654
+ if denied:
655
+ handle_gateway_denial(resp, ip) # raises the actionable error
634
656
  return resp
635
657
 
636
658
 
659
+ def check_gateway_status(resp, ip):
660
+ """Non-raising variant of :func:`_check_gateway` for fan-out and
661
+ fail-open callers (`lager boxes`, health polls) that must not abort on a
662
+ single box's denial.
663
+
664
+ Performs the same record-and-retry as ``_check_gateway``. Returns
665
+ ``(resp, label)``: ``label`` is None when the caller should simply use
666
+ ``resp`` (plain box, or the retry authenticated transparently), else a
667
+ short user-facing verdict — 'sign-in required', 'session rejected',
668
+ 'no access', or 'auth server down'.
669
+ """
670
+ from .gateway_auth import denial_label
671
+ resp, denied = _resolve_gateway(resp, ip)
672
+ if not denied:
673
+ return resp, None
674
+ return resp, denial_label(resp)
675
+
676
+
637
677
  def _lock_url(ip, suffix=''):
638
678
  # Lock state is shared box-wide; both the :5000 and :9000 servers expose
639
679
  # it via lager.lock_state. The CLI talks to :9000 (the primary HTTP API).
@@ -807,6 +847,9 @@ def release_box_lock(ip, holder, *, quiet=True):
807
847
  )
808
848
  return False
809
849
 
850
+ # Non-raising gateway check (this function promises never to raise);
851
+ # a denial falls through to the failure warning below.
852
+ resp, _gate_verdict = check_gateway_status(resp, ip)
810
853
  if resp.status_code == 200:
811
854
  return True
812
855
  if not quiet:
@@ -848,6 +891,9 @@ def heartbeat_box_lock(ip, holder, *, quiet=True):
848
891
  fg='yellow', err=True,
849
892
  )
850
893
  return False
894
+ # Non-raising gateway check; a denial reads as a failed heartbeat, which
895
+ # callers already treat as "carry on" (server TTL is authoritative).
896
+ resp, _gate_verdict = check_gateway_status(resp, ip)
851
897
  return resp.status_code == 200
852
898
 
853
899
 
@@ -47,6 +47,7 @@ def _list_boxes_live(port=9000, timeout=5):
47
47
 
48
48
  results = []
49
49
  failed_count = 0
50
+ auth_failed_count = 0
50
51
  needs_update_count = 0
51
52
  newer_count = 0
52
53
 
@@ -67,8 +68,14 @@ def _list_boxes_live(port=9000, timeout=5):
67
68
  busy_info = ''
68
69
  try:
69
70
  from ...gateway_auth import auth_headers_for_box
71
+ from ...box_storage import check_gateway_status
70
72
  lock_resp = requests.get(f'http://{ip}:{port}/lock', timeout=3,
71
73
  headers={'Cache-Control': 'no-cache', **auth_headers_for_box(ip)})
74
+ # Non-raising gateway check: one gated box must not abort the
75
+ # whole table. On first contact this records the box→auth-server
76
+ # mapping and retries with the stored token, so the /status call
77
+ # below authenticates normally (no second round trip).
78
+ lock_resp, _ = check_gateway_status(lock_resp, ip)
72
79
  if lock_resp.status_code == 200:
73
80
  lock_data = lock_resp.json()
74
81
  if lock_data.get('locked'):
@@ -81,12 +88,18 @@ def _list_boxes_live(port=9000, timeout=5):
81
88
  # It predates the newer capability fields, so even older box images
82
89
  # answer it — unlike a brand-new endpoint would.
83
90
  from ...gateway_auth import auth_headers_for_box
91
+ from ...box_storage import check_gateway_status
84
92
  url = f'http://{ip}:{port}/status'
85
93
  headers = {'Cache-Control': 'no-cache', 'Pragma': 'no-cache',
86
94
  **auth_headers_for_box(ip)}
87
95
  response = requests.get(url, timeout=timeout, headers=headers)
96
+ response, gate_verdict = check_gateway_status(response, ip)
88
97
 
89
- if response.status_code == 200:
98
+ if gate_verdict:
99
+ results.append((name, ip, user, '-', gate_verdict, locked_by, busy_info))
100
+ auth_failed_count += 1
101
+
102
+ elif response.status_code == 200:
90
103
  try:
91
104
  data = response.json()
92
105
  box_version = data.get('version') or data.get('box_version')
@@ -205,6 +218,18 @@ def _list_boxes_live(port=9000, timeout=5):
205
218
  if failed_count > 0:
206
219
  box_word = 'box' if failed_count == 1 else 'boxes'
207
220
  click.secho(f'{failed_count} {box_word} could not be reached', fg='red')
221
+ if auth_failed_count > 0:
222
+ from ...gateway_auth import auth_server_for_box
223
+ box_word = 'box needs' if auth_failed_count == 1 else 'boxes need'
224
+ click.secho(f'{auth_failed_count} {box_word} sign-in or an access grant', fg='red')
225
+ # The denial recorded each box's auth server, so we can say exactly
226
+ # where to sign in. Usually one server covers the whole fleet.
227
+ signin_urls = sorted({
228
+ url for r in results if r[4] in ('sign-in required', 'session rejected')
229
+ for url in [auth_server_for_box(r[1])] if url
230
+ })
231
+ for url in signin_urls:
232
+ click.secho(f' Sign in with: lager login {url}', fg='red')
208
233
 
209
234
  root_locked = [r[0] for r in results if r[5] == 'root']
210
235
  if root_locked:
@@ -1627,8 +1627,15 @@ def _box_api_responding(box_ip: str, *, timeout: float = 2.0) -> bool:
1627
1627
  poll cheaply."""
1628
1628
  try:
1629
1629
  from ...gateway_auth import auth_headers_for_box
1630
+ from ...box_storage import check_gateway_status
1630
1631
  r = requests.get(f"http://{box_ip}:{_BOX_API_PORT}/hello", timeout=timeout,
1631
1632
  headers=auth_headers_for_box(box_ip))
1633
+ # Non-raising gateway check: first contact with a gated box records
1634
+ # the mapping and retries with the stored token so the probe sees the
1635
+ # real /hello. A genuine denial reads as not-responding (False) —
1636
+ # this probe sits inside the apply/restart poll loop, which must not
1637
+ # abort mid-restart; the flow's own requests surface auth errors.
1638
+ r, _gate_verdict = check_gateway_status(r, box_ip)
1632
1639
  return r.status_code == 200
1633
1640
  except Exception:
1634
1641
  return False
@@ -30,6 +30,7 @@ import click
30
30
  import requests
31
31
 
32
32
  from ...box_storage import resolve_and_validate_box_with_name
33
+ from ...errors import LagerError
33
34
 
34
35
 
35
36
  # Net-role choices for --type. Mirrors the role strings produced by
@@ -51,8 +52,10 @@ def _fetch_net_info(
51
52
  that — otherwise the returned role wins."""
52
53
  try:
53
54
  from ...gateway_auth import auth_headers_for_box
55
+ from ...box_storage import _check_gateway
54
56
  r = requests.get(f'http://{box_ip}:9000/nets/list', timeout=5,
55
57
  headers=auth_headers_for_box(box_ip))
58
+ r = _check_gateway(r, box_ip)
56
59
  r.raise_for_status()
57
60
  nets = r.json()
58
61
  # Older box images wrap the list: {"nets": [...]}. Unwrap so the
@@ -72,6 +75,10 @@ def _fetch_net_info(
72
75
  f'Check network/Tailscale connectivity, then `lager hello --box {display_name}`.',
73
76
  fg='red'), err=True)
74
77
  return None, None
78
+ except LagerError:
79
+ # A gateway denial from _check_gateway is already actionable — let it
80
+ # render as-is rather than collapsing into the generic message below.
81
+ raise
75
82
  except Exception as e:
76
83
  click.echo(click.style(f'Could not fetch net list from box: {e}', fg='red'), err=True)
77
84
  return None, None
@@ -35,6 +35,7 @@ def lock(ctx, box, lock_user):
35
35
  # lock immune to the new heartbeat-driven auto-reap that ephemeral
36
36
  # `lager python` locks participate in.
37
37
  from ...gateway_auth import auth_headers_for_box
38
+ from ...box_storage import _check_gateway
38
39
  resp = requests.post(
39
40
  f'http://{ip}:9000/lock',
40
41
  headers=auth_headers_for_box(ip),
@@ -45,6 +46,7 @@ def lock(ctx, box, lock_user):
45
46
  },
46
47
  timeout=5,
47
48
  )
49
+ resp = _check_gateway(resp, ip)
48
50
  except requests.exceptions.RequestException as e:
49
51
  click.secho(f"Error: Could not reach box '{display_name}': {e}", fg='red', err=True)
50
52
  ctx.exit(1)
@@ -80,12 +82,17 @@ def unlock(ctx, box, force):
80
82
 
81
83
  try:
82
84
  from ...gateway_auth import auth_headers_for_box
85
+ from ...box_storage import _check_gateway
83
86
  resp = requests.post(
84
87
  f'http://{ip}:9000/unlock',
85
88
  headers=auth_headers_for_box(ip),
86
89
  json={'user': user, 'force': force},
87
90
  timeout=5,
88
91
  )
92
+ # Gateway denials raise here with the actionable sign-in error; the
93
+ # 403 branch below keeps meaning "locked by another user" only (a
94
+ # plain box's application 403 has no discovery header).
95
+ resp = _check_gateway(resp, ip)
89
96
  except requests.exceptions.RequestException as e:
90
97
  click.secho(f"Error: Could not reach box '{display_name}': {e}", fg='red', err=True)
91
98
  ctx.exit(1)
@@ -100,8 +100,12 @@ def _run_query_instruments(ctx: click.Context, box_ip: str) -> list[dict]:
100
100
  """
101
101
  try:
102
102
  from ...gateway_auth import auth_headers_for_box
103
+ from ...box_storage import _check_gateway
103
104
  resp = requests.get(f'http://{box_ip}:9000/instruments/list', timeout=15,
104
105
  headers=auth_headers_for_box(box_ip))
106
+ # A gateway denial raises the actionable LagerError (not caught
107
+ # below): an auth problem must not be reported as "no instruments".
108
+ resp = _check_gateway(resp, box_ip)
105
109
  if resp.status_code == 200:
106
110
  data = resp.json()
107
111
  if isinstance(data, list):
@@ -260,23 +260,37 @@ class UARTWebSocketClient:
260
260
  self._setup_terminal()
261
261
 
262
262
  # Connect to WebSocket
263
- try:
264
- from ...gateway_auth import auth_headers_for_url
265
- self.sio.connect(
266
- self.box_url,
267
- namespaces=['/uart'],
268
- wait_timeout=10,
269
- headers=auth_headers_for_url(self.box_url)
270
- )
271
- except Exception as e:
272
- # Preserve the return-code contract (don't sys.exit / .die here):
273
- # the caller relies on the return value and the finally-block
274
- # terminal restore below.
275
- from ...errors import connection_error
276
- from urllib.parse import urlparse
277
- host = urlparse(self.box_url).hostname or self.box_url
278
- connection_error(e, host=host).show()
279
- return 1
263
+ from ...gateway_auth import auth_headers_for_url, ws_handshake_recovery
264
+ headers = auth_headers_for_url(self.box_url)
265
+ for attempt in (0, 1):
266
+ try:
267
+ self.sio.connect(
268
+ self.box_url,
269
+ namespaces=['/uart'],
270
+ wait_timeout=10,
271
+ headers=headers
272
+ )
273
+ break
274
+ except Exception as e:
275
+ # The handshake exception hides the HTTP response, so
276
+ # probe the box over HTTP: a gated box's first contact
277
+ # records the mapping and hands back the token for one
278
+ # retry; a genuine denial shows the actionable error.
279
+ # Preserve the return-code contract (don't sys.exit /
280
+ # .die here): the caller relies on the return value and
281
+ # the finally-block terminal restore below.
282
+ retry_headers, denial = ws_handshake_recovery(self.box_url, headers)
283
+ if denial is not None:
284
+ denial.show()
285
+ return 1
286
+ if attempt == 0 and retry_headers:
287
+ headers = retry_headers
288
+ continue
289
+ from ...errors import connection_error
290
+ from urllib.parse import urlparse
291
+ host = urlparse(self.box_url).hostname or self.box_url
292
+ connection_error(e, host=host).show()
293
+ return 1
280
294
 
281
295
  # Start UART session
282
296
  self.sio.emit('start_uart', {
@@ -44,6 +44,17 @@ class DebugServiceClient:
44
44
  'User-Agent': 'lager-cli/1.0',
45
45
  })
46
46
 
47
+ if not ssh_tunnel:
48
+ # Direct connection may cross an authenticating gateway: attach
49
+ # the bearer token when this box is known to be gated, and turn
50
+ # gateway denials into actionable errors instead of raw 401s.
51
+ # No-op for plain boxes (the hook only fires on the discovery
52
+ # header, and no token is sent to an unknown box). Tunneled
53
+ # connections terminate at 127.0.0.1 and never see the gateway.
54
+ from ....gateway_auth import auth_headers_for_box, gateway_response_hook
55
+ self.session.headers.update(auth_headers_for_box(box_host))
56
+ self.session.hooks['response'] = [gateway_response_hook(box_host)]
57
+
47
58
  def health_check(self) -> Dict[str, Any]:
48
59
  """Check if service is healthy."""
49
60
  response = self.session.get(f'{self.base_url}/health', timeout=5)
@@ -74,8 +74,15 @@ def wait_for_box_ready(box_ip, *, timeout_s=60, initial_delay_s=2):
74
74
  for port in sorted(pending):
75
75
  try:
76
76
  from ...gateway_auth import auth_headers_for_box
77
+ from ...box_storage import check_gateway_status
77
78
  r = requests.get(f'http://{box_ip}:{port}/health', timeout=3,
78
79
  headers=auth_headers_for_box(box_ip))
80
+ # Non-raising gateway check: first contact with a gated box
81
+ # records the mapping and retries with the stored token so a
82
+ # healthy gated box reads as ready. A denial reads as
83
+ # not-ready and falls to the timeout path rather than
84
+ # aborting the update's readiness poll.
85
+ r, _gate_verdict = check_gateway_status(r, box_ip)
79
86
  if r.status_code == 200:
80
87
  pending.discard(port)
81
88
  except requests.exceptions.RequestException:
@@ -358,8 +358,10 @@ class DirectIPSession:
358
358
 
359
359
  try:
360
360
  from ..gateway_auth import auth_headers_for_box
361
+ from ..box_storage import _check_gateway
361
362
  response = requests.post(url, json=payload, timeout=5,
362
363
  headers=auth_headers_for_box(self.ip_address))
364
+ response = _check_gateway(response, self.ip_address)
363
365
  return response
364
366
  except requests.exceptions.RequestException as e:
365
367
  # Return a mock response with error
@@ -479,12 +481,19 @@ class DirectIPSession:
479
481
  url = f'http://{self.ip_address}:5001/run/pip'
480
482
 
481
483
  try:
484
+ from ..gateway_auth import auth_headers_for_box
485
+ from ..box_storage import _check_gateway
482
486
  response = requests.post(
483
487
  url,
484
488
  json={'args': args},
485
489
  stream=True,
486
- timeout=600 # 10 minute timeout for pip operations
490
+ timeout=600, # 10 minute timeout for pip operations
491
+ headers=auth_headers_for_box(self.ip_address),
487
492
  )
493
+ # Gateway discovery/denial handling; on a gated box's first
494
+ # contact the request is retried with the stored token so the
495
+ # caller streams the real pip output.
496
+ response = _check_gateway(response, self.ip_address)
488
497
 
489
498
  # Check for HTTP errors
490
499
  if response.status_code != 200:
@@ -61,8 +61,17 @@ def check_and_warn(box_ip: str, box_name: str | None = None) -> None:
61
61
  # it's the same endpoint `lager box hello` uses. 1.5s timeout
62
62
  # keeps the latency penalty bounded if the box is briefly slow.
63
63
  from ..gateway_auth import auth_headers_for_box
64
+ from ..box_storage import check_gateway_status
64
65
  r = requests.get(f'http://{box_ip}:9000/status', timeout=1.5,
65
66
  headers=auth_headers_for_box(box_ip))
67
+ # Non-raising gateway check: on first contact with a gated box this
68
+ # records the mapping and retries with the stored token; a genuine
69
+ # denial just skips the warning (this module is fail-open — the
70
+ # command's own request will surface the actionable auth error).
71
+ r, gate_verdict = check_gateway_status(r, box_ip)
72
+ if gate_verdict:
73
+ logger.debug('version-skew check: gateway denied (%s)', gate_verdict)
74
+ return
66
75
  if r.status_code == 404:
67
76
  # The :9000 server answered but has no /status route — the box
68
77
  # image predates the :9000 API surface this CLI requires. That is
@@ -37,9 +37,26 @@ def make_ws_failure_message(box_ip: str, original_error: str | Exception = '') -
37
37
  # /health is a cheap, always-present endpoint on box_http_server
38
38
  # (port 9000). 2s timeout matches the worst case of a healthy box
39
39
  # under temporary load.
40
- from ..gateway_auth import auth_headers_for_box
40
+ from ..gateway_auth import auth_headers_for_box, auth_server_for_box
41
+ from ..box_storage import check_gateway_status
41
42
  r = requests.get(f'http://{box_ip}:9000/health', timeout=2.0,
42
43
  headers=auth_headers_for_box(box_ip))
44
+ # Non-raising gateway check (this helper promises never to raise):
45
+ # first contact with a gated box records the mapping and retries with
46
+ # the stored token; a genuine denial becomes the actionable message.
47
+ r, gate_verdict = check_gateway_status(r, box_ip)
48
+ if gate_verdict:
49
+ url = auth_server_for_box(box_ip) or 'your auth server'
50
+ if gate_verdict in ('sign-in required', 'session rejected'):
51
+ action = f'sign in with `lager login {url}` and retry.'
52
+ elif gate_verdict == 'no access':
53
+ action = 'ask an org admin to grant you access to this box.'
54
+ else: # auth server down
55
+ action = 'its auth server is unreachable — try again shortly.'
56
+ return (
57
+ f'{base}{detail}.\n'
58
+ f'Action: the box refused the connection ({gate_verdict}) — {action}'
59
+ )
43
60
  if r.status_code == 200:
44
61
  return (
45
62
  f'{base}{detail}.\n'
@@ -359,6 +359,36 @@ def handle_gateway_denial(response, box_ip):
359
359
  )
360
360
 
361
361
 
362
+ def denial_label(response):
363
+ """Short, render-friendly label for a gateway denial response.
364
+
365
+ For fan-out commands (`lager boxes`) that show one row per box and must
366
+ not abort the whole table on a single gated box. The full actionable
367
+ message stays in :func:`handle_gateway_denial`; this is the compact
368
+ verdict a table cell can hold.
369
+ """
370
+ if response.status_code == 403:
371
+ return 'no access'
372
+ if response.status_code == 503:
373
+ return 'auth server down'
374
+ if 'Authorization' in getattr(response.request, 'headers', {}):
375
+ return 'session rejected'
376
+ return 'sign-in required'
377
+
378
+
379
+ def plain_error_text(err):
380
+ """A LagerError flattened to one unstyled line.
381
+
382
+ For error callbacks that render inside a TUI, where the ANSI codes
383
+ from ``LagerError.format_message()`` would show up literally.
384
+ """
385
+ parts = [err.problem]
386
+ if err.cause:
387
+ parts.append(err.cause)
388
+ parts.extend(err.fixes)
389
+ return ' '.join(parts)
390
+
391
+
362
392
  def gateway_response_hook(box_ip):
363
393
  """requests response hook that intercepts gateway denials."""
364
394
  def hook(response, *_args, **_kwargs):
@@ -374,3 +404,48 @@ def auth_headers_for_url(box_url):
374
404
  if not host:
375
405
  return {}
376
406
  return auth_headers_for_box(host)
407
+
408
+
409
+ def ws_handshake_recovery(box_url, sent_headers=None):
410
+ """After a failed WebSocket/SocketIO handshake, work out whether an
411
+ authenticating gateway refused it.
412
+
413
+ The SocketIO/engineio handshake exception does not expose the HTTP
414
+ response headers, so the discovery header can't be read from it. Instead,
415
+ probe ``/health`` on the same host/port carrying the same auth the
416
+ handshake did; a gateway answers every path identically, so the probe
417
+ mirrors what the handshake saw. A plain box answers the probe without
418
+ the discovery header and is left completely untouched.
419
+
420
+ Returns ``(headers, error)``:
421
+ - ``({'Authorization': ...}, None)`` — the box is gated, the mapping is
422
+ now recorded, and we hold a session the failed handshake didn't
423
+ carry: retry the handshake once with these headers.
424
+ - ``({}, LagerError)`` — genuine gateway denial; show the error, don't
425
+ retry.
426
+ - ``({}, None)`` — not a gateway problem; the original error stands.
427
+ """
428
+ parsed = urlparse(box_url)
429
+ host = parsed.hostname
430
+ if not host:
431
+ return {}, None
432
+ port = parsed.port or 80
433
+ probe_headers = dict(sent_headers or {})
434
+ try:
435
+ resp = requests.get(f'http://{host}:{port}/health',
436
+ headers=probe_headers, timeout=5)
437
+ except requests.RequestException:
438
+ return {}, None
439
+ if not (resp.status_code in (401, 403, 503)
440
+ and DISCOVERY_HEADER in resp.headers):
441
+ return {}, None
442
+ record_box_auth_server(host, resp.headers[DISCOVERY_HEADER])
443
+ if resp.status_code == 401 and 'Authorization' not in probe_headers:
444
+ headers = auth_headers_for_box(host)
445
+ if headers:
446
+ return headers, None
447
+ try:
448
+ handle_gateway_denial(resp, host)
449
+ except LagerError as err:
450
+ return {}, err
451
+ return {}, None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: lager-cli
3
- Version: 0.32.4
3
+ Version: 0.32.5
4
4
  Summary: Lager CLI - Box and Docker connectivity
5
5
  Home-page: https://github.com/lagerdata/lager
6
6
  Author: Lager Data
@@ -373,6 +373,7 @@ terminal/ui/themes.py
373
373
  tests/test_box_lager_imports.py
374
374
  tests/test_box_storage.py
375
375
  tests/test_gateway_auth.py
376
+ tests/test_gateway_callsites.py
376
377
  tests/test_io_imports.py
377
378
  tests/test_update_gate.py
378
379
  vendor/__init__.py
@@ -19,6 +19,14 @@ import threading
19
19
  import sys
20
20
  import os
21
21
  import select
22
+
23
+ # BaseExceptionGroup is a builtin only from 3.11. setup.py declares
24
+ # python_requires=">=3.10", and the nursery below catches it -- on 3.10 that
25
+ # except clause raises NameError and masks the real exception. trio pulls in
26
+ # the `exceptiongroup` backport on <3.11, so it is always importable here.
27
+ if sys.version_info < (3, 11): # pragma: no cover - version-dependent
28
+ from exceptiongroup import BaseExceptionGroup
29
+
22
30
  from functools import partial
23
31
  from bson import decode as bson_loads
24
32
  import click