cli-login 0.4.0__tar.gz → 0.4.1__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 (133) hide show
  1. {cli_login-0.4.0 → cli_login-0.4.1}/PKG-INFO +26 -3
  2. {cli_login-0.4.0 → cli_login-0.4.1}/README.md +25 -2
  3. {cli_login-0.4.0 → cli_login-0.4.1}/docs/design.md +4 -1
  4. {cli_login-0.4.0 → cli_login-0.4.1}/docs/protocol.md +5 -0
  5. {cli_login-0.4.0 → cli_login-0.4.1}/features/remote_sign_in.feature +2 -0
  6. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/remote_sign_in_steps.py +15 -1
  7. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_agent_steps.py +16 -0
  8. {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_agent.feature +5 -0
  9. cli_login-0.4.1/project/events/2026-10-31T15-05-12.601Z__80377f48-3218-4a3d-9f7e-9c4db44de2f0.json +23 -0
  10. cli_login-0.4.1/project/events/2026-10-31T15-27-42.778Z__65bd906f-6801-4df1-ae7b-2112c3629b32.json +12 -0
  11. cli_login-0.4.1/project/events/2026-10-31T15-27-43.836Z__5da8b7b7-4903-4767-a638-3cb0d95c32a0.json +12 -0
  12. cli_login-0.4.1/project/issues/cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49.json +28 -0
  13. {cli_login-0.4.0 → cli_login-0.4.1}/pyproject.toml +1 -1
  14. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/__init__.py +1 -1
  15. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/handler_test.py +25 -0
  16. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/pages_test.py +44 -0
  17. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/remote.py +32 -2
  18. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/remote_test.py +54 -0
  19. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/agent.py +12 -5
  20. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/agent_test.py +27 -0
  21. {cli_login-0.4.0 → cli_login-0.4.1}/.claude/settings.json +0 -0
  22. {cli_login-0.4.0 → cli_login-0.4.1}/.cursorignore +0 -0
  23. {cli_login-0.4.0 → cli_login-0.4.1}/.github/workflows/release.yml +0 -0
  24. {cli_login-0.4.0 → cli_login-0.4.1}/.github/workflows/tests.yml +0 -0
  25. {cli_login-0.4.0 → cli_login-0.4.1}/.gitignore +0 -0
  26. {cli_login-0.4.0 → cli_login-0.4.1}/.kanbus.yml +0 -0
  27. {cli_login-0.4.0 → cli_login-0.4.1}/.vscode/settings.json +0 -0
  28. {cli_login-0.4.0 → cli_login-0.4.1}/AGENTS.md +0 -0
  29. {cli_login-0.4.0 → cli_login-0.4.1}/CONTRIBUTING_AGENT.md +0 -0
  30. {cli_login-0.4.0 → cli_login-0.4.1}/CONTRIBUTING_AGENT.template.md +0 -0
  31. {cli_login-0.4.0 → cli_login-0.4.1}/LICENSE +0 -0
  32. {cli_login-0.4.0 → cli_login-0.4.1}/features/environment.py +0 -0
  33. {cli_login-0.4.0 → cli_login-0.4.1}/features/locks.feature +0 -0
  34. {cli_login-0.4.0 → cli_login-0.4.1}/features/login_session.feature +0 -0
  35. {cli_login-0.4.0 → cli_login-0.4.1}/features/oauth_provider.feature +0 -0
  36. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/locks_steps.py +0 -0
  37. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/login_session_steps.py +0 -0
  38. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/oauth_provider_steps.py +0 -0
  39. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/store_common_steps.py +0 -0
  40. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_kernel_keyring_steps.py +0 -0
  41. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_macos_steps.py +0 -0
  42. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_secret_service_steps.py +0 -0
  43. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_selection_steps.py +0 -0
  44. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/stores_windows_steps.py +0 -0
  45. {cli_login-0.4.0 → cli_login-0.4.1}/features/steps/target_steps.py +0 -0
  46. {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_agent_windows.feature +0 -0
  47. {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_kernel_keyring.feature +0 -0
  48. {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_macos.feature +0 -0
  49. {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_secret_service.feature +0 -0
  50. {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_selection.feature +0 -0
  51. {cli_login-0.4.0 → cli_login-0.4.1}/features/stores_windows.feature +0 -0
  52. {cli_login-0.4.0 → cli_login-0.4.1}/features/target.feature +0 -0
  53. {cli_login-0.4.0 → cli_login-0.4.1}/infra/broker.yaml +0 -0
  54. {cli_login-0.4.0 → cli_login-0.4.1}/infra/broker_template_test.py +0 -0
  55. {cli_login-0.4.0 → cli_login-0.4.1}/project/AGENTS.md +0 -0
  56. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.293Z__b9260b7c-7ee0-4df4-a3e0-618bf6410f7f.json +0 -0
  57. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.309Z__8329b8bd-f933-4597-b6ef-11d3b9e83819.json +0 -0
  58. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.319Z__02962f12-cc16-413f-94b1-9ac250cfe43a.json +0 -0
  59. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.329Z__78d07504-8941-45fc-aa87-a883260179b6.json +0 -0
  60. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.339Z__e2e9e373-9425-47cc-ab2e-97bd17a904b3.json +0 -0
  61. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.350Z__811313ad-5bef-4918-a09b-0c6fbee06c27.json +0 -0
  62. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-52-21.363Z__92385224-7ace-40cd-9f1f-09f3a491c086.json +0 -0
  63. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T10-53-42.529Z__b801b764-9abb-4714-9c9a-57f98ec6b120.json +0 -0
  64. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-43-05.049Z__5f67cee7-ed11-4602-b91a-cc0dc054a000.json +0 -0
  65. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-43-05.062Z__447020e8-b028-4fbe-9207-869e9aff8ee6.json +0 -0
  66. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.436Z__48a79b75-ead6-4a2d-a31c-bb9cedbaaabc.json +0 -0
  67. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.454Z__222fa29e-fada-4e25-a64d-e259c99cbb24.json +0 -0
  68. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-54-51.463Z__0f87a247-a94e-4611-a7a5-0a80f16b50a0.json +0 -0
  69. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-56-05.842Z__3faa8c4c-7c22-41ab-b86c-2e12e622eb6a.json +0 -0
  70. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-56-05.852Z__9c5e7de9-0269-47d0-9dab-e58850e21ac9.json +0 -0
  71. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.140Z__284dc920-8b9e-47e3-b924-4233c908a54e.json +0 -0
  72. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.154Z__08903f6c-aab5-4ea7-877c-cc67ed9cf832.json +0 -0
  73. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.163Z__9d412c33-67bf-4bd4-83af-0c55e972f94e.json +0 -0
  74. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.173Z__ed2149f3-4c1f-43e4-b38d-0f9b295a0234.json +0 -0
  75. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.183Z__8cc06e66-99f6-4529-b312-a70cbaff1ca9.json +0 -0
  76. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-57-13.193Z__3ee6c0c7-52ca-40c3-b7d2-5e0950b0dce2.json +0 -0
  77. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T11-58-11.250Z__57d95af6-0bf0-4cc6-b610-82f4c40fbff1.json +0 -0
  78. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-00-38.672Z__b391bfe3-c3b0-4a9d-8839-1381a6577b91.json +0 -0
  79. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-00-38.686Z__153c3c9d-7b02-43af-8d50-888e8a9d965d.json +0 -0
  80. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-07-40.329Z__ba26d852-684d-4347-bae5-d347cb69fddd.json +0 -0
  81. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-07-40.337Z__602e30d8-8ef7-40f3-9fe6-505e735f3b89.json +0 -0
  82. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-10-43.690Z__c2398383-7bf7-437e-8b4c-7aba9633cfbc.json +0 -0
  83. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-10-44.027Z__0644b779-33ed-49c7-91dc-166760f42aae.json +0 -0
  84. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T12-28-43.772Z__f550a131-02c7-44c0-b76a-3694222d74a9.json +0 -0
  85. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T14-01-17.888Z__bf995622-7f63-4936-9891-4cebf7208cf5.json +0 -0
  86. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/2026-10-30T14-01-17.900Z__e9675700-922f-449e-8b9f-9779917ce373.json +0 -0
  87. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/AGENTS.md +0 -0
  88. {cli_login-0.4.0 → cli_login-0.4.1}/project/events/DO_NOT_EDIT +0 -0
  89. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/AGENTS.md +0 -0
  90. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/DO_NOT_EDIT +0 -0
  91. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-04de9a1b-0c3e-4589-b9b5-ae30cd3cdd18.json +0 -0
  92. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-1b4a091f-2bfc-405c-be6e-b787b4bb4696.json +0 -0
  93. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-31750ff1-8d69-4b03-b953-82da220c1a9f.json +0 -0
  94. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4.json +0 -0
  95. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-361d5c51-7951-4e46-9c57-a64cb6f8058f.json +0 -0
  96. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-85e67311-6ca7-4c57-9e44-4d414ebc618f.json +0 -0
  97. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-96ed2301-f038-4d76-84bb-b670184ab538.json +0 -0
  98. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-9a02d68d-d3ff-4dfd-a319-2c411f05fddd.json +0 -0
  99. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-cccc972a-7df2-4795-9f9b-d8805dfc142e.json +0 -0
  100. {cli_login-0.4.0 → cli_login-0.4.1}/project/issues/cli-login-ff76cbbc-d47b-49d5-91e1-4aa2ae15e99f.json +0 -0
  101. {cli_login-0.4.0 → cli_login-0.4.1}/project/wiki/index.md +0 -0
  102. {cli_login-0.4.0 → cli_login-0.4.1}/project/wiki/whats-next.md +0 -0
  103. {cli_login-0.4.0 → cli_login-0.4.1}/scripts/build_broker.py +0 -0
  104. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/__init___test.py +0 -0
  105. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/__init__.py +0 -0
  106. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/handler.py +0 -0
  107. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/pages.py +0 -0
  108. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/throttle.py +0 -0
  109. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/transactions.py +0 -0
  110. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/broker/transactions_test.py +0 -0
  111. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/concurrency_test.py +0 -0
  112. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/errors.py +0 -0
  113. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/errors_test.py +0 -0
  114. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/locks.py +0 -0
  115. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/locks_test.py +0 -0
  116. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/oauth.py +0 -0
  117. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/oauth_test.py +0 -0
  118. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/session.py +0 -0
  119. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/session_test.py +0 -0
  120. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/__init__.py +0 -0
  121. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/kernel_keyring.py +0 -0
  122. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/kernel_keyring_test.py +0 -0
  123. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/macos.py +0 -0
  124. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/macos_test.py +0 -0
  125. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/secret_service.py +0 -0
  126. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/secret_service_test.py +0 -0
  127. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/stores_test.py +0 -0
  128. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/windows.py +0 -0
  129. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/stores/windows_test.py +0 -0
  130. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/target.py +0 -0
  131. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/target_test.py +0 -0
  132. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/testing.py +0 -0
  133. {cli_login-0.4.0 → cli_login-0.4.1}/src/cli_login/testing_processes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-login
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Browser sign-in for CLIs without long-lived keys: PKCE, remote approval, secure storage and renewal.
5
5
  Author: Anthus AI
6
6
  License: MIT
@@ -222,6 +222,26 @@ Each product deployment runs one broker, built from `infra/broker.yaml`.
222
222
  The broker and the approval pages must run the same cli-login version. Products should pin an exact version, since
223
223
  releases before 1.0 may change behaviour.
224
224
 
225
+ ## The product's app client
226
+
227
+ cli-login creates no Cognito client; each product signs its CLI in with an app client it defines. Use these settings
228
+ unless the product has a reviewed reason not to:
229
+
230
+ | Setting | Default |
231
+ | --- | --- |
232
+ | OAuth flow | Authorization code only, with PKCE (S256) |
233
+ | Client secret | None (a public client) |
234
+ | Explicit auth flows | `ALLOW_REFRESH_TOKEN_AUTH` only |
235
+ | Refresh token | 7 days |
236
+ | Access and ID tokens | 60 minutes |
237
+ | Token revocation | On, so `logout()` can revoke the refresh token |
238
+ | Callbacks | Exact URLs: one fixed loopback port for local sign-in and `https://<product-domain>/cli/callback` for remote sign-in |
239
+
240
+ Seven days lets an agent on a cloud machine keep working for a week without a person signing in again, while a
241
+ lost or leaked refresh token stops working within the week. Access tokens stay short, so an API sees a revocation
242
+ within an hour. Cognito matches callbacks exactly, with no port wildcard, so the loopback port is fixed and
243
+ registered.
244
+
225
245
  ## Where sessions are stored
226
246
 
227
247
  Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
@@ -240,7 +260,10 @@ and tests). Any other value is refused.
240
260
  ### Cloud machines and containers
241
261
 
242
262
  A cloud machine usually has no desktop keyring, and Docker's default seccomp profile blocks the kernel keyring
243
- calls. Start the memory agent once per product, then sign in again:
263
+ calls. Start the memory agent once per product, then sign in again. The error message prints the exact command,
264
+ with the full path of the interpreter that has `cli-login` installed; use that one. A bare `python` cannot import
265
+ the library when a product installs it in a private environment (`pipx`, `uv tool`), so in these examples
266
+ `python` stands for that interpreter:
244
267
 
245
268
  ```bash
246
269
  python -m cli_login.stores.agent serve --namespace <product-namespace> --background
@@ -276,7 +299,7 @@ as with ssh-agent.
276
299
  requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
277
300
  remove it, and the CLI always prints the identity it signed in as.
278
301
  - **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
279
- token works until it expires or is revoked.
302
+ token works until it is revoked or expires (7 days with the default app client).
280
303
  - **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
281
304
  code is useless without the verifier.
282
305
  - **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
@@ -206,6 +206,26 @@ Each product deployment runs one broker, built from `infra/broker.yaml`.
206
206
  The broker and the approval pages must run the same cli-login version. Products should pin an exact version, since
207
207
  releases before 1.0 may change behaviour.
208
208
 
209
+ ## The product's app client
210
+
211
+ cli-login creates no Cognito client; each product signs its CLI in with an app client it defines. Use these settings
212
+ unless the product has a reviewed reason not to:
213
+
214
+ | Setting | Default |
215
+ | --- | --- |
216
+ | OAuth flow | Authorization code only, with PKCE (S256) |
217
+ | Client secret | None (a public client) |
218
+ | Explicit auth flows | `ALLOW_REFRESH_TOKEN_AUTH` only |
219
+ | Refresh token | 7 days |
220
+ | Access and ID tokens | 60 minutes |
221
+ | Token revocation | On, so `logout()` can revoke the refresh token |
222
+ | Callbacks | Exact URLs: one fixed loopback port for local sign-in and `https://<product-domain>/cli/callback` for remote sign-in |
223
+
224
+ Seven days lets an agent on a cloud machine keep working for a week without a person signing in again, while a
225
+ lost or leaked refresh token stops working within the week. Access tokens stay short, so an API sees a revocation
226
+ within an hour. Cognito matches callbacks exactly, with no port wildcard, so the loopback port is fixed and
227
+ registered.
228
+
209
229
  ## Where sessions are stored
210
230
 
211
231
  Sessions live only in a secure store, tried in this order. There is no plaintext fallback: if nothing is
@@ -224,7 +244,10 @@ and tests). Any other value is refused.
224
244
  ### Cloud machines and containers
225
245
 
226
246
  A cloud machine usually has no desktop keyring, and Docker's default seccomp profile blocks the kernel keyring
227
- calls. Start the memory agent once per product, then sign in again:
247
+ calls. Start the memory agent once per product, then sign in again. The error message prints the exact command,
248
+ with the full path of the interpreter that has `cli-login` installed; use that one. A bare `python` cannot import
249
+ the library when a product installs it in a private environment (`pipx`, `uv tool`), so in these examples
250
+ `python` stands for that interpreter:
228
251
 
229
252
  ```bash
230
253
  python -m cli_login.stores.agent serve --namespace <product-namespace> --background
@@ -260,7 +283,7 @@ as with ssh-agent.
260
283
  requester label, the matching code the approver must confirm, and `expected_email` narrow the risk. They do not
261
284
  remove it, and the CLI always prints the identity it signed in as.
262
285
  - **Tokens can leak.** They are short-lived and renewable, and logout revokes the refresh token. A stolen refresh
263
- token works until it expires or is revoked.
286
+ token works until it is revoked or expires (7 days with the default app client).
264
287
  - **The broker never holds a token.** It holds an authorization code until the right CLI collects it, and the
265
288
  code is useless without the verifier.
266
289
  - **Cognito only, for now.** The library targets Amazon Cognito user pools with the authorization-code flow and PKCE.
@@ -41,7 +41,7 @@ A product describes one **target**:
41
41
 
42
42
  The library does the rest:
43
43
 
44
- - **Local sign-in.** PKCE (S256), a loopback callback on a free port, state and nonce, and the token
44
+ - **Local sign-in.** PKCE (S256), a loopback callback on a fixed, registered port, state and nonce, and the token
45
45
  exchange. It checks each token's issuer, client, `token_use`, expiry and nonce.
46
46
  - **Remote sign-in.** It calls the broker's `/start`, prints the approval URL and matching code, polls `/poll`
47
47
  at the interval given, waits longer on `slow_down`, and exchanges the code (see the protocol below).
@@ -152,6 +152,9 @@ What this guarantees:
152
152
 
153
153
  ## A product's API
154
154
 
155
+ The product's app client defaults to a 7-day refresh token with 60-minute access and ID tokens, code flow with PKCE
156
+ and no secret; see [the README](../README.md#the-products-app-client).
157
+
155
158
  The CLI calls the product's API with `Authorization: Bearer <access token>`. The API verifies the token as it
156
159
  would for a browser session. A request that carries both a cookie and a bearer token is refused.
157
160
 
@@ -108,6 +108,11 @@ is sent as the OpenID `nonce`, and the ID token must carry it back or the login
108
108
  `enroll(tokens, state, enrollment)` then runs before anything is stored, and if it raises the tokens are discarded
109
109
  and the refresh token is revoked on a best-effort basis.
110
110
 
111
+ A `nonce_mismatch` stores nothing, tells the person to start the sign-in again, and is never retried with a different
112
+ expected nonce. Its error carries `diagnostics` that tell the cases apart without exposing anything: whether `enroll`
113
+ was given, whether the broker handed over a nonce, and eight-character SHA-256 tags of the transaction, the CLI's own
114
+ nonce, the nonce it expected and the nonce the ID token carried.
115
+
111
116
  A network failure during the exchange is **not** retried, because the code may already be spent. The CLI starts
112
117
  again.
113
118
 
@@ -56,6 +56,8 @@ Feature: Remote sign-in from another device
56
56
  When the CLI starts a remote sign-in
57
57
  And the person approves it but the sign-in provider answers tokens for a different nonce
58
58
  Then the CLI reports that the tokens were for a different request
59
+ And the report tells the person to start the sign-in again and that nothing was saved
60
+ And the report carries one-way tags that tell the cases apart and no nonce or token
59
61
  And no session is stored
60
62
 
61
63
  Scenario: Someone who reads the link in chat and approves first does not get the session
@@ -114,7 +114,7 @@ def run_cli(context, on_poll):
114
114
  try:
115
115
  remote_login(Target(), context.stored.append, http=product.http, out=context.printed.append,
116
116
  clock=lambda: product.now, sleep=sleep, host="cloud-box",
117
- expected_email=getattr(context, "expected_email", None))
117
+ expected_email=getattr(context, "expected_email", None), login_command="app login --remote example.test")
118
118
  except RemoteLoginError as error:
119
119
  context.error = error
120
120
 
@@ -289,6 +289,20 @@ def step_nonce(context):
289
289
  assert context.error.code == "nonce_mismatch"
290
290
 
291
291
 
292
+ @then("the report tells the person to start the sign-in again and that nothing was saved")
293
+ def step_nonce_again(context):
294
+ message = str(context.error)
295
+ assert "Start the sign-in again with `app login --remote example.test`." in message and "nothing was saved" in message
296
+
297
+
298
+ @then("the report carries one-way tags that tell the cases apart and no nonce or token")
299
+ def step_nonce_tags(context):
300
+ diagnostics = context.error.diagnostics
301
+ assert set(diagnostics) == {"enroll", "broker_nonce", "transaction", "own_nonce", "expected_nonce", "received_nonce"}
302
+ assert diagnostics["received_nonce"] != diagnostics["expected_nonce"]
303
+ assert "another" not in str(context.error) and getattr(context, "transaction", "no-transaction") not in str(context.error)
304
+
305
+
292
306
  @then("the page says the link is no longer valid")
293
307
  def step_gone(context):
294
308
  assert context.page.status == 410, context.page.status
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import shlex
2
3
  import shutil
3
4
  import socket
4
5
  import stat
@@ -219,3 +220,18 @@ def windows(context):
219
220
  raise AssertionError("expected refusal")
220
221
 
221
222
 
223
+
224
+
225
+ @then("the start-up advice names the running interpreter by its full path")
226
+ def advice_path(context):
227
+ words = shlex.split(agent.agent_command(NS, state_dir=context.state_dir))
228
+ assert words[0] == sys.executable and os.path.isabs(words[0]), words[0]
229
+
230
+
231
+ @then("pasting the start-up advice into a shell starts a running agent")
232
+ def advice_runs(context):
233
+ context.add_cleanup(agent.stop, NS, state_dir=context.state_dir)
234
+ done = subprocess.run(agent.agent_command(NS, state_dir=context.state_dir), shell=True, capture_output=True,
235
+ text=True, timeout=60, check=False)
236
+ assert done.returncode == 0, done.stderr
237
+ assert agent.status(NS, state_dir=context.state_dir) is not None
@@ -40,6 +40,11 @@ Feature: The memory agent
40
40
  Given no memory agent is running
41
41
  Then a client store is unavailable with guidance mentioning "agent serve"
42
42
 
43
+ Scenario: The start-up advice works when pasted, even where the library is installed in a private environment
44
+ Given no memory agent is running
45
+ Then the start-up advice names the running interpreter by its full path
46
+ And pasting the start-up advice into a shell starts a running agent
47
+
43
48
  Scenario: A second agent for the same product is refused
44
49
  Given a running memory agent for the namespace "example-product"
45
50
  Then starting another agent for the namespace "example-product" fails
@@ -0,0 +1,23 @@
1
+ {
2
+ "schema_version": 1,
3
+ "event_id": "80377f48-3218-4a3d-9f7e-9c4db44de2f0",
4
+ "issue_id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
5
+ "event_type": "issue_created",
6
+ "occurred_at": "2026-10-31T15:05:12.601Z",
7
+ "actor_id": "home",
8
+ "payload": {
9
+ "title": "Document a 7-day refresh token as the default app client setting",
10
+ "description": "Ryan (2026-10-31): one week (seven days) should be the default refresh-token lifetime for the product app clients that cli-login signs in with. Access and ID tokens stay at 60 minutes. cli-login creates no Cognito client, so the default is the documented app client setup in README.md and docs/design.md.\n\nDone when:\n- README.md has a section listing the app client settings: code flow, PKCE, no client secret, refresh 7 days, access and ID 60 minutes, token revocation on, and exact callbacks with a fixed loopback port.\n- The security-limits bullet names the 7-day window.\n- docs/design.md points to that section.",
11
+ "issue_type": "story",
12
+ "status": "open",
13
+ "priority": 2,
14
+ "assignee": null,
15
+ "parent": "cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4",
16
+ "labels": [],
17
+ "agent": {
18
+ "platform": "claude_code",
19
+ "model": "claude-opus-5-5",
20
+ "name": "Prospectus implementation"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "schema_version": 1,
3
+ "event_id": "65bd906f-6801-4df1-ae7b-2112c3629b32",
4
+ "issue_id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
5
+ "event_type": "state_transition",
6
+ "occurred_at": "2026-10-31T15:27:42.778Z",
7
+ "actor_id": "home",
8
+ "payload": {
9
+ "from_status": "open",
10
+ "to_status": "closed"
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "schema_version": 1,
3
+ "event_id": "5da8b7b7-4903-4767-a638-3cb0d95c32a0",
4
+ "issue_id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
5
+ "event_type": "state_transition",
6
+ "occurred_at": "2026-10-31T15:27:43.836Z",
7
+ "actor_id": "home",
8
+ "payload": {
9
+ "from_status": "open",
10
+ "to_status": "closed"
11
+ }
12
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "cli-login-79ca92d5-4534-4bea-bfe1-aaeaa5037b49",
3
+ "title": "Document a 7-day refresh token as the default app client setting",
4
+ "description": "Ryan (2026-10-31): one week (seven days) should be the default refresh-token lifetime for the product app clients that cli-login signs in with. Access and ID tokens stay at 60 minutes. cli-login creates no Cognito client, so the default is the documented app client setup in README.md and docs/design.md.\n\nDone when:\n- README.md has a section listing the app client settings: code flow, PKCE, no client secret, refresh 7 days, access and ID 60 minutes, token revocation on, and exact callbacks with a fixed loopback port.\n- The security-limits bullet names the 7-day window.\n- docs/design.md points to that section.",
5
+ "type": "story",
6
+ "status": "closed",
7
+ "priority": 2,
8
+ "assignee": null,
9
+ "creator": null,
10
+ "parent": "cli-login-333326d6-56fb-48cc-bee1-b2a349b145a4",
11
+ "labels": [],
12
+ "dependencies": [],
13
+ "comments": [],
14
+ "created_at": "2026-10-31T15:05:12.601696Z",
15
+ "updated_at": "2026-10-31T15:27:43.836828Z",
16
+ "closed_at": "2026-10-31T15:27:43.836802Z",
17
+ "agent": {
18
+ "platform": "claude_code",
19
+ "model": "claude-opus-5-5",
20
+ "name": "Prospectus implementation"
21
+ },
22
+ "right_now_summary": null,
23
+ "right_now_updated_at": null,
24
+ "custom": {
25
+ "project_label": "cli-login",
26
+ "source": "shared"
27
+ }
28
+ }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-login"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Browser sign-in for CLIs without long-lived keys: PKCE, remote approval, secure storage and renewal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,4 +1,4 @@
1
1
  """Browser sign-in for CLIs without long-lived keys. See README.md and docs/protocol.md."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
4
4
  PROTOCOL_VERSION = 1
@@ -332,3 +332,28 @@ def test_a_malformed_product_nonce_or_enrollment_is_refused_and_nothing_is_bound
332
332
  transaction = started(b)
333
333
  assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n"), **extra}) == {"ok": False, "error": "unavailable"}
334
334
  assert b.describe({"transaction": transaction})["ok"] is True
335
+
336
+
337
+ def test_a_second_bind_cannot_replace_the_nonce_or_enrollment_of_the_first():
338
+ b = broker()
339
+ transaction = started(b)
340
+ assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n1"), "nonce": "challenge-nonce-0001",
341
+ "enrollment": "proof-1"}) == {"ok": True}
342
+ assert b.bind({"transaction": transaction, "nonce_hash": sha256_hex("n2"), "nonce": "challenge-nonce-0002",
343
+ "enrollment": "proof-2"}) == {"ok": False, "error": "unavailable"}
344
+ item = b.store.get(transaction)
345
+ assert item["oidc_nonce"] == "challenge-nonce-0001" and item["enrollment"] == "proof-1" and item["nonce_hash"] == sha256_hex("n1")
346
+
347
+
348
+ def test_a_code_completed_for_one_transaction_is_never_handed_to_another_transactions_poll():
349
+ clock = Clock()
350
+ b = broker(clock)
351
+ first, second = started(b), started(b)
352
+ b.bind({"transaction": first, "nonce_hash": sha256_hex("n1"), "nonce": "challenge-nonce-0001", "enrollment": "proof-1"})
353
+ b.bind({"transaction": second, "nonce_hash": sha256_hex("n2")})
354
+ assert b.complete({"transaction": first, "nonce": "n2", "code": "code-1"}) == {"ok": False, "error": "unavailable"}
355
+ assert b.complete({"transaction": first, "nonce": "n1", "code": "code-1"}) == {"ok": True}
356
+ clock.now += 5
357
+ assert b.poll({"transaction": second, "poll_secret": SECRET}, "ip")[1]["error"] == "authorization_pending"
358
+ status, answer = b.poll({"transaction": first, "poll_secret": SECRET}, "ip")
359
+ assert status == 200 and answer["code"] == "code-1" and answer["nonce"] == "challenge-nonce-0001"
@@ -172,3 +172,47 @@ def test_when_prepare_fails_nothing_is_bound_and_the_link_still_works():
172
172
  def counter():
173
173
  tokens = iter(f"tok{n:02d}" for n in range(99))
174
174
  return lambda _n: next(tokens)
175
+
176
+
177
+ def post_approve(pages, transaction, csrf):
178
+ return pages.approve("POST", {}, {"t": transaction, "csrf": csrf, "action": "approve", "confirm": "yes"}, {CSRF_COOKIE: csrf})
179
+
180
+
181
+ def test_a_replayed_approval_cannot_change_the_nonce_the_first_approval_bound():
182
+ broker, _, transaction = world()
183
+ challenges = iter(["challenge-nonce-0001", "challenge-nonce-0002"])
184
+ pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
185
+ prepare=lambda t, described: (next(challenges), "sealed-proof"), token=counter())
186
+ csrf = cookie(pages.approve("GET", {"t": transaction}, {}, {}), CSRF_COOKIE)
187
+ first = post_approve(pages, transaction, csrf)
188
+ replay = post_approve(pages, transaction, csrf) # back button, a second tab or a double submit with the old cookie
189
+ assert first.status == 303 and replay.status == 410
190
+ assert cookie(replay, NONCE_COOKIE) is None and "Location" not in dict(replay.headers)
191
+ assert broker.store.get(transaction)["oidc_nonce"] == "challenge-nonce-0001"
192
+
193
+
194
+ def test_two_approvals_that_race_bind_one_nonce_and_only_that_one_reaches_the_provider():
195
+ import threading
196
+
197
+ broker, _, transaction = world()
198
+ meeting = threading.Barrier(2, timeout=5)
199
+ challenges, lock = iter(["challenge-nonce-0001", "challenge-nonce-0002"]), threading.Lock()
200
+
201
+ def prepare(t, described):
202
+ meeting.wait() # both requests have seen the transaction pending before either binds
203
+ with lock:
204
+ return next(challenges), "sealed-proof"
205
+
206
+ pages = ApprovalPages(broker.handle, authorize_endpoint=AUTHORIZE, product="Example", privileges="Act as you.",
207
+ prepare=prepare, token=lambda _n: "t" * 32)
208
+ csrf = cookie(pages.approve("GET", {"t": transaction}, {}, {}), CSRF_COOKIE)
209
+ answers = []
210
+ threads = [threading.Thread(target=lambda: answers.append(post_approve(pages, transaction, csrf))) for _ in range(2)]
211
+ for thread in threads:
212
+ thread.start()
213
+ for thread in threads:
214
+ thread.join()
215
+ assert sorted(page.status for page in answers) == [303, 410]
216
+ sent = next(page for page in answers if page.status == 303)
217
+ nonce = parse_qs(urlsplit(header(sent, "Location")).query)["nonce"][0]
218
+ assert broker.store.get(transaction)["oidc_nonce"] == nonce
@@ -30,9 +30,10 @@ from .errors import AuthError, OAuthError
30
30
  class RemoteLoginError(OAuthError):
31
31
  """A remote sign-in that did not finish. ``transient`` says whether trying again later may work."""
32
32
 
33
- def __init__(self, code: str, message: str, *, transient: bool = False):
33
+ def __init__(self, code: str, message: str, *, transient: bool = False, diagnostics: Mapping[str, str] | None = None):
34
34
  self.code = code
35
35
  self.transient = transient
36
+ self.diagnostics = dict(diagnostics or {})
36
37
  AuthError.__init__(self, message)
37
38
 
38
39
 
@@ -55,6 +56,15 @@ def default_http(method: str, url: str, *, json_body=None, form=None, timeout: f
55
56
  return status, body if isinstance(body, dict) else {}, {key.lower(): value for key, value in headers.items()}
56
57
 
57
58
 
59
+ def _fingerprint(value: object) -> str:
60
+ """A short, non-reversible tag for comparing two values in a report; the value itself is never shown."""
61
+ if value is None:
62
+ return "none"
63
+ if not isinstance(value, str):
64
+ return "invalid"
65
+ return hashlib.sha256(value.encode()).hexdigest()[:8]
66
+
67
+
58
68
  def _b64url(data: bytes) -> str:
59
69
  return base64.urlsafe_b64encode(data).decode().rstrip("=")
60
70
 
@@ -103,6 +113,7 @@ def remote_login(
103
113
  expected_email: str | None = None,
104
114
  enroll: Callable[[Mapping[str, Any], str, str | None], Any] | None = None,
105
115
  token=secrets.token_urlsafe,
116
+ login_command: str | None = None,
106
117
  ):
107
118
  """Sign in through the target's broker and return what ``store_token_set`` returns.
108
119
 
@@ -120,6 +131,11 @@ def remote_login(
120
131
  enrollment of each login (the Cyclotron lab enrolls each Cognito login at Prospectus). Only then does the CLI
121
132
  accept an OpenID nonce chosen by the product's approval page, which the broker hands over with the code;
122
133
  without ``enroll`` the CLI checks its own nonce and nothing else.
134
+
135
+ ``login_command`` is the command that starts this sign-in again; the nonce-mismatch error names it. That error
136
+ also carries ``diagnostics``: whether ``enroll`` was given, whether the broker handed over a nonce, and short
137
+ one-way tags of the transaction and of the nonces (never a token or a nonce), so a failure that does not repeat
138
+ can still be told apart from one that does.
123
139
  """
124
140
  broker = getattr(target, "broker_endpoint", None)
125
141
  if not broker:
@@ -207,7 +223,21 @@ def remote_login(
207
223
  claims = _claims(tokens.get("id_token"))
208
224
  expected_nonce = answer.get("nonce") if enroll is not None and isinstance(answer.get("nonce"), str) else nonce
209
225
  if claims.get("nonce") != expected_nonce:
210
- raise RemoteLoginError("nonce_mismatch", "The sign-in answered tokens for a different request; nothing was saved.")
226
+ diagnostics = {
227
+ "enroll": "on" if enroll is not None else "off",
228
+ "broker_nonce": "none" if answer.get("nonce") is None else "handed",
229
+ "transaction": _fingerprint(transaction),
230
+ "own_nonce": _fingerprint(nonce),
231
+ "expected_nonce": _fingerprint(expected_nonce),
232
+ "received_nonce": _fingerprint(claims.get("nonce")),
233
+ }
234
+ again = f"Start the sign-in again with `{login_command}`." if login_command else "Start the sign-in again."
235
+ raise RemoteLoginError(
236
+ "nonce_mismatch",
237
+ f"The sign-in answered tokens for a different request; nothing was saved. {again} "
238
+ f"If it repeats, report these tags (one-way, no secrets): {' '.join(f'{k}={v}' for k, v in diagnostics.items())}.",
239
+ diagnostics=diagnostics,
240
+ )
211
241
  email = claims.get("email") if isinstance(claims.get("email"), str) else None
212
242
  if expected_email is not None and (email or "").casefold() != expected_email.casefold():
213
243
  raise RemoteLoginError("unexpected_identity", "Someone other than the expected person approved this sign-in; nothing was saved.")
@@ -354,3 +354,57 @@ def test_a_refused_enrollment_stores_nothing():
354
354
  remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
355
355
  sleep=world.sleep, host="cloud-box", enroll=enrolling(world, calls, fail=True))
356
356
  assert raised.value.code == "enrollment_refused" and stored == []
357
+
358
+
359
+ def test_a_nonce_mismatch_says_to_start_again_and_reports_one_way_tags_without_any_nonce():
360
+ world, stored = World(), []
361
+ original = world.http
362
+
363
+ def swapped(method, url, **kwargs):
364
+ status, body, headers = original(method, url, **kwargs)
365
+ if url == TOKEN and status == 200:
366
+ body["id_token"] = jwt({"nonce": "someone-elses-nonce"})
367
+ return status, body, headers
368
+
369
+ world.http = swapped
370
+ with pytest.raises(RemoteLoginError) as raised:
371
+ remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock, sleep=world.sleep,
372
+ host="cloud-box", login_command="app login --remote example.test")
373
+ error = raised.value
374
+ assert error.code == "nonce_mismatch" and stored == []
375
+ assert "nothing was saved" in str(error) and "Start the sign-in again with `app login --remote example.test`." in str(error)
376
+ tag = hashlib.sha256(b"someone-elses-nonce").hexdigest()[:8]
377
+ assert error.diagnostics["received_nonce"] == tag and error.diagnostics["enroll"] == "off"
378
+ assert error.diagnostics["broker_nonce"] == "none" and error.diagnostics["expected_nonce"] == error.diagnostics["own_nonce"]
379
+ assert error.diagnostics["transaction"] == hashlib.sha256(world.transaction.encode()).hexdigest()[:8]
380
+ assert "someone-elses-nonce" not in str(error) and world.transaction not in str(error)
381
+ assert all(len(value) <= 8 for key, value in error.diagnostics.items() if key.endswith("nonce") or key == "transaction")
382
+
383
+
384
+ def test_the_diagnostics_tell_a_page_that_used_the_product_nonce_from_a_cli_that_did_not_enroll():
385
+ world, stored = World(product_nonce="challenge-nonce-0001"), []
386
+ with pytest.raises(RemoteLoginError) as raised:
387
+ remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
388
+ sleep=world.sleep, host="cloud-box")
389
+ diagnostics = raised.value.diagnostics
390
+ assert diagnostics["enroll"] == "off" and diagnostics["broker_nonce"] == "handed"
391
+ assert diagnostics["received_nonce"] == hashlib.sha256(b"challenge-nonce-0001").hexdigest()[:8] != diagnostics["expected_nonce"]
392
+ assert stored == [] and "Start the sign-in again." in str(raised.value)
393
+
394
+
395
+ def test_an_enrolling_cli_that_gets_the_other_nonce_refuses_and_never_retries_with_it():
396
+ world, stored, calls = World(product_nonce="challenge-nonce-0001"), [], []
397
+ original = world.http
398
+
399
+ def swapped(method, url, **kwargs):
400
+ status, body, headers = original(method, url, **kwargs)
401
+ if url == TOKEN and status == 200:
402
+ body["id_token"] = jwt({"nonce": "own-nonce-not-the-products"})
403
+ return status, body, headers
404
+
405
+ world.http = swapped
406
+ with pytest.raises(RemoteLoginError) as raised:
407
+ remote_login(Target(), stored.append, http=world.http, out=world.printed.append, clock=world.clock,
408
+ sleep=world.sleep, host="cloud-box", enroll=enrolling(world, calls))
409
+ assert raised.value.code == "nonce_mismatch" and raised.value.diagnostics["enroll"] == "on"
410
+ assert raised.value.diagnostics["broker_nonce"] == "handed" and stored == [] and calls == [] and len(world.exchanges) == 1
@@ -28,6 +28,7 @@ import ctypes
28
28
  import json
29
29
  import os
30
30
  import re
31
+ import shlex
31
32
  import signal
32
33
  import socket
33
34
  import stat
@@ -60,13 +61,19 @@ def _require_unix() -> None:
60
61
 
61
62
 
62
63
  def agent_command(namespace: str, *, state_dir: Path | str | None = None, socket_path: Path | str | None = None) -> str:
63
- """The command that starts this namespace's agent, for guidance in error messages."""
64
- command = f"{os.path.basename(sys.executable) or 'python'} -m cli_login.stores.agent serve --namespace {namespace}"
64
+ """The command that starts this namespace's agent, for guidance in error messages.
65
+
66
+ It names the interpreter that is running right now by its full path, because the library may be installed
67
+ only in a private environment (``pipx``, ``uv tool``) where a bare ``python`` cannot import it. Every part
68
+ is shell-quoted, so the line works when pasted.
69
+ """
70
+ parts = [sys.executable or "python", "-m", "cli_login.stores.agent", "serve", "--namespace", str(namespace)]
65
71
  if state_dir is not None:
66
- command += f" --state-dir {state_dir}"
72
+ parts += ["--state-dir", str(state_dir)]
67
73
  if socket_path is not None:
68
- command += f" --socket {socket_path}"
69
- return command + " --background"
74
+ parts += ["--socket", str(socket_path)]
75
+ parts.append("--background")
76
+ return " ".join(shlex.quote(part) for part in parts)
70
77
 
71
78
 
72
79
  def socket_path_for(namespace: str, state_dir: Path | str | None = None, environ=None) -> Path:
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import shlex
2
3
  import shutil
3
4
  import socket
4
5
  import stat
@@ -203,6 +204,32 @@ def test_the_command_line_serves_in_the_background_reports_and_stops(state):
203
204
  assert run("status").returncode == 1
204
205
 
205
206
 
207
+ def test_the_advice_names_the_running_interpreter_by_full_path():
208
+ words = shlex.split(agent.agent_command("ns"))
209
+ assert words[0] == sys.executable and os.path.isabs(words[0])
210
+ assert words[1:] == ["-m", "cli_login.stores.agent", "serve", "--namespace", "ns", "--background"]
211
+
212
+
213
+ def test_the_advice_quotes_paths_with_spaces(monkeypatch):
214
+ monkeypatch.setattr(sys, "executable", "/opt/my tools/venv/bin/python")
215
+ command = agent.agent_command("ns", state_dir="/tmp/a b", socket_path="/tmp/it's/agent.sock")
216
+ assert shlex.split(command) == [
217
+ "/opt/my tools/venv/bin/python", "-m", "cli_login.stores.agent", "serve", "--namespace", "ns",
218
+ "--state-dir", "/tmp/a b", "--socket", "/tmp/it's/agent.sock", "--background",
219
+ ]
220
+
221
+
222
+ @unix
223
+ def test_the_advice_works_when_pasted_into_a_shell(state):
224
+ command = agent.agent_command("ns", state_dir=state)
225
+ try:
226
+ started = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=60, check=False)
227
+ assert started.returncode == 0, started.stderr
228
+ assert agent.status("ns", state_dir=state) is not None
229
+ finally:
230
+ agent.stop("ns", state_dir=state)
231
+
232
+
206
233
  def test_windows_is_not_supported(monkeypatch, tmp_path):
207
234
  monkeypatch.setattr(sys, "platform", "win32")
208
235
  with pytest.raises(AuthError, match="Windows"):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes