firekeep-client 1.8.2__tar.gz → 1.8.3__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 (181) hide show
  1. {firekeep_client-1.8.2/firekeep_client.egg-info → firekeep_client-1.8.3}/PKG-INFO +1 -1
  2. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/__init__.py +1 -1
  3. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/join.py +83 -0
  4. {firekeep_client-1.8.2 → firekeep_client-1.8.3/firekeep_client.egg-info}/PKG-INFO +1 -1
  5. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/pyproject.toml +1 -1
  6. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_join.py +103 -0
  7. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_package.py +1 -1
  8. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/LICENSE +0 -0
  9. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/NOTICE +0 -0
  10. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/README.md +0 -0
  11. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/__init__.py +0 -0
  12. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/base.py +0 -0
  13. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/claude.py +0 -0
  14. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/claude_desktop.py +0 -0
  15. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/codex.py +0 -0
  16. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/generic.py +0 -0
  17. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/kiro.py +0 -0
  18. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/opencode.py +0 -0
  19. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/adapters/pi.py +0 -0
  20. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/autoupdate.py +0 -0
  21. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/background.py +0 -0
  22. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/backups.py +0 -0
  23. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/childenv.py +0 -0
  24. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/cli.py +0 -0
  25. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/config_write.py +0 -0
  26. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/connect.py +0 -0
  27. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/contract/__init__.py +0 -0
  28. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/contract/matrix.py +0 -0
  29. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/decision/__init__.py +0 -0
  30. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/decision/board.py +0 -0
  31. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/decision/server.py +0 -0
  32. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/dexes.py +0 -0
  33. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/docdexsync.py +0 -0
  34. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/gateway.py +0 -0
  35. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooklog.py +0 -0
  36. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/__init__.py +0 -0
  37. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/__main__.py +0 -0
  38. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/_git.py +0 -0
  39. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/_mcp.py +0 -0
  40. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/destructive.py +0 -0
  41. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/post_tool.py +0 -0
  42. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/pre_tool.py +0 -0
  43. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/precompact.py +0 -0
  44. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/prompt.py +0 -0
  45. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/runbooks.py +0 -0
  46. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/session_end.py +0 -0
  47. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/session_start.py +0 -0
  48. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/hooks/stop.py +0 -0
  49. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/jobobject.py +0 -0
  50. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/joincode.py +0 -0
  51. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/maildexsync.py +0 -0
  52. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/migrate.py +0 -0
  53. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/nightshift.py +0 -0
  54. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/nightshiftdrain.py +0 -0
  55. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/pathenv.py +0 -0
  56. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/promptrecall.py +0 -0
  57. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/report.py +0 -0
  58. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/resolver.py +0 -0
  59. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/serverinit.py +0 -0
  60. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/serverupdate.py +0 -0
  61. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/shim.py +0 -0
  62. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/sidecar.py +0 -0
  63. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/signing.py +0 -0
  64. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/state.py +0 -0
  65. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/stdio.py +0 -0
  66. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/symdexindex.py +0 -0
  67. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/transport.py +0 -0
  68. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/updater.py +0 -0
  69. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/wizard.py +0 -0
  70. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client/worktree_snapshot.py +0 -0
  71. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client.egg-info/SOURCES.txt +0 -0
  72. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client.egg-info/dependency_links.txt +0 -0
  73. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client.egg-info/entry_points.txt +0 -0
  74. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client.egg-info/requires.txt +0 -0
  75. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/firekeep_client.egg-info/top_level.txt +0 -0
  76. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/setup.cfg +0 -0
  77. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_attribution_headers.py +0 -0
  78. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_autoupdate.py +0 -0
  79. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_background.py +0 -0
  80. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bash_layer_retired.py +0 -0
  81. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_consent_pty.py +0 -0
  82. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_docdex.py +0 -0
  83. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_maildex.py +0 -0
  84. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_ps1.py +0 -0
  85. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_reinstall.py +0 -0
  86. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_sh.py +0 -0
  87. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_symdex.py +0 -0
  88. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_bootstrap_venv_provisioning.py +0 -0
  89. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli.py +0 -0
  90. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_backup.py +0 -0
  91. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_dex.py +0 -0
  92. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_docdex.py +0 -0
  93. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_doctor.py +0 -0
  94. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_doctor_generic.py +0 -0
  95. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_doctor_report.py +0 -0
  96. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_entry.py +0 -0
  97. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_generic_install.py +0 -0
  98. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_hands.py +0 -0
  99. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_install.py +0 -0
  100. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_maildex.py +0 -0
  101. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_onboarding.py +0 -0
  102. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_personal.py +0 -0
  103. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_uninstall.py +0 -0
  104. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_uninstall_generic.py +0 -0
  105. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_update.py +0 -0
  106. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_cli_whoami.py +0 -0
  107. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_config_migration.py +0 -0
  108. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_connect.py +0 -0
  109. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_decision_board_html.py +0 -0
  110. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_decision_bypass.py +0 -0
  111. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_decision_poll_ceiling.py +0 -0
  112. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_decision_registration.py +0 -0
  113. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_decision_server.py +0 -0
  114. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_dexes.py +0 -0
  115. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_docdexsync.py +0 -0
  116. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_docs_reference_client_kit.py +0 -0
  117. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_doctor_credential_expiry.py +0 -0
  118. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_doctor_embeddings.py +0 -0
  119. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_doctor_no_server.py +0 -0
  120. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_e2e_bootstrap.py +0 -0
  121. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_gateway.py +0 -0
  122. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_gateway_child_env.py +0 -0
  123. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_gateway_schema_slimming.py +0 -0
  124. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_gateway_toolset.py +0 -0
  125. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_generic_config.py +0 -0
  126. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_hooklog.py +0 -0
  127. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_import_boundary.py +0 -0
  128. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_init_self_enroll.py +0 -0
  129. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_init_stale_bundle.py +0 -0
  130. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_install_skill_contract.py +0 -0
  131. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_instruction_budget.py +0 -0
  132. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_jobobject.py +0 -0
  133. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_join_config_write.py +0 -0
  134. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_joincode.py +0 -0
  135. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_kit_smoke.py +0 -0
  136. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_maildexsync.py +0 -0
  137. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_make_release.py +0 -0
  138. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_memory_instructions.py +0 -0
  139. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_migration_entrypoints.py +0 -0
  140. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_nightshift.py +0 -0
  141. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_nightshiftdrain.py +0 -0
  142. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_pathenv.py +0 -0
  143. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_promptrecall.py +0 -0
  144. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_bootstrap_enums.py +0 -0
  145. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_capture.py +0 -0
  146. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_consent.py +0 -0
  147. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_consent_surfaces.py +0 -0
  148. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_flush_points.py +0 -0
  149. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_integration.py +0 -0
  150. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_milestones.py +0 -0
  151. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_schema.py +0 -0
  152. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_report_spool.py +0 -0
  153. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_resolver.py +0 -0
  154. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_resolver_bypass.py +0 -0
  155. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_resolver_single_connection.py +0 -0
  156. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_runbook_match_parity.py +0 -0
  157. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_runbook_state.py +0 -0
  158. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_scratch_ttl.py +0 -0
  159. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_serverinit.py +0 -0
  160. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_serverupdate.py +0 -0
  161. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_shim_bridge.py +0 -0
  162. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_shim_bypass.py +0 -0
  163. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_shim_eof_exit.py +0 -0
  164. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_shim_fail_loud.py +0 -0
  165. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_shim_identity.py +0 -0
  166. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_shim_recovery.py +0 -0
  167. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_shim_skeleton.py +0 -0
  168. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_sidecar.py +0 -0
  169. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_sidecar_robustness.py +0 -0
  170. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_signing.py +0 -0
  171. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_state.py +0 -0
  172. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_state_session_report.py +0 -0
  173. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_stdio_encoding.py +0 -0
  174. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_symdexindex.py +0 -0
  175. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_transport.py +0 -0
  176. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_transport_sse.py +0 -0
  177. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_twin_removed.py +0 -0
  178. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_updater.py +0 -0
  179. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_wizard.py +0 -0
  180. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_wizard_generic.py +0 -0
  181. {firekeep_client-1.8.2 → firekeep_client-1.8.3}/tests/test_worktree_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firekeep-client
3
- Version: 1.8.2
3
+ Version: 1.8.3
4
4
  Summary: Client for Firekeep, the self-hosted operating layer for connected AI agents.
5
5
  License-Expression: LicenseRef-Firekeep-BUSL-1.1
6
6
  Project-URL: Homepage, https://firekeep.ai
@@ -9,4 +9,4 @@ versions without a verdict (`_check_versions`) and judges staleness against the
9
9
  release manifest instead (`_check_client_version`).
10
10
  """
11
11
 
12
- __version__ = "1.8.2"
12
+ __version__ = "1.8.3"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import configparser
6
7
  import hashlib
7
8
  import json
8
9
  import os
@@ -91,6 +92,19 @@ def _new_pending(code: JoinCode) -> dict:
91
92
  def _prepare_pending(code: JoinCode, *, resume: bool, config_path: Path) -> dict:
92
93
  path = pending_path(config_path)
93
94
  sweep_pending(config_path)
95
+ if not resume and path.exists():
96
+ # Re-running the SAME code after a crash or a lost response is the
97
+ # documented shape of "it failed, run it again" -- and reusing the
98
+ # pending secret is exactly the safe thing to do: the server answers
99
+ # `replay` if that secret was already registered, and spends nothing
100
+ # twice. Only a pending enrollment for a DIFFERENT code still refuses.
101
+ try:
102
+ existing = json.loads(path.read_text(encoding="utf-8"))
103
+ except (OSError, json.JSONDecodeError):
104
+ existing = {}
105
+ if existing.get("ticket_id") == code.tid:
106
+ print("resuming the pending enrollment for this join code")
107
+ resume = True
94
108
  if resume:
95
109
  try:
96
110
  pending = json.loads(path.read_text(encoding="utf-8"))
@@ -230,6 +244,10 @@ def _server_values(
230
244
  expires = response.get("credential_expires_at")
231
245
  if expires:
232
246
  server["credential_expires_at"] = str(expires)
247
+ # Which code produced this credential. Lets a re-run of the same install
248
+ # command recognise "already joined" locally instead of spending a second
249
+ # attempt on a single-use ticket and failing with "already redeemed".
250
+ server["join_ticket"] = code.tid
233
251
  if response["kind"] == "ports":
234
252
  server["host"] = "127.0.0.1" if code.transport == "tunnel" else str(response["host"])
235
253
  else:
@@ -239,6 +257,46 @@ def _server_values(
239
257
  return server
240
258
 
241
259
 
260
+ _USED_CREDENTIAL = re.compile(r"issuing credential ([0-9a-f]{16})")
261
+
262
+
263
+ def _joined_credential(config_path: Path) -> tuple[str, str]:
264
+ """(credential_id, join_ticket) of the connection this machine already holds.
265
+
266
+ Empty strings when there is no usable connection (no api_key). A RAW read,
267
+ never resolver.load_config(): that migrates and rewrites the file, and a
268
+ join that ends up refused must leave the config exactly as it found it."""
269
+ cfg = configparser.ConfigParser(interpolation=None)
270
+ try:
271
+ cfg.read_string(config_path.read_text(encoding="utf-8"))
272
+ except (OSError, UnicodeDecodeError, configparser.Error):
273
+ return "", ""
274
+ if not cfg.get("server", "api_key", fallback="").strip():
275
+ return "", ""
276
+ return (
277
+ cfg.get("server", "credential_id", fallback="").strip(),
278
+ cfg.get("server", "join_ticket", fallback="").strip(),
279
+ )
280
+
281
+
282
+ def _already_joined(config_path: Path, credential_id: str) -> int:
283
+ """Report an install re-run against a code this machine already redeemed."""
284
+ try:
285
+ pending_path(config_path).unlink()
286
+ except OSError:
287
+ pass
288
+ print(
289
+ f"this machine already joined with this code (credential {credential_id}); "
290
+ "nothing to do."
291
+ )
292
+ from firekeep_client.cli import run_doctor
293
+
294
+ rows = run_doctor()
295
+ for name, status, detail in rows:
296
+ print(f"[{status.upper()}] {name}: {detail}")
297
+ return 1 if any(status == "fail" for _, status, _ in rows) else 0
298
+
299
+
242
300
  def join(
243
301
  pasted_code: str,
244
302
  *,
@@ -260,6 +318,14 @@ def join(
260
318
  raise JoinError(str(exc), exit_code=2) from exc
261
319
 
262
320
  config_path = resolver._config_path()
321
+ joined_credential, joined_ticket = _joined_credential(config_path)
322
+ if (
323
+ code.purpose == "device"
324
+ and not force
325
+ and joined_credential
326
+ and joined_ticket == code.tid
327
+ ):
328
+ return _already_joined(config_path, joined_credential)
263
329
  pending = (
264
330
  _prepare_pending(code, resume=resume, config_path=config_path)
265
331
  if code.purpose == "device"
@@ -333,6 +399,23 @@ def join(
333
399
  pending = _new_pending(code)
334
400
  _write_private_json(pending_path(config_path), pending)
335
401
  continue
402
+ # A config written before `join_ticket` existed cannot be matched
403
+ # locally; the server's "already redeemed" names the credential the
404
+ # code issued, and if that is the one this machine holds, the
405
+ # earlier run succeeded -- this is a re-run, not a stolen code.
406
+ used = _USED_CREDENTIAL.search(str(exc)) if exc.status == 409 else None
407
+ if used and joined_credential and used.group(1) == joined_credential:
408
+ return _already_joined(config_path, joined_credential)
409
+ # A JSON 4xx is a definitive refusal: the enrollment script registers
410
+ # the credential only on success, so the pending secret was never
411
+ # stored and keeping it would only block the next join for 24h.
412
+ # Transport failures and 5xx keep it -- the response may have been
413
+ # lost after the server registered it, which is what --resume is for.
414
+ if exc.status is not None and 400 <= exc.status < 500 and exc.response_is_json:
415
+ try:
416
+ pending_path(config_path).unlink()
417
+ except OSError:
418
+ pass
336
419
  if exc.status == 404 and not exc.response_is_json:
337
420
  raise JoinError(
338
421
  "this server predates client enrollment (no POST /enroll). On "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firekeep-client
3
- Version: 1.8.2
3
+ Version: 1.8.3
4
4
  Summary: Client for Firekeep, the self-hosted operating layer for connected AI agents.
5
5
  License-Expression: LicenseRef-Firekeep-BUSL-1.1
6
6
  Project-URL: Homepage, https://firekeep.ai
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "firekeep-client"
7
- version = "1.8.2"
7
+ version = "1.8.3"
8
8
  description = "Client for Firekeep, the self-hosted operating layer for connected AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
4
5
  import os
5
6
  import stat
@@ -189,6 +190,108 @@ def test_enrollment_error_exit_codes(
189
190
  assert "predates client enrollment" in str(error.value)
190
191
 
191
192
 
193
+ def _throw(exc):
194
+ def raiser(*a, **k):
195
+ raise exc
196
+ return raiser
197
+
198
+
199
+ def test_rerun_of_the_same_code_is_a_local_no_op(home, monkeypatch, capsys):
200
+ """The install one-liner re-run on a machine it already joined must succeed
201
+ without spending a second attempt on the single-use ticket."""
202
+ monkeypatch.setattr(J, "get_json", lambda *a, **k: {"ok": True})
203
+ monkeypatch.setattr(J, "post_json", lambda *a, **k: response())
204
+ code = encode(payload())
205
+ assert J.join(code) == 0
206
+ assert "join_ticket" in home.read_text(encoding="utf-8")
207
+ before = home.read_text(encoding="utf-8")
208
+
209
+ posts = []
210
+ monkeypatch.setattr(J, "get_json", lambda *a, **k: posts.append(a))
211
+ monkeypatch.setattr(J, "post_json", lambda *a, **k: posts.append(a))
212
+ capsys.readouterr()
213
+ assert J.join(code) == 0
214
+ assert posts == []
215
+ assert "already joined with this code" in capsys.readouterr().out
216
+ assert home.read_text(encoding="utf-8") == before
217
+ assert not J.pending_path(home).exists()
218
+
219
+
220
+ def test_rerun_against_a_legacy_config_recognises_its_own_credential(home, monkeypatch, capsys):
221
+ """A config written before join_ticket existed: the server's 'already
222
+ redeemed' names the credential, and it is the one this machine holds."""
223
+ home.parent.mkdir(parents=True)
224
+ home.write_text(
225
+ "[identity]\nagent_id = bob\n\n[server]\nkind = ports\nhost = firekeep.example\n"
226
+ f"api_key = nxs_{'a' * 64}\ncredential_id = {'1' * 16}\n",
227
+ encoding="utf-8",
228
+ )
229
+ monkeypatch.setattr(J, "get_json", lambda *a, **k: {"ok": True})
230
+ monkeypatch.setattr(J, "post_json", _throw(TransportError(
231
+ '409 {"detail":"this join code was already redeemed at 2026-11-09T00:09:30 by a '
232
+ f'different device, issuing credential {"1" * 16}. Join codes are single-use."}}',
233
+ status=409, response_is_json=True,
234
+ )))
235
+ assert J.join(encode(payload())) == 0
236
+ assert "already joined with this code" in capsys.readouterr().out
237
+ assert not J.pending_path(home).exists()
238
+
239
+
240
+ def test_code_redeemed_by_another_credential_still_fails_and_cleans_up(home, monkeypatch):
241
+ home.parent.mkdir(parents=True)
242
+ home.write_text(
243
+ f"[server]\nkind = ports\napi_key = nxs_{'a' * 64}\ncredential_id = {'9' * 16}\n",
244
+ encoding="utf-8",
245
+ )
246
+ monkeypatch.setattr(J, "get_json", lambda *a, **k: {"ok": True})
247
+ monkeypatch.setattr(J, "post_json", _throw(TransportError(
248
+ f'409 {{"detail":"already redeemed ... issuing credential {"1" * 16}."}}',
249
+ status=409, response_is_json=True,
250
+ )))
251
+ with pytest.raises(J.JoinError, match="already redeemed") as error:
252
+ J.join(encode(payload()))
253
+ assert error.value.exit_code == 3
254
+ # Refused outright: the secret was never registered, so nothing to resume.
255
+ assert not J.pending_path(home).exists()
256
+
257
+
258
+ def test_transport_failure_keeps_pending_and_a_plain_rerun_resumes_it(home, monkeypatch, capsys):
259
+ """A lost response may mean the server DID register the secret, so the
260
+ pending file stays -- and re-running the same command (no --resume) reuses
261
+ it instead of refusing with 'a pending enrollment already exists'."""
262
+ monkeypatch.setattr(J, "get_json", lambda *a, **k: {"ok": True})
263
+ hashes = []
264
+
265
+ def lost(url, body, **kwargs):
266
+ hashes.append(body["credential_hash"])
267
+ raise TransportError("response lost")
268
+
269
+ monkeypatch.setattr(J, "post_json", lost)
270
+ with pytest.raises(J.JoinError):
271
+ J.join(encode(payload()))
272
+ assert J.pending_path(home).exists()
273
+
274
+ def replay(url, body, **kwargs):
275
+ hashes.append(body["credential_hash"])
276
+ return {**response(), "replay": True}
277
+
278
+ monkeypatch.setattr(J, "post_json", replay)
279
+ assert J.join(encode(payload())) == 0
280
+ assert hashes[0] == hashes[1]
281
+ assert "resuming the pending enrollment" in capsys.readouterr().out
282
+ assert not J.pending_path(home).exists()
283
+
284
+
285
+ def test_pending_for_a_different_code_still_refuses(home, monkeypatch):
286
+ monkeypatch.setattr(J, "get_json", lambda *a, **k: {"ok": True})
287
+ monkeypatch.setattr(J, "post_json", _throw(TransportError("response lost")))
288
+ with pytest.raises(J.JoinError):
289
+ J.join(encode(payload()))
290
+ other = encode(payload(q=base64.urlsafe_b64encode(bytes(range(1, 33))).decode().rstrip("=")))
291
+ with pytest.raises(J.JoinError, match="pending enrollment already exists"):
292
+ J.join(other)
293
+
294
+
192
295
  def test_tunnel_setup_failure_is_actionable_exit_7(home, monkeypatch):
193
296
  from firekeep_client import connect
194
297
 
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  def test_package_imports_and_exposes_frozen_version():
8
8
  import firekeep_client
9
9
 
10
- assert firekeep_client.__version__ == "1.8.2"
10
+ assert firekeep_client.__version__ == "1.8.3"
11
11
 
12
12
 
13
13
  def test_the_version_pins_agree():
File without changes
File without changes