hx-cli 0.2.14__tar.gz → 0.2.15__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 (158) hide show
  1. {hx_cli-0.2.14 → hx_cli-0.2.15}/CHANGELOG.md +24 -1
  2. {hx_cli-0.2.14 → hx_cli-0.2.15}/PKG-INFO +8 -2
  3. {hx_cli-0.2.14 → hx_cli-0.2.15}/README.md +7 -1
  4. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/claude_code.py +18 -2
  6. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/cli.py +3 -1
  7. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/net.py +60 -7
  8. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/registry.py +15 -14
  9. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/ansi.py +7 -0
  10. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/fake_claude.py +8 -2
  11. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_claude_code.py +81 -4
  12. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_cli.py +3 -4
  13. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_commands.py +30 -0
  14. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_claude_code.py +74 -0
  15. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/tls_proxy.py +41 -1
  16. {hx_cli-0.2.14 → hx_cli-0.2.15}/.github/workflows/ci.yml +0 -0
  17. {hx_cli-0.2.14 → hx_cli-0.2.15}/.gitignore +0 -0
  18. {hx_cli-0.2.14 → hx_cli-0.2.15}/examples/hooks/check.sh +0 -0
  19. {hx_cli-0.2.14 → hx_cli-0.2.15}/install.sh +0 -0
  20. {hx_cli-0.2.14 → hx_cli-0.2.15}/pyproject.toml +0 -0
  21. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/agents/__init__.py +0 -0
  22. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/agents/definitions.py +0 -0
  23. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/agents/subagent.py +0 -0
  24. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/__init__.py +0 -0
  25. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/__init__.py +0 -0
  26. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/browser.py +0 -0
  27. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/callback.py +0 -0
  28. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/codex.py +0 -0
  29. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/devin.py +0 -0
  30. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/pkce.py +0 -0
  31. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/resolve.py +0 -0
  32. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/store.py +0 -0
  33. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/config.py +0 -0
  34. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/__init__.py +0 -0
  35. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/checkpoints.py +0 -0
  36. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/compaction.py +0 -0
  37. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/context.py +0 -0
  38. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/events.py +0 -0
  39. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/images.py +0 -0
  40. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/lateinject.py +0 -0
  41. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/loop.py +0 -0
  42. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/messages.py +0 -0
  43. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/session.py +0 -0
  44. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/title.py +0 -0
  45. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/usage.py +0 -0
  46. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/docs.py +0 -0
  47. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/frontmatter.py +0 -0
  48. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/git.py +0 -0
  49. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/hooks/__init__.py +0 -0
  50. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/hooks/engine.py +0 -0
  51. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/hooks/spec.py +0 -0
  52. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/keys.py +0 -0
  53. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/__init__.py +0 -0
  54. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/client.py +0 -0
  55. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/manager.py +0 -0
  56. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/oauth.py +0 -0
  57. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/paths.py +0 -0
  58. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/__init__.py +0 -0
  59. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/engine.py +0 -0
  60. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/parser.py +0 -0
  61. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/sandbox.py +0 -0
  62. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/__init__.py +0 -0
  63. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/base.py +0 -0
  64. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code.py +0 -0
  65. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code_catalogue.py +0 -0
  66. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code_cli.py +0 -0
  67. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code_transcript.py +0 -0
  68. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/codex.py +0 -0
  69. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/codex_catalogue.py +0 -0
  70. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/devin.py +0 -0
  71. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/devin_catalogue.py +0 -0
  72. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/devin_wire.py +0 -0
  73. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/models.py +0 -0
  74. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/openrouter.py +0 -0
  75. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/protowire.py +0 -0
  76. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/responses_codec.py +0 -0
  77. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/skills/__init__.py +0 -0
  78. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/skills/loader.py +0 -0
  79. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/skills/runtime.py +0 -0
  80. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/__init__.py +0 -0
  81. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/buffer.py +0 -0
  82. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/component.py +0 -0
  83. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/demo.py +0 -0
  84. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/editor.py +0 -0
  85. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/keydecode.py +0 -0
  86. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/loop.py +0 -0
  87. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/markdown.py +0 -0
  88. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/modifiers.py +0 -0
  89. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/primitives.py +0 -0
  90. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/sanitize.py +0 -0
  91. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/screen.py +0 -0
  92. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/syntax.py +0 -0
  93. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/terminal.py +0 -0
  94. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/undo.py +0 -0
  95. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/width.py +0 -0
  96. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/word_nav.py +0 -0
  97. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/__init__.py +0 -0
  98. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/anchors.py +0 -0
  99. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/base.py +0 -0
  100. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/bash.py +0 -0
  101. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/edit.py +0 -0
  102. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/glob.py +0 -0
  103. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/grep.py +0 -0
  104. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/output.py +0 -0
  105. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/read.py +0 -0
  106. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/registry.py +0 -0
  107. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/symbols.py +0 -0
  108. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/task.py +0 -0
  109. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/todo.py +0 -0
  110. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/websearch.py +0 -0
  111. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/write.py +0 -0
  112. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/trace.py +0 -0
  113. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/__init__.py +0 -0
  114. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/app.py +0 -0
  115. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/clipboard.py +0 -0
  116. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/commands.py +0 -0
  117. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/format.py +0 -0
  118. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/fuzzy.py +0 -0
  119. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/glyphs.py +0 -0
  120. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/killring.py +0 -0
  121. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/limits.py +0 -0
  122. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/paint.py +0 -0
  123. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/renderers.py +0 -0
  124. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/roles.py +0 -0
  125. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/runtime.py +0 -0
  126. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/theme.py +0 -0
  127. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/theme_json.py +0 -0
  128. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/themes/ansi.json +0 -0
  129. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/themes/dark.json +0 -0
  130. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/themes/light.json +0 -0
  131. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/__init__.py +0 -0
  132. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/blocks.py +0 -0
  133. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/dialog.py +0 -0
  134. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/login.py +0 -0
  135. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/permission.py +0 -0
  136. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/pickers.py +0 -0
  137. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/prompt.py +0 -0
  138. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/status.py +0 -0
  139. {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/transcript.py +0 -0
  140. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/__init__.py +0 -0
  141. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/conftest.py +0 -0
  142. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/__init__.py +0 -0
  143. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/conftest.py +0 -0
  144. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/fixtures/echo_server.py +0 -0
  145. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/report.py +0 -0
  146. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/stub.py +0 -0
  147. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/terminal.py +0 -0
  148. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_chat.py +0 -0
  149. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_extend.py +0 -0
  150. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_input.py +0 -0
  151. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_print.py +0 -0
  152. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_queue.py +0 -0
  153. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_tools.py +0 -0
  154. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live.py +0 -0
  155. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_codex.py +0 -0
  156. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_devin.py +0 -0
  157. {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_tavily.py +0 -0
  158. {hx_cli-0.2.14 → hx_cli-0.2.15}/uv.lock +0 -0
@@ -28,6 +28,28 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.15] - 2026-10-04
32
+
33
+ ### Changed
34
+
35
+ - `/login` and `hx auth login` list the accounts as OpenAI, Claude, OpenRouter, Devin. At the
36
+ `hx auth login` menu, Enter still picks OpenRouter.
37
+
38
+ ### Fixed
39
+
40
+ - `/login claude-code` behind a network whose TLS inspection signs with a certificate not
41
+ valid for servers ("unsupported certificate purpose") now gives Claude Code's reason and
42
+ the fix: have the network fixed or exempted, or set `HX_SSL_NO_VERIFY=1` meanwhile. It
43
+ used to give the whole sign-in URL and no fix. Turns and HX's own requests that fail the
44
+ same way give the same advice, not "add the CA", which is trusted already.
45
+ - A `HX_CA_BUNDLE`, `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` that names a file holding no
46
+ certificates no longer crashes HX at startup. HX says so and verifies against the system
47
+ trust store, and does not hand the file to Claude Code.
48
+ - A wrapped error notice no longer shows a bright green background under every line after
49
+ the first.
50
+
51
+ ---
52
+
31
53
  ## [0.2.14] - 2026-10-04
32
54
 
33
55
  ### Added
@@ -879,7 +901,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
879
901
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
880
902
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
881
903
 
882
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.14...HEAD
904
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.15...HEAD
905
+ [0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
883
906
  [0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
884
907
  [0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
885
908
  [0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.14
3
+ Version: 0.2.15
4
4
  Summary: HX - an agent harness for the terminal
5
5
  Project-URL: Homepage, https://hx.sunilaleti.dev
6
6
  Project-URL: Documentation, https://hx.sunilaleti.dev
@@ -335,7 +335,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
335
335
  `HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
336
336
  startup every session, because it is not a fix. A TLS failure is reported in
337
337
  one line - `/model` and the startup notice both name the certificate problem
338
- rather than reporting an empty catalogue with no cause.
338
+ rather than reporting an empty catalogue with no cause. A file that holds no
339
+ certificates is ignored, with a startup notice that says so.
340
+
341
+ "Unsupported certificate purpose" (or "unsuitable") is different: the CA is
342
+ trusted, but the proxy signs with a certificate that is not valid for servers,
343
+ so no CA setting fixes it. The network has to fix that certificate or exempt
344
+ the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
339
345
 
340
346
  The same settings cover the `claude-code` route, whose requests Claude Code
341
347
  makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
@@ -294,7 +294,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
294
294
  `HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
295
295
  startup every session, because it is not a fix. A TLS failure is reported in
296
296
  one line - `/model` and the startup notice both name the certificate problem
297
- rather than reporting an empty catalogue with no cause.
297
+ rather than reporting an empty catalogue with no cause. A file that holds no
298
+ certificates is ignored, with a startup notice that says so.
299
+
300
+ "Unsupported certificate purpose" (or "unsuitable") is different: the CA is
301
+ trusted, but the proxy signs with a certificate that is not valid for servers,
302
+ so no CA setting fixes it. The network has to fix that certificate or exempt
303
+ the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
298
304
 
299
305
  The same settings cover the `claude-code` route, whose requests Claude Code
300
306
  makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.14"
3
+ __version__ = "0.2.15"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -290,6 +290,22 @@ def login_terminal() -> int:
290
290
 
291
291
 
292
292
  _URL = re.compile(r"https://\S+")
293
+ _PASTE_PROMPT = re.compile(r"^.*Paste code[^>]*>\s*")
294
+ _FAILED = re.compile(r"^Login failed:\s*", re.IGNORECASE)
295
+
296
+
297
+ def _failure(output: list[str]) -> str | None:
298
+ """Why ``claude auth login`` failed: its last line of its own, ``unsupported
299
+ certificate purpose`` from ``Login failed: unsupported certificate purpose``.
300
+
301
+ Not the URL it printed before - the screen already showed that, and it is
302
+ wider than the notice - nor the paste prompt the reason is printed after.
303
+ """
304
+ for line in reversed(output):
305
+ line = _FAILED.sub("", _PASTE_PROMPT.sub("", line)).strip()
306
+ if line and not _URL.search(line):
307
+ return line
308
+ return None
293
309
 
294
310
 
295
311
  class LoginFailed(Exception):
@@ -361,8 +377,8 @@ async def login_interactive(interaction: LoginInteraction) -> LoginStatus:
361
377
  forget_status()
362
378
 
363
379
  if code != 0:
364
- tail = " ".join(line.strip() for line in output[-3:] if line.strip())
365
- raise LoginFailed(f"`claude auth login` failed: {tail or f'exit {code}'}")
380
+ reason = _failure(output) or f"exit {code}"
381
+ raise LoginFailed(net.with_tls_hint(f"`claude auth login` failed: {reason}"))
366
382
  current = await status_async(fresh=True)
367
383
  if current is None or not current.logged_in:
368
384
  raise LoginFailed("Claude Code still reports no login.")
@@ -1432,8 +1432,10 @@ def _choose_provider() -> str:
1432
1432
  print("Sign in to:")
1433
1433
  for index, spec in enumerate(registry.SPECS, start=1):
1434
1434
  print(f" {index} {spec.label}")
1435
+ # Enter still means OpenRouter, wherever it sits in the list.
1436
+ default = str(next(i for i, s in enumerate(registry.SPECS, start=1) if s.id == OPENROUTER))
1435
1437
  try:
1436
- choice = input("Choice [1]: ").strip() or "1"
1438
+ choice = input(f"Choice [{default}]: ").strip() or default
1437
1439
  except (EOFError, KeyboardInterrupt):
1438
1440
  print()
1439
1441
  return OPENROUTER
@@ -41,11 +41,38 @@ _TRUTHY = frozenset({"1", "true", "yes", "on"})
41
41
 
42
42
 
43
43
  def ca_bundle() -> Path | None:
44
- """The CA bundle the environment names, if it points at a real file."""
44
+ """The CA bundle the environment names, if it points at a file of certificates."""
45
+ named = _named_bundle()
46
+ if named is None or _load_error(named[1]) is not None:
47
+ return None
48
+ return named[1]
49
+
50
+
51
+ def _named_bundle() -> tuple[str, Path] | None:
52
+ """The first of :data:`CA_BUNDLE_VARS` that names a real file, and that file."""
45
53
  for name in CA_BUNDLE_VARS:
46
54
  raw = (os.environ.get(name) or "").strip()
47
55
  if raw and Path(raw).expanduser().is_file():
48
- return Path(raw).expanduser()
56
+ return name, Path(raw).expanduser()
57
+ return None
58
+
59
+
60
+ def _load_error(path: Path) -> str | None:
61
+ """Why ``path`` cannot serve as a CA bundle - a DER file, a key, an empty
62
+ file - or ``None`` when it can. Asked again only when the file changes."""
63
+ try:
64
+ stat = path.stat()
65
+ except OSError as exc:
66
+ return exc.strerror or str(exc)
67
+ return _load_error_of(path, stat.st_mtime_ns, stat.st_size)
68
+
69
+
70
+ @lru_cache(maxsize=4)
71
+ def _load_error_of(path: Path, mtime_ns: int, size: int) -> str | None:
72
+ try:
73
+ ssl.create_default_context(cafile=str(path))
74
+ except (ssl.SSLError, OSError, ValueError) as exc:
75
+ return _flatten(getattr(exc, "reason", None) or str(exc))
49
76
  return None
50
77
 
51
78
 
@@ -80,6 +107,15 @@ def tls_notice() -> str | None:
80
107
  """One line for the startup transcript when TLS is not stock."""
81
108
  if verification_disabled():
82
109
  return f"{NO_VERIFY_VAR} is set: TLS certificates are not being verified."
110
+ named = _named_bundle()
111
+ if named is not None and (error := _load_error(named[1])) is not None:
112
+ # Ignored, as OpenSSL and Node ignore a CA file they cannot read: failing
113
+ # to start would cost every route, the ones this file is not for too.
114
+ name, path = named
115
+ return (
116
+ f"{name} names {path}, which holds no usable CA certificates ({error}). "
117
+ f"Verifying TLS against the system trust store instead."
118
+ )
83
119
  bundle = ca_bundle()
84
120
  return f"Verifying TLS against {bundle}." if bundle is not None else None
85
121
 
@@ -108,13 +144,29 @@ _CERT_FAILURE = re.compile(
108
144
  r"certificate verif"
109
145
  r"|self[- ]signed certificate"
110
146
  r"|unable to (?:get local issuer|verify the first) certificate"
111
- r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN",
147
+ r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN"
148
+ r"|certificate purpose|INVALID_PURPOSE",
112
149
  re.IGNORECASE,
113
150
  )
114
151
 
152
+ _WRONG_PURPOSE = re.compile(r"(?:unsupported|unsuitable) certificate purpose|INVALID_PURPOSE")
153
+ """A chain the client does trust, but with a certificate in it - typically the
154
+ issuing CA of a network that intercepts TLS - that may not serve as a TLS
155
+ server's: OpenSSL says "unsuitable", the BoringSSL in Claude Code "unsupported"."""
156
+
115
157
 
116
- def tls_hint() -> str:
117
- """What to do about a certificate the client would not trust."""
158
+ def tls_hint(reason: str = "") -> str:
159
+ """What to do about a certificate the client would not accept.
160
+
161
+ ``reason`` is the client's own words, when there are any: a certificate of
162
+ the wrong purpose is already trusted, so adding its CA again changes nothing.
163
+ """
164
+ if _WRONG_PURPOSE.search(reason):
165
+ return (
166
+ "Your network intercepts TLS with a certificate that is not valid for servers. "
167
+ "Ask its administrator to fix that certificate or to exclude this service from "
168
+ f"inspection; until then, {NO_VERIFY_VAR}=1 turns verification off."
169
+ )
118
170
  bundle = ca_bundle()
119
171
  if bundle is not None:
120
172
  return (
@@ -130,7 +182,7 @@ def tls_hint() -> str:
130
182
  def with_tls_hint(message: str) -> str:
131
183
  """``message``, followed by :func:`tls_hint` when it reports a certificate
132
184
  failure - in any client's words: OpenSSL's, Node's, Claude Code's."""
133
- hint = tls_hint()
185
+ hint = tls_hint(message)
134
186
  if not _CERT_FAILURE.search(message) or hint in message:
135
187
  return message
136
188
  return f"{message.rstrip().rstrip('.')}. {hint}"
@@ -156,7 +208,8 @@ def describe(exc: BaseException) -> str:
156
208
  or getattr(err, "reason", None)
157
209
  or "certificate verify failed"
158
210
  )
159
- return f"TLS certificate verification failed ({_flatten(reason)}). {tls_hint()}"
211
+ reason = _flatten(reason)
212
+ return f"TLS certificate verification failed ({reason}). {tls_hint(reason)}"
160
213
  if isinstance(err, ssl.SSLError):
161
214
  return f"TLS handshake failed: {_flatten(err.reason or str(err))}."
162
215
  if isinstance(exc, httpx.TimeoutException):
@@ -145,13 +145,7 @@ def _build_claude_code(
145
145
 
146
146
 
147
147
  SPECS: tuple[ProviderSpec, ...] = (
148
- ProviderSpec(
149
- id=OPENROUTER,
150
- label="OpenRouter API key",
151
- kind=AuthKind.API_KEY,
152
- namespace=None,
153
- build=_build_openrouter,
154
- ),
148
+ # In the order /login and `hx auth login` offer them.
155
149
  ProviderSpec(
156
150
  id=CODEX,
157
151
  label="OpenAI (ChatGPT Plus/Pro)",
@@ -159,13 +153,6 @@ SPECS: tuple[ProviderSpec, ...] = (
159
153
  namespace=f"{CODEX}/",
160
154
  build=_build_codex,
161
155
  ),
162
- ProviderSpec(
163
- id=DEVIN,
164
- label="Devin (personal or Enterprise)",
165
- kind=AuthKind.SUBSCRIPTION,
166
- namespace=f"{DEVIN}/",
167
- build=_build_devin,
168
- ),
169
156
  ProviderSpec(
170
157
  id=CLAUDE_CODE,
171
158
  label="Claude (Pro/Max, via Claude Code)",
@@ -173,6 +160,20 @@ SPECS: tuple[ProviderSpec, ...] = (
173
160
  namespace=f"{CLAUDE_CODE}/",
174
161
  build=_build_claude_code,
175
162
  ),
163
+ ProviderSpec(
164
+ id=OPENROUTER,
165
+ label="OpenRouter API key",
166
+ kind=AuthKind.API_KEY,
167
+ namespace=None,
168
+ build=_build_openrouter,
169
+ ),
170
+ ProviderSpec(
171
+ id=DEVIN,
172
+ label="Devin (personal or Enterprise)",
173
+ kind=AuthKind.SUBSCRIPTION,
174
+ namespace=f"{DEVIN}/",
175
+ build=_build_devin,
176
+ ),
176
177
  )
177
178
 
178
179
  BY_ID: dict[str, ProviderSpec] = {spec.id: spec for spec in SPECS}
@@ -266,6 +266,13 @@ def active_background(text: str) -> str:
266
266
  # Truncated. Nothing legible to re-open, and consuming the
267
267
  # rest would be a guess at parameters that never arrived.
268
268
  break
269
+ elif code == 38 and index + 1 < len(codes):
270
+ # A foreground's parameters are not codes: the 102 of
271
+ # 38;2;204;102;102 would otherwise open a bright green background.
272
+ span = 3 if codes[index + 1] == 5 else 5 if codes[index + 1] == 2 else 0
273
+ if not span or index + span > len(codes):
274
+ break
275
+ index += span - 1
269
276
  index += 1
270
277
  return current
271
278
 
@@ -11,6 +11,7 @@ State lives in ``$E2E_CLAUDE_DIR``:
11
11
  test starts; ``cursor`` says how far it has been read;
12
12
  * ``auth.json`` - what ``claude auth status`` reports (absent: signed out);
13
13
  * ``status_delay`` - seconds ``claude auth status`` takes to answer;
14
+ * ``login_error`` - why ``claude auth login`` fails, whatever code is pasted;
14
15
  * ``version`` and ``models.json`` - which Claude Code this is: what
15
16
  ``--version`` prints and the models ``initialize`` lists (absent: 2.1.205
16
17
  and :data:`MODELS`);
@@ -130,8 +131,13 @@ def auth(args: list[str]) -> int:
130
131
  sys.stdout.write("Paste code here if prompted > ")
131
132
  sys.stdout.flush()
132
133
  code = sys.stdin.readline().strip()
133
- if code != "good-code":
134
- print("\nLogin failed: invalid code")
134
+ failure = STATE / "login_error"
135
+ if failure.exists() or code != "good-code":
136
+ # On the prompt's line, as Claude Code 2.1 prints it when stdin is a pipe.
137
+ reason = (
138
+ failure.read_text() if failure.exists() else "Request failed with status code 400"
139
+ )
140
+ print(f"Login failed: {reason}")
135
141
  return 1
136
142
  path.write_text(
137
143
  json.dumps(
@@ -15,6 +15,8 @@ import time
15
15
  from pathlib import Path
16
16
  from typing import Any
17
17
 
18
+ import trustme
19
+
18
20
  from tests.e2e import stub as openrouter
19
21
  from tests.e2e.conftest import HX, FakeClaude
20
22
  from tests.e2e.test_print import png
@@ -497,6 +499,13 @@ CERT_FAILURE = (
497
499
  """What Claude Code 2.1 reports when its connection meets a CA it does not trust."""
498
500
 
499
501
 
502
+ def corporate_ca(hx: HX) -> Path:
503
+ """A network's CA certificate, as a PEM file HX can load."""
504
+ path = hx.root / "corp-ca.pem"
505
+ trustme.CA(organization_name="Corp").cert_pem.write_to_path(str(path))
506
+ return path
507
+
508
+
500
509
  def test_corporate_tls_settings_reach_claude_code(
501
510
  hx: HX, claude: FakeClaude, project: Path
502
511
  ) -> None:
@@ -505,8 +514,7 @@ def test_corporate_tls_settings_reach_claude_code(
505
514
  Node's switch, and Claude Code's own certificate settings survive the scrub - while
506
515
  everything that moves billing off the subscription is still dropped."""
507
516
  (project / "a.txt").write_text("alpha=17\n")
508
- bundle = hx.root / "corp-ca.pem"
509
- bundle.write_text("-----BEGIN CERTIFICATE-----\nnot parsed by the fake\n")
517
+ bundle = corporate_ca(hx)
510
518
  claude.login()
511
519
  claude.use()
512
520
  claude.script(calls(("Read", {"file_path": "a.txt"})), say("alpha is 17."))
@@ -558,6 +566,18 @@ def test_corporate_tls_settings_reach_claude_code(
558
566
  assert not any(name.startswith("NODE_") for name in env)
559
567
  assert not any(name.startswith("CLAUDE_CODE_CLIENT") for name in env)
560
568
 
569
+ # A bundle that holds no certificates - a DER file, a stale path's leftovers - is said
570
+ # out loud and left out, as OpenSSL and Node leave it: HX starts and verifies against
571
+ # the system store, and Claude Code is not handed a file it cannot read either.
572
+ broken = hx.root / "broken-ca.pem"
573
+ broken.write_text("-----BEGIN CERTIFICATE-----\nnot a certificate\n")
574
+ claude.script(say("Still verifying."))
575
+ result = hx.run("-p", "hello", env={"SSL_CERT_FILE": str(broken)}, check=True)
576
+ assert result.stdout == "Still verifying.\n"
577
+ assert f"SSL_CERT_FILE names {broken}, which holds no usable CA certificates" in result.stderr
578
+ assert "Verifying TLS against the system trust store instead." in result.stderr
579
+ assert "NODE_EXTRA_CA_CERTS" not in claude.spawns(tools=True)[-1]["env"]
580
+
561
581
 
562
582
  def test_a_certificate_failure_says_how_to_fix_it(hx: HX, claude: FakeClaude) -> None:
563
583
  """Claude Code refusing the network's CA names HX's fix after its own words; with a
@@ -573,8 +593,7 @@ def test_a_certificate_failure_says_how_to_fix_it(hx: HX, claude: FakeClaude) ->
573
593
  "system trust store or set HX_CA_BUNDLE to its PEM file."
574
594
  ) in result.stderr
575
595
 
576
- bundle = hx.root / "corp-ca.pem"
577
- bundle.write_text("-----BEGIN CERTIFICATE-----\n")
596
+ bundle = corporate_ca(hx)
578
597
  claude.script({"error": CERT_FAILURE}, say("Through the proxy now."))
579
598
  term = hx.tui(env={"HX_CA_BUNDLE": str(bundle)})
580
599
  term.submit("hello")
@@ -654,6 +673,64 @@ def test_signing_in_from_the_tui(hx: HX, claude: FakeClaude) -> None:
654
673
  assert json.loads((claude.state / "auth.json").read_text())["loggedIn"] is True
655
674
 
656
675
 
676
+ WRONG_PURPOSE_HINT = (
677
+ "Your network intercepts TLS with a certificate that is not valid for servers. Ask its "
678
+ "administrator to fix that certificate or to exclude this service from inspection; until "
679
+ "then, HX_SSL_NO_VERIFY=1 turns verification off."
680
+ )
681
+
682
+
683
+ def test_a_network_that_breaks_sign_in_says_why_and_what_to_do(hx: HX, claude: FakeClaude) -> None:
684
+ """Behind TLS inspection whose certificate may not serve a TLS server, Claude Code's sign-in
685
+ fails after the code is pasted. The notice gives Claude Code's reason and the fix - not the
686
+ sign-in URL, which the dialog showed already - and not "add the CA", which is already
687
+ trusted. Once the network is fixed, signing in again lists the subscription's models; a
688
+ turn that meets the same network says the same."""
689
+ bundle = corporate_ca(hx)
690
+ (claude.state / "login_error").write_text("unsupported certificate purpose")
691
+ term = hx.tui(env={"HX_CA_BUNDLE": str(bundle)})
692
+ term.submit("/login claude-code")
693
+ term.wait_for("https://claude.example/oauth/authorize")
694
+ term.paste("good-code")
695
+ term.press("enter")
696
+ term.wait_for(lambda screen: "verificationoff." in "".join(screen.split()))
697
+ solid = "".join(term.text().split())
698
+ expected = "Sign-infailed:`claudeauthlogin`failed:unsupportedcertificatepurpose."
699
+ assert "".join(f"{expected}{WRONG_PURPOSE_HINT}".split()) in solid
700
+ assert "claude.example/oauth" not in solid
701
+ assert "Pastecode" not in solid
702
+ assert "holdstheCA" not in solid
703
+ # Wrapped, the notice keeps its colour and nothing else: its foreground's 102 once
704
+ # opened a bright green background under every line after the first.
705
+ notice = [y for y, row in enumerate(term.text().split("\n")) if "intercepts TLS" in row]
706
+ assert notice
707
+ with term._lock:
708
+ filled = {term.screen.buffer[notice[0]][x].bg for x in range(term.columns)}
709
+ assert filled == {"default"}
710
+ term.snapshot("sign-in refused by the network")
711
+
712
+ (claude.state / "login_error").unlink()
713
+ term.submit("/login claude-code")
714
+ term.wait_for("https://claude.example/oauth/authorize")
715
+ term.paste("good-code")
716
+ term.press("enter")
717
+ term.wait_for("Claude Code signed in (e2e@example.com, max plan)")
718
+ term.submit("/model")
719
+ term.paste("anthropic", newline="")
720
+ term.wait_for("claude-code/claude-sonnet-5")
721
+ term.snapshot("subscription models after signing in")
722
+ term.press("esc")
723
+
724
+ # Claude Code 2.1.289's words, measured behind such a proxy.
725
+ claude.use()
726
+ claude.script({"error": "API Error: Unable to connect to API (INVALID_PURPOSE)"})
727
+ result = hx.run("-p", "hello", env={"HX_CA_BUNDLE": str(bundle)})
728
+ assert result.returncode != 0
729
+ assert (
730
+ f"Claude Code: API Error: Unable to connect to API (INVALID_PURPOSE). {WRONG_PURPOSE_HINT}"
731
+ ) in result.stderr
732
+
733
+
657
734
  def test_a_subagent_gets_its_own_process_while_the_parent_waits(
658
735
  hx: HX, claude: FakeClaude, project: Path
659
736
  ) -> None:
@@ -77,10 +77,9 @@ def test_every_tag_has_a_changelog_section(hx: HX) -> None:
77
77
  def test_auth_reports_where_the_key_comes_from(hx: HX) -> None:
78
78
  """`hx auth` masks the key and says the environment overrides the saved file."""
79
79
  result = hx.run("auth", check=True)
80
- first = result.stdout.splitlines()[0]
81
- assert first.startswith("openrouter")
82
- assert "sk-or-…cafe" in first
83
- assert "environment (HX_OPENROUTER_API_KEY)" in first
80
+ (line,) = [line for line in result.stdout.splitlines() if line.startswith("openrouter")]
81
+ assert "sk-or-…cafe" in line
82
+ assert "environment (HX_OPENROUTER_API_KEY)" in line
84
83
  assert "e2e0000" not in result.stdout, "the key must never be printed whole"
85
84
  assert "tavily" in result.stdout
86
85
 
@@ -657,3 +657,33 @@ def test_unknown_command(hx: HX) -> None:
657
657
  term.settle()
658
658
  term.snapshot("unknown command")
659
659
  assert "Unknown command" in term.text() or "unknown command" in term.text()
660
+
661
+
662
+ SIGN_IN_ORDER = (
663
+ "OpenAI (ChatGPT Plus/Pro)",
664
+ "Claude (Pro/Max, via Claude Code)",
665
+ "OpenRouter API key",
666
+ "Devin (personal or Enterprise)",
667
+ )
668
+
669
+
670
+ def test_sign_in_offers_openai_claude_openrouter_devin(hx: HX) -> None:
671
+ """/login and `hx auth login` list the accounts in one order: OpenAI, Claude, OpenRouter,
672
+ Devin. Enter at the terminal's menu still picks OpenRouter, as it did when it came first."""
673
+ term = hx.tui()
674
+ term.submit("/login")
675
+ screen = term.wait_for("Devin (personal or Enterprise)")
676
+ rows = [row for row in screen.split("\n") if any(label in row for label in SIGN_IN_ORDER)]
677
+ assert [label for row in rows for label in SIGN_IN_ORDER if label in row] == list(SIGN_IN_ORDER)
678
+ assert "OpenAI" in rows[0] and "→" in rows[0], "the first account is the one selected"
679
+ term.snapshot("sign-in accounts")
680
+ term.press("esc")
681
+
682
+ menu = hx.tui("auth", "login", ready="Choice")
683
+ screen = menu.text()
684
+ numbered = [f"{index} {label}" for index, label in enumerate(SIGN_IN_ORDER, start=1)]
685
+ assert all(line in screen for line in numbered), screen
686
+ assert "Choice [3]:" in screen
687
+ menu.snapshot("hx auth login menu")
688
+ menu.press("enter")
689
+ menu.wait_for("OpenRouter API key")
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import asyncio
18
18
  import os
19
+ import re
19
20
  from collections.abc import Iterator
20
21
  from pathlib import Path
21
22
 
@@ -348,3 +349,76 @@ async def test_a_device_certificate_reaches_the_cli(
348
349
  assert "4417" in reply
349
350
  assert "api.anthropic.com" in mitm.intercepted
350
351
  assert "api.anthropic.com" not in mitm.rejected
352
+
353
+
354
+ WRONG_PURPOSE_HINT = "Your network intercepts TLS with a certificate that is not valid for servers."
355
+
356
+
357
+ async def test_an_interception_ca_of_the_wrong_purpose_names_the_fix(
358
+ hx_home: Path, tmp_path: Path, mitm: InterceptingProxy, monkeypatch: pytest.MonkeyPatch
359
+ ) -> None:
360
+ """Trusted, yet refused: the proxy signs through a CA that may not vouch for servers.
361
+ Adding the CA again changes nothing, so the hint must not say to."""
362
+ mitm.wrong_purpose = True
363
+ monkeypatch.setenv("HX_CA_BUNDLE", str(mitm.ca_file))
364
+ error, _ = await _tool_turn(tmp_path)
365
+ assert error is not None
366
+ assert "INVALID_PURPOSE" in error
367
+ assert WRONG_PURPOSE_HINT in error
368
+ assert "holds the CA" not in error
369
+ assert "api.anthropic.com" in mitm.rejected
370
+ assert "api.anthropic.com" not in mitm.intercepted
371
+
372
+
373
+ class _PastedCode:
374
+ """The sign-in dialog, with a code that gets as far as the token exchange: the
375
+ state is the one the CLI asked for, the code itself is made up."""
376
+
377
+ def __init__(self) -> None:
378
+ self.url = ""
379
+
380
+ def show_url(self, url: str, message: str) -> None:
381
+ self.url = url
382
+
383
+ async def prompt_paste(self, prompt: str) -> str:
384
+ state = re.search(r"state=([^&\s]+)", self.url)
385
+ assert state is not None, self.url
386
+ return f"made-up-code#{state.group(1)}"
387
+
388
+
389
+ @pytest.mark.parametrize("wrong_purpose", [True, False], ids=["wrong-purpose", "control"])
390
+ async def test_signing_in_behind_an_interception_ca_of_the_wrong_purpose(
391
+ tmp_path: Path, mitm: InterceptingProxy, monkeypatch: pytest.MonkeyPatch, wrong_purpose: bool
392
+ ) -> None:
393
+ """/login through such a network, as a user on Claude Code 2.1.289 met it: the code is
394
+ pasted and the token exchange is refused. The failure says why and what to do, without
395
+ the sign-in URL. The control - the same proxy, signing as it should - reaches the server,
396
+ which turns the made-up code away. Claude Code's state goes to a directory of the test's
397
+ own, so the machine's login is never touched; no browser opens."""
398
+ from hx.auth import claude_code as cc_auth
399
+
400
+ if cc_auth.find_cli() is None:
401
+ pytest.skip("Claude Code is not installed")
402
+ mitm.wrong_purpose = wrong_purpose
403
+ monkeypatch.setenv("HX_CA_BUNDLE", str(mitm.ca_file))
404
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude-config"))
405
+ no_browser = tmp_path / "bin"
406
+ no_browser.mkdir()
407
+ for opener in ("open", "xdg-open"):
408
+ (no_browser / opener).write_text("#!/bin/sh\nexit 0\n")
409
+ (no_browser / opener).chmod(0o755)
410
+ monkeypatch.setenv("PATH", f"{no_browser}{os.pathsep}{os.environ['PATH']}")
411
+
412
+ with pytest.raises(cc_auth.LoginFailed) as failed:
413
+ await cc_auth.login_interactive(_PastedCode())
414
+ message = str(failed.value)
415
+ assert "https://" not in message
416
+ assert "Paste code" not in message
417
+ if wrong_purpose:
418
+ assert message.startswith(
419
+ "`claude auth login` failed: unsupported certificate purpose. " + WRONG_PURPOSE_HINT
420
+ ), message
421
+ assert "platform.claude.com" in mitm.rejected
422
+ else:
423
+ assert message == "`claude auth login` failed: Request failed with status code 400"
424
+ assert "platform.claude.com" in mitm.intercepted
@@ -25,6 +25,9 @@ from dataclasses import dataclass, field
25
25
  from pathlib import Path
26
26
 
27
27
  import trustme
28
+ from cryptography import x509
29
+ from cryptography.hazmat.primitives import hashes
30
+ from cryptography.x509.oid import ExtendedKeyUsageOID
28
31
 
29
32
  HEADER_LIMIT = 64 * 1024
30
33
 
@@ -39,10 +42,15 @@ class InterceptingProxy:
39
42
  require_client_cert: bool = False
40
43
  """Refuse a client that does not present a certificate from :attr:`ca` -
41
44
  the device certificate a zero-trust network asks every machine for."""
45
+ wrong_purpose: bool = False
46
+ """Sign through an issuing CA under :attr:`ca` whose extended key usage leaves
47
+ out server authentication: the chain is trusted, and still refused, with
48
+ "unsupported certificate purpose". A real inspection appliance did this."""
42
49
  events: list[tuple[str, str]] = field(default_factory=list)
43
50
  """``("intercepted" | "rejected", host)`` in the order they happened."""
44
51
  port: int = 0
45
52
  _contexts: dict[str, ssl.SSLContext] = field(default_factory=dict)
53
+ _issuer: trustme.CA | None = None
46
54
  _loop: asyncio.AbstractEventLoop | None = None
47
55
  _server: asyncio.Server | None = None
48
56
  _thread: threading.Thread | None = None
@@ -128,7 +136,12 @@ class InterceptingProxy:
128
136
  context = self._contexts.get(host)
129
137
  if context is None:
130
138
  context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
131
- self.ca.issue_cert(host).configure_cert(context)
139
+ issuer = self.ca
140
+ if self.wrong_purpose:
141
+ if self._issuer is None:
142
+ self._issuer = _client_only_child(self.ca)
143
+ issuer = self._issuer
144
+ issuer.issue_cert(host).configure_cert(context)
132
145
  if self.require_client_cert:
133
146
  context.verify_mode = ssl.CERT_REQUIRED
134
147
  self.ca.configure_trust(context)
@@ -180,6 +193,33 @@ class InterceptingProxy:
180
193
  )
181
194
 
182
195
 
196
+ def _client_only_child(ca: trustme.CA) -> trustme.CA:
197
+ """A CA under ``ca`` that may only vouch for TLS clients.
198
+
199
+ trustme has no option for it, so its child CA is signed again with the
200
+ extension added; the key, names and identifiers stay, and so does every
201
+ certificate the child then issues.
202
+ """
203
+ child = ca.create_child_ca()
204
+ cert = child._certificate
205
+ builder = (
206
+ x509.CertificateBuilder()
207
+ .subject_name(cert.subject)
208
+ .issuer_name(cert.issuer)
209
+ .public_key(cert.public_key())
210
+ .serial_number(cert.serial_number)
211
+ .not_valid_before(cert.not_valid_before_utc)
212
+ .not_valid_after(cert.not_valid_after_utc)
213
+ )
214
+ for extension in cert.extensions:
215
+ builder = builder.add_extension(extension.value, extension.critical)
216
+ builder = builder.add_extension(
217
+ x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False
218
+ )
219
+ child._certificate = builder.sign(ca._private_key, hashes.SHA256())
220
+ return child
221
+
222
+
183
223
  def _upstream_context() -> ssl.SSLContext:
184
224
  import truststore
185
225