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.
- {hx_cli-0.2.14 → hx_cli-0.2.15}/CHANGELOG.md +24 -1
- {hx_cli-0.2.14 → hx_cli-0.2.15}/PKG-INFO +8 -2
- {hx_cli-0.2.14 → hx_cli-0.2.15}/README.md +7 -1
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/claude_code.py +18 -2
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/cli.py +3 -1
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/net.py +60 -7
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/registry.py +15 -14
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/ansi.py +7 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/fake_claude.py +8 -2
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_claude_code.py +81 -4
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_cli.py +3 -4
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_commands.py +30 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_claude_code.py +74 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/tls_proxy.py +41 -1
- {hx_cli-0.2.14 → hx_cli-0.2.15}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/.gitignore +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/install.sh +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/pyproject.toml +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/config.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/loop.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/docs.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/git.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/keys.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/paths.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code_catalogue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/trace.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/commands.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/runtime.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/conftest.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/e2e/test_tools.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.15}/tests/test_live_tavily.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
365
|
-
raise LoginFailed(f"`claude auth login` failed: {
|
|
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 [
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
81
|
-
assert
|
|
82
|
-
assert "
|
|
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
|
|
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
|
|