firekeep-client 1.8.0__tar.gz → 1.8.2__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.0/firekeep_client.egg-info → firekeep_client-1.8.2}/PKG-INFO +4 -4
  2. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/README.md +3 -3
  3. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/__init__.py +1 -1
  4. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/childenv.py +3 -0
  5. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/cli.py +5 -2
  6. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/session_start.py +2 -0
  7. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/report.py +146 -10
  8. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/serverupdate.py +23 -0
  9. {firekeep_client-1.8.0 → firekeep_client-1.8.2/firekeep_client.egg-info}/PKG-INFO +4 -4
  10. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/SOURCES.txt +1 -0
  11. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/pyproject.toml +1 -1
  12. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_consent_pty.py +20 -5
  13. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_package.py +1 -1
  14. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_consent_surfaces.py +1 -1
  15. firekeep_client-1.8.2/tests/test_report_milestones.py +177 -0
  16. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_serverupdate.py +38 -0
  17. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/LICENSE +0 -0
  18. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/NOTICE +0 -0
  19. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/__init__.py +0 -0
  20. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/base.py +0 -0
  21. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/claude.py +0 -0
  22. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/claude_desktop.py +0 -0
  23. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/codex.py +0 -0
  24. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/generic.py +0 -0
  25. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/kiro.py +0 -0
  26. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/opencode.py +0 -0
  27. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/pi.py +0 -0
  28. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/autoupdate.py +0 -0
  29. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/background.py +0 -0
  30. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/backups.py +0 -0
  31. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/config_write.py +0 -0
  32. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/connect.py +0 -0
  33. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/contract/__init__.py +0 -0
  34. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/contract/matrix.py +0 -0
  35. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/decision/__init__.py +0 -0
  36. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/decision/board.py +0 -0
  37. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/decision/server.py +0 -0
  38. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/dexes.py +0 -0
  39. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/docdexsync.py +0 -0
  40. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/gateway.py +0 -0
  41. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooklog.py +0 -0
  42. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/__init__.py +0 -0
  43. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/__main__.py +0 -0
  44. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/_git.py +0 -0
  45. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/_mcp.py +0 -0
  46. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/destructive.py +0 -0
  47. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/post_tool.py +0 -0
  48. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/pre_tool.py +0 -0
  49. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/precompact.py +0 -0
  50. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/prompt.py +0 -0
  51. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/runbooks.py +0 -0
  52. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/session_end.py +0 -0
  53. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/stop.py +0 -0
  54. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/jobobject.py +0 -0
  55. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/join.py +0 -0
  56. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/joincode.py +0 -0
  57. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/maildexsync.py +0 -0
  58. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/migrate.py +0 -0
  59. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/nightshift.py +0 -0
  60. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/nightshiftdrain.py +0 -0
  61. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/pathenv.py +0 -0
  62. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/promptrecall.py +0 -0
  63. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/resolver.py +0 -0
  64. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/serverinit.py +0 -0
  65. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/shim.py +0 -0
  66. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/sidecar.py +0 -0
  67. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/signing.py +0 -0
  68. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/state.py +0 -0
  69. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/stdio.py +0 -0
  70. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/symdexindex.py +0 -0
  71. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/transport.py +0 -0
  72. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/updater.py +0 -0
  73. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/wizard.py +0 -0
  74. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/worktree_snapshot.py +0 -0
  75. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/dependency_links.txt +0 -0
  76. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/entry_points.txt +0 -0
  77. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/requires.txt +0 -0
  78. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/top_level.txt +0 -0
  79. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/setup.cfg +0 -0
  80. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_attribution_headers.py +0 -0
  81. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_autoupdate.py +0 -0
  82. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_background.py +0 -0
  83. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bash_layer_retired.py +0 -0
  84. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_docdex.py +0 -0
  85. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_maildex.py +0 -0
  86. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_ps1.py +0 -0
  87. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_reinstall.py +0 -0
  88. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_sh.py +0 -0
  89. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_symdex.py +0 -0
  90. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_venv_provisioning.py +0 -0
  91. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli.py +0 -0
  92. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_backup.py +0 -0
  93. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_dex.py +0 -0
  94. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_docdex.py +0 -0
  95. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_doctor.py +0 -0
  96. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_doctor_generic.py +0 -0
  97. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_doctor_report.py +0 -0
  98. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_entry.py +0 -0
  99. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_generic_install.py +0 -0
  100. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_hands.py +0 -0
  101. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_install.py +0 -0
  102. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_maildex.py +0 -0
  103. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_onboarding.py +0 -0
  104. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_personal.py +0 -0
  105. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_uninstall.py +0 -0
  106. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_uninstall_generic.py +0 -0
  107. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_update.py +0 -0
  108. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_whoami.py +0 -0
  109. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_config_migration.py +0 -0
  110. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_connect.py +0 -0
  111. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_board_html.py +0 -0
  112. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_bypass.py +0 -0
  113. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_poll_ceiling.py +0 -0
  114. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_registration.py +0 -0
  115. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_server.py +0 -0
  116. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_dexes.py +0 -0
  117. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_docdexsync.py +0 -0
  118. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_docs_reference_client_kit.py +0 -0
  119. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_doctor_credential_expiry.py +0 -0
  120. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_doctor_embeddings.py +0 -0
  121. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_doctor_no_server.py +0 -0
  122. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_e2e_bootstrap.py +0 -0
  123. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway.py +0 -0
  124. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway_child_env.py +0 -0
  125. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway_schema_slimming.py +0 -0
  126. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway_toolset.py +0 -0
  127. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_generic_config.py +0 -0
  128. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_hooklog.py +0 -0
  129. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_import_boundary.py +0 -0
  130. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_init_self_enroll.py +0 -0
  131. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_init_stale_bundle.py +0 -0
  132. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_install_skill_contract.py +0 -0
  133. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_instruction_budget.py +0 -0
  134. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_jobobject.py +0 -0
  135. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_join.py +0 -0
  136. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_join_config_write.py +0 -0
  137. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_joincode.py +0 -0
  138. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_kit_smoke.py +0 -0
  139. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_maildexsync.py +0 -0
  140. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_make_release.py +0 -0
  141. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_memory_instructions.py +0 -0
  142. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_migration_entrypoints.py +0 -0
  143. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_nightshift.py +0 -0
  144. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_nightshiftdrain.py +0 -0
  145. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_pathenv.py +0 -0
  146. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_promptrecall.py +0 -0
  147. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_bootstrap_enums.py +0 -0
  148. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_capture.py +0 -0
  149. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_consent.py +0 -0
  150. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_flush_points.py +0 -0
  151. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_integration.py +0 -0
  152. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_schema.py +0 -0
  153. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_spool.py +0 -0
  154. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_resolver.py +0 -0
  155. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_resolver_bypass.py +0 -0
  156. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_resolver_single_connection.py +0 -0
  157. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_runbook_match_parity.py +0 -0
  158. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_runbook_state.py +0 -0
  159. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_scratch_ttl.py +0 -0
  160. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_serverinit.py +0 -0
  161. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_bridge.py +0 -0
  162. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_bypass.py +0 -0
  163. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_eof_exit.py +0 -0
  164. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_fail_loud.py +0 -0
  165. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_identity.py +0 -0
  166. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_recovery.py +0 -0
  167. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_skeleton.py +0 -0
  168. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_sidecar.py +0 -0
  169. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_sidecar_robustness.py +0 -0
  170. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_signing.py +0 -0
  171. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_state.py +0 -0
  172. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_state_session_report.py +0 -0
  173. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_stdio_encoding.py +0 -0
  174. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_symdexindex.py +0 -0
  175. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_transport.py +0 -0
  176. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_transport_sse.py +0 -0
  177. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_twin_removed.py +0 -0
  178. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_updater.py +0 -0
  179. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_wizard.py +0 -0
  180. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_wizard_generic.py +0 -0
  181. {firekeep_client-1.8.0 → firekeep_client-1.8.2}/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.0
3
+ Version: 1.8.2
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
@@ -57,9 +57,9 @@ an absent registry is seeded with both (default-on), `firekeep dex remove` is
57
57
  the off-switch, and removals stick across updates.
58
58
 
59
59
  **Firekeep is self-hosted — this package needs a server to talk to.** A person
60
- or team runs its own server, with per-key authentication. Current server images
61
- target `linux/amd64`: use an x86-64 Linux host, or Docker Desktop with amd64
62
- container support on Windows or Mac. There is no hosted endpoint. Server install:
60
+ or team runs its own server, with per-key authentication. Server images target
61
+ `linux/amd64` and, for releases after v1.6.2, `linux/arm64`: use an x86-64 or
62
+ ARM64 Linux host, or Docker Desktop on Windows or Mac. There is no hosted endpoint. Server install:
63
63
  [firekeep.ai/docs.html](https://firekeep.ai/docs.html#server).
64
64
 
65
65
  ## Install
@@ -30,9 +30,9 @@ an absent registry is seeded with both (default-on), `firekeep dex remove` is
30
30
  the off-switch, and removals stick across updates.
31
31
 
32
32
  **Firekeep is self-hosted — this package needs a server to talk to.** A person
33
- or team runs its own server, with per-key authentication. Current server images
34
- target `linux/amd64`: use an x86-64 Linux host, or Docker Desktop with amd64
35
- container support on Windows or Mac. There is no hosted endpoint. Server install:
33
+ or team runs its own server, with per-key authentication. Server images target
34
+ `linux/amd64` and, for releases after v1.6.2, `linux/arm64`: use an x86-64 or
35
+ ARM64 Linux host, or Docker Desktop on Windows or Mac. There is no hosted endpoint. Server install:
36
36
  [firekeep.ai/docs.html](https://firekeep.ai/docs.html#server).
37
37
 
38
38
  ## Install
@@ -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.0"
12
+ __version__ = "1.8.2"
@@ -118,6 +118,9 @@ NOT_FORWARDED: frozenset[str] = frozenset({
118
118
  "FIREKEEP_SIGNING_KEY",
119
119
  # An enrolment code is a credential; only `firekeep join` reads it.
120
120
  "FIREKEEP_JOIN",
121
+ # The bootstrap's fresh-install answer belongs to the install hand-off
122
+ # alone; a child seeing it could count a non-install as a new install.
123
+ "FIREKEEP_INSTALL_REPORT_CONSENT",
121
124
  # Set by the gateway from ~/.firekeep/config for symdex alone; a parent's
122
125
  # copy (stale shell export, another member's key) is never authoritative.
123
126
  "FIREKEEP_CORTEX_URL", "FIREKEEP_CLIENT_API_KEY", "FIREKEEP_CORTEX_CA",
@@ -461,7 +461,7 @@ def _apply_flags(cfg, args) -> bool:
461
461
  # overwrite an ALREADY-recorded answer — a re-render, an update's
462
462
  # re-exec, or a non-interactive run relaying a pre-set env must not
463
463
  # rewrite a "no" a human already gave.
464
- report.record_consent(cfg, pre == "1")
464
+ report.record_consent(cfg, pre == "1", installs=report.installs_env_answer())
465
465
  touched = True
466
466
  return touched
467
467
 
@@ -710,8 +710,9 @@ def cmd_install(args) -> int:
710
710
  print(f"firekeep: install failed at '{step}': {exc}", file=sys.stderr)
711
711
  return 1
712
712
 
713
+ report.milestone("installed")
713
714
  print(f"firekeep: installed into {home}")
714
- registered = "firekeep (gateway: memory, sessions, relay, monitoring, code, decisions)"
715
+ registered ="firekeep (gateway: memory, sessions, relay, monitoring, code, decisions)"
715
716
  print(f"firekeep: registered MCP servers: {registered}")
716
717
  print("firekeep: tip — `/personal` (Claude) or `firekeep personal` toggles a private "
717
718
  "session where Firekeep is fully bypassed (nothing logged/recalled); it "
@@ -1018,6 +1019,8 @@ def _check_health(cfg) -> list[tuple[str, str, str]]:
1018
1019
  else:
1019
1020
  for svc, category in failures:
1020
1021
  report.emit("connectivity", svc, error=category, cfg=cfg)
1022
+ if not failures:
1023
+ report.milestone("connected", cfg=cfg)
1021
1024
  return out
1022
1025
 
1023
1026
 
@@ -127,6 +127,8 @@ def run(payload: dict) -> dict:
127
127
  )
128
128
  if isinstance(data, dict) and data.get("rendered"):
129
129
  rendered = data["rendered"]
130
+ # Once per machine; the flush below sends it.
131
+ report.milestone("connected", cfg=cfg, flush_now=False)
130
132
  # Stash the server-minted briefing_id so the bridge shim can inject it
131
133
  # into a ctx_start_session call the agent makes without it — closing the
132
134
  # briefing_id -> session_id A/B join mechanically. (The stale-id clear
@@ -72,6 +72,15 @@ _STAGES_BY_KIND = {
72
72
  "runtime": RUNTIME_STAGES,
73
73
  }
74
74
 
75
+ # Install milestones: the success half of the funnel, so downloads can be read
76
+ # against installs that worked. NOT a failure kind — its own consent key
77
+ # ([report] installs), its own once-per-machine marker, no `error` field, and
78
+ # the collector logs it apart from failures (never mailed, never pulled into
79
+ # Sentinel). installed = `firekeep install` completed; connected = the first
80
+ # time this machine reached its Keep (doctor all-green or a served briefing).
81
+ MILESTONE_KIND = "milestone"
82
+ MILESTONE_STAGES = ("installed", "connected")
83
+
75
84
  _ERRNO_MAP = {
76
85
  _errno.EACCES: "permission-denied",
77
86
  _errno.EPERM: "permission-denied",
@@ -184,15 +193,43 @@ def build_event(kind, stage, *, error=None, exc=None, exit_code=None,
184
193
  return None
185
194
 
186
195
 
196
+ def build_milestone(stage) -> dict | None:
197
+ """Validated milestone event, or None. Same closed fields as a failure
198
+ minus `error` and every tagged-union extra."""
199
+ try:
200
+ if stage not in MILESTONE_STAGES:
201
+ return None
202
+ os_family = detect_os()
203
+ if os_family not in OS_FAMILIES:
204
+ return None
205
+ from firekeep_client import __version__
206
+ return {
207
+ "id": uuid.uuid4().hex,
208
+ "kind": MILESTONE_KIND,
209
+ "stage": stage,
210
+ "os": os_family,
211
+ "arch": detect_arch(),
212
+ "client": __version__,
213
+ "py": py_bucket(),
214
+ }
215
+ except Exception: # noqa: BLE001
216
+ return None
217
+
218
+
187
219
  _FALSEY = ("", "0", "false", "no", "off")
188
220
 
221
+ # One question, two recorded answers ([report] failures + [report] installs).
222
+ # Machines enrolled under the earlier failures-only wording keep `installs`
223
+ # absent and so never send a milestone: their consent is not widened after
224
+ # the fact. The bootstraps (install.sh / install.ps1) carry the same scope.
189
225
  CONSENT_PROMPT = (
190
- "Send anonymous failure reports to firekeep.ai? When an install step fails, a\n"
191
- "connection to your own Keep fails, or a Firekeep background task errors,\n"
192
- "Firekeep sends category codes only — what failed, the error class, OS family,\n"
193
- "versions. Never paths, messages, addresses, or any persistent device, account\n"
194
- "or session identifier. Ongoing until you turn it off\n"
195
- "([report] failures = false). [Y/n] "
226
+ "Send anonymous reports to firekeep.ai? Once when this install succeeds and\n"
227
+ "first reaches your Keep, and whenever an install step fails, a connection to\n"
228
+ "your own Keep fails, or a Firekeep background task errors, Firekeep sends\n"
229
+ "category codes only — what happened, the error class, OS family, versions.\n"
230
+ "Never paths, messages, addresses, or any persistent device, account or\n"
231
+ "session identifier. Ongoing until you turn it off\n"
232
+ "([report] failures = false, [report] installs = false). [Y/n] "
196
233
  )
197
234
 
198
235
 
@@ -216,6 +253,23 @@ def is_enabled(cfg=None) -> bool:
216
253
  return False
217
254
 
218
255
 
256
+ def milestones_enabled(cfg=None) -> bool:
257
+ """Consent gate for install milestones. Stricter than is_enabled: only a
258
+ recorded `[report] installs = true` counts — there is deliberately no env
259
+ opt-in, so CI runs that set FIREKEEP_FAILURE_REPORT=1 never count as
260
+ installs. The failure opt-out and personal mode silence it too."""
261
+ try:
262
+ if os.environ.get("FIREKEEP_NO_FAILURE_REPORT", "").strip().lower() not in _FALSEY:
263
+ return False
264
+ if resolver.is_bypassed():
265
+ return False
266
+ if cfg is None:
267
+ cfg = resolver.load_config()
268
+ return cfg.get("report", "installs", fallback="").strip().lower() == "true"
269
+ except Exception: # noqa: BLE001 — any doubt means OFF (fail closed)
270
+ return False
271
+
272
+
219
273
  def has_answer(cfg) -> bool:
220
274
  try:
221
275
  return cfg.get("report", "failures", fallback="").strip() != ""
@@ -223,15 +277,38 @@ def has_answer(cfg) -> bool:
223
277
  return False
224
278
 
225
279
 
226
- def record_consent(cfg, value: bool) -> None:
280
+ def record_consent(cfg, value: bool, *, installs: bool | None = None) -> None:
281
+ """Record the failures answer, and the installs answer only when the
282
+ human was shown wording that covers milestones (installs is not None)."""
227
283
  try:
228
284
  if not cfg.has_section("report"):
229
285
  cfg.add_section("report")
230
286
  cfg.set("report", "failures", "true" if value else "false")
287
+ if installs is not None:
288
+ cfg.set("report", "installs", "true" if installs else "false")
231
289
  except Exception: # noqa: BLE001
232
290
  pass
233
291
 
234
292
 
293
+ # Set when THIS process recorded an answer from a human at the prompt. With
294
+ # the bootstrap's relayed answer it defines a fresh install: the prompt only
295
+ # ever appears on a machine with no recorded answer, so an update, a re-render
296
+ # or a later opt-in never counts as a new install.
297
+ _ANSWERED_HERE = False
298
+
299
+
300
+ def _fresh_install_consented() -> bool:
301
+ return installs_env_answer() is True or _ANSWERED_HERE
302
+
303
+
304
+ def installs_env_answer() -> bool | None:
305
+ """The bootstrap relays its prompt's answer in FIREKEEP_INSTALL_REPORT_CONSENT,
306
+ set ONLY when a human answered the milestone-covering wording — never by
307
+ FIREKEEP_FAILURE_REPORT or an update re-exec. None = not shown that prompt."""
308
+ pre = os.environ.get("FIREKEEP_INSTALL_REPORT_CONSENT", "").strip()
309
+ return (pre == "1") if pre in ("0", "1") else None
310
+
311
+
235
312
  def ask_consent(cfg) -> bool:
236
313
  """Ask once; record only a real answer. EOF and Ctrl-C record NOTHING —
237
314
  deliberately not wizard.console_ask, whose EOF-takes-the-default would
@@ -242,7 +319,7 @@ def ask_consent(cfg) -> bool:
242
319
  return False
243
320
  pre = os.environ.get("FIREKEEP_REPORT_CONSENT", "").strip()
244
321
  if pre in ("0", "1"):
245
- record_consent(cfg, pre == "1")
322
+ record_consent(cfg, pre == "1", installs=installs_env_answer())
246
323
  return True
247
324
  if not sys.stdin.isatty():
248
325
  return False
@@ -256,7 +333,10 @@ def ask_consent(cfg) -> bool:
256
333
  except (EOFError, KeyboardInterrupt):
257
334
  print()
258
335
  return False
259
- record_consent(cfg, answer in ("", "y", "yes"))
336
+ yes = answer in ("", "y", "yes")
337
+ record_consent(cfg, yes, installs=yes)
338
+ global _ANSWERED_HERE
339
+ _ANSWERED_HERE = yes
260
340
  return True
261
341
  except Exception: # noqa: BLE001
262
342
  return False
@@ -373,6 +453,56 @@ def emit(kind, stage, *, error=None, exc=None, exit_code=None,
373
453
  pass
374
454
 
375
455
 
456
+ def _milestones_path() -> Path:
457
+ return _report_dir() / "report-milestones.json"
458
+
459
+
460
+ def _claim_milestone(stage: str) -> bool:
461
+ """Mark `stage` sent for this machine; True only for the caller that
462
+ newly marked it. The marker is written BEFORE the event is spooled and a
463
+ failed write means no event: a milestone may be lost, never repeated —
464
+ repeats would inflate exactly the count this exists to make honest.
465
+
466
+ The funnel is nested: `installed` only on a fresh install whose prompt was
467
+ answered yes in this run, `connected` only on a machine that already sent
468
+ `installed` — so an existing machine opting in later counts as neither."""
469
+ try:
470
+ path = _milestones_path()
471
+ seen = {}
472
+ if path.exists():
473
+ seen = json.loads(path.read_text(encoding="utf-8"))
474
+ if not isinstance(seen, dict):
475
+ return False # unreadable marker: assume sent
476
+ if stage in seen:
477
+ return False
478
+ if stage == "installed" and not _fresh_install_consented():
479
+ return False
480
+ if stage == "connected" and "installed" not in seen:
481
+ return False
482
+ seen[stage] = int(time.time())
483
+ path.parent.mkdir(parents=True, exist_ok=True)
484
+ path.write_text(json.dumps(seen), encoding="utf-8")
485
+ return True
486
+ except Exception: # noqa: BLE001
487
+ return False
488
+
489
+
490
+ def milestone(stage, *, cfg=None, flush_now: bool = True) -> None:
491
+ """Record an install milestone once per machine. Never raises; same spool
492
+ and wire as emit(), so an offline machine sends it on a later flush."""
493
+ try:
494
+ if not milestones_enabled(cfg):
495
+ return
496
+ event = build_milestone(stage)
497
+ if event is None or not _claim_milestone(stage):
498
+ return
499
+ _append_spool(event)
500
+ if flush_now:
501
+ flush(cfg=cfg)
502
+ except Exception: # noqa: BLE001
503
+ pass
504
+
505
+
376
506
  def _read_events(path: Path) -> list[dict]:
377
507
  out = []
378
508
  try:
@@ -443,7 +573,8 @@ def flush(cfg=None, timeout=None) -> None:
443
573
  if timeout is None:
444
574
  timeout = FLUSH_TIMEOUT
445
575
  try:
446
- if not is_enabled(cfg):
576
+ failures_on, milestones_on = is_enabled(cfg), milestones_enabled(cfg)
577
+ if not (failures_on or milestones_on):
447
578
  return
448
579
  claims = _adoptable_claims()
449
580
  spool = _spool_path()
@@ -457,6 +588,11 @@ def flush(cfg=None, timeout=None) -> None:
457
588
  claim_path.unlink()
458
589
  except OSError:
459
590
  pass
591
+ # Each event travels only under its own consent: a spooled failure
592
+ # from before `failures = false` is dropped, not sent under the
593
+ # installs answer (and vice versa).
594
+ events = [e for e in events
595
+ if (milestones_on if e.get("kind") == MILESTONE_KIND else failures_on)]
460
596
  if not events:
461
597
  continue
462
598
  try:
@@ -19,6 +19,7 @@ from firekeep_client.transport import get_json
19
19
 
20
20
  _TIMEOUT = 3.0
21
21
  _CACHE_KEY = "server_update_check"
22
+ _RECHECK_KEY = "server_update_recheck"
22
23
 
23
24
 
24
25
  @dataclass
@@ -93,6 +94,26 @@ def _fetch_latest(cfg) -> str | None:
93
94
  return None
94
95
 
95
96
 
97
+ def _recheck_ahead(cfg, running: str, cached: str | None) -> str | None:
98
+ """A server ahead of the day-cached latest usually means the cache predates
99
+ a release deployed since (a published server cannot outrun the published
100
+ series). Re-fetch once, at most once a day per running version so a
101
+ checkout parked on an unpublished tag pays the 3s once, not per call."""
102
+ try:
103
+ today = datetime.date.today().isoformat()
104
+ marker = f"{today}|{running}"
105
+ if state.read_scratch(_RECHECK_KEY) == marker:
106
+ return cached
107
+ state.write_scratch(_RECHECK_KEY, marker)
108
+ fresh = _fetch_latest_uncached(cfg)
109
+ if fresh:
110
+ state.write_scratch(_CACHE_KEY, f"{today}|{fresh}")
111
+ return fresh
112
+ except Exception: # noqa: BLE001 — keep the cached verdict
113
+ pass
114
+ return cached
115
+
116
+
96
117
  def _relation(running: str, latest: str | None) -> str:
97
118
  # Checked BEFORE the manifest: provenance is a property of the server, not
98
119
  # of dist-host reachability, so this verdict must never degrade to
@@ -129,6 +150,8 @@ def check(cfg) -> ServerUpdateStatus | None:
129
150
  if running is None:
130
151
  return None
131
152
  latest = _fetch_latest(cfg)
153
+ if _relation(running, latest) == "ahead":
154
+ latest = _recheck_ahead(cfg, running, latest)
132
155
  return ServerUpdateStatus(
133
156
  running=running, latest=latest,
134
157
  relation=_relation(running, latest),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firekeep-client
3
- Version: 1.8.0
3
+ Version: 1.8.2
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
@@ -57,9 +57,9 @@ an absent registry is seeded with both (default-on), `firekeep dex remove` is
57
57
  the off-switch, and removals stick across updates.
58
58
 
59
59
  **Firekeep is self-hosted — this package needs a server to talk to.** A person
60
- or team runs its own server, with per-key authentication. Current server images
61
- target `linux/amd64`: use an x86-64 Linux host, or Docker Desktop with amd64
62
- container support on Windows or Mac. There is no hosted endpoint. Server install:
60
+ or team runs its own server, with per-key authentication. Server images target
61
+ `linux/amd64` and, for releases after v1.6.2, `linux/arm64`: use an x86-64 or
62
+ ARM64 Linux host, or Docker Desktop on Windows or Mac. There is no hosted endpoint. Server install:
63
63
  [firekeep.ai/docs.html](https://firekeep.ai/docs.html#server).
64
64
 
65
65
  ## Install
@@ -145,6 +145,7 @@ tests/test_report_consent.py
145
145
  tests/test_report_consent_surfaces.py
146
146
  tests/test_report_flush_points.py
147
147
  tests/test_report_integration.py
148
+ tests/test_report_milestones.py
148
149
  tests/test_report_schema.py
149
150
  tests/test_report_spool.py
150
151
  tests/test_resolver.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "firekeep-client"
7
- version = "1.8.0"
7
+ version = "1.8.2"
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"
@@ -40,8 +40,9 @@ def _consent_snippet(tmp_path):
40
40
  end = text.index("# --- TLS trust", start)
41
41
  snippet = ("#!/bin/sh\nset -u\nPYTHON_VERSION=3.12\n"
42
42
  + text[start:end]
43
- + '\nprintf "CONSENT=%s EXPORTED=%s\\n" '
44
- '"${REPORT_CONSENT}" "${FIREKEEP_REPORT_CONSENT-unset}"\n')
43
+ + '\nprintf "CONSENT=%s EXPORTED=%s INSTALLS=%s\\n" '
44
+ '"${REPORT_CONSENT}" "${FIREKEEP_REPORT_CONSENT-unset}" '
45
+ '"${FIREKEEP_INSTALL_REPORT_CONSENT-unset}"\n')
45
46
  script = tmp_path / "consent-only.sh"
46
47
  script.write_text(snippet, encoding="utf-8")
47
48
  script.chmod(0o755)
@@ -89,18 +90,18 @@ def _run_under_pty(script, keys, env=None):
89
90
 
90
91
  def test_enter_records_yes(tmp_path):
91
92
  out = _run_under_pty(_consent_snippet(tmp_path), b"\n")
92
- assert "CONSENT=1 EXPORTED=1" in out
93
+ assert "CONSENT=1 EXPORTED=1 INSTALLS=1" in out
93
94
 
94
95
 
95
96
  def test_n_records_no(tmp_path):
96
97
  out = _run_under_pty(_consent_snippet(tmp_path), b"n\n")
97
- assert "CONSENT=0 EXPORTED=0" in out
98
+ assert "CONSENT=0 EXPORTED=0 INSTALLS=0" in out
98
99
 
99
100
 
100
101
  def test_eof_records_nothing(tmp_path):
101
102
  # ^D at the prompt: unanswered — nothing exported, nothing reported
102
103
  out = _run_under_pty(_consent_snippet(tmp_path), b"\x04")
103
- assert "CONSENT=0 EXPORTED=unset" in out
104
+ assert "CONSENT=0 EXPORTED=unset INSTALLS=unset" in out
104
105
 
105
106
 
106
107
  def test_env_opt_out_never_asks(tmp_path):
@@ -108,3 +109,17 @@ def test_env_opt_out_never_asks(tmp_path):
108
109
  env={"FIREKEEP_NO_FAILURE_REPORT": "1"})
109
110
  assert "[Y/n]" not in out
110
111
  assert "CONSENT=0 EXPORTED=unset" in out
112
+
113
+
114
+ def test_failure_env_opt_in_never_relays_install_consent(tmp_path):
115
+ """FIREKEEP_FAILURE_REPORT=1 (CI) enrolls failures without a prompt; it must
116
+ never stand in for a human's answer to the milestone wording."""
117
+ out = _run_under_pty(_consent_snippet(tmp_path), b"",
118
+ env={"FIREKEEP_FAILURE_REPORT": "1"})
119
+ assert "[Y/n]" not in out
120
+ assert "CONSENT=1 EXPORTED=1 INSTALLS=unset" in out
121
+
122
+
123
+ def test_prompt_names_the_milestones(tmp_path):
124
+ out = _run_under_pty(_consent_snippet(tmp_path), b"\n")
125
+ assert "Once when this install succeeds" in out
@@ -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.0"
10
+ assert firekeep_client.__version__ == "1.8.2"
11
11
 
12
12
 
13
13
  def test_the_version_pins_agree():
@@ -90,7 +90,7 @@ def test_cmd_doctor_asks_once_after_output_and_preserves_exit_code(
90
90
  assert cli.cmd_doctor(Args()) == 1 # fail row -> rc 1, EOF didn't change it
91
91
  assert "[report]" not in cfg_path.read_text() # EOF recorded nothing
92
92
  out = capsys.readouterr().out
93
- assert out.index("[FAIL] cortex") < out.index("Send anonymous failure reports")
93
+ assert out.index("[FAIL] cortex") < out.index("Send anonymous reports")
94
94
 
95
95
  monkeypatch.setattr(builtins, "input", lambda prompt="": "y")
96
96
  assert cli.cmd_doctor(Args()) == 1
@@ -0,0 +1,177 @@
1
+ """Install milestones: the success half of the funnel, under their own consent
2
+ key ([report] installs). Machines enrolled under the failures-only wording must
3
+ never send one, and an existing machine opting in later must not count as a
4
+ new install."""
5
+ import builtins
6
+ import configparser
7
+ import json
8
+
9
+ import pytest
10
+
11
+ from firekeep_client import cli, report
12
+
13
+
14
+ def _cfg(text=""):
15
+ cfg = configparser.ConfigParser()
16
+ cfg.read_string(text)
17
+ return cfg
18
+
19
+
20
+ ON = "[report]\nfailures = true\ninstalls = true\n"
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def report_dir(tmp_path, monkeypatch):
25
+ for var in ("FIREKEEP_NO_FAILURE_REPORT", "FIREKEEP_FAILURE_REPORT",
26
+ "FIREKEEP_REPORT_CONSENT", "FIREKEEP_INSTALL_REPORT_CONSENT",
27
+ "FIREKEEP_BYPASS"):
28
+ monkeypatch.delenv(var, raising=False)
29
+ monkeypatch.setenv("FIREKEEP_REPORT_DIR", str(tmp_path))
30
+ monkeypatch.setattr(report, "_ANSWERED_HERE", False)
31
+ return tmp_path
32
+
33
+
34
+ @pytest.fixture
35
+ def posted(monkeypatch):
36
+ sent = []
37
+
38
+ def fake_post(url, body, timeout):
39
+ sent.extend(body["events"])
40
+ return {"accepted": [e["id"] for e in body["events"]], "rejected": []}
41
+ monkeypatch.setattr(report, "_post", fake_post)
42
+ return sent
43
+
44
+
45
+ def test_failures_only_enrollee_never_sends_milestones(posted, monkeypatch):
46
+ monkeypatch.setenv("FIREKEEP_INSTALL_REPORT_CONSENT", "1")
47
+ cfg = _cfg("[report]\nfailures = true\n")
48
+ assert report.milestones_enabled(cfg) is False
49
+ report.milestone("installed", cfg=cfg)
50
+ assert posted == []
51
+
52
+
53
+ def test_failure_env_opt_in_does_not_enable_milestones(monkeypatch):
54
+ """CI sets FIREKEEP_FAILURE_REPORT=1; that must never count as an install."""
55
+ monkeypatch.setenv("FIREKEEP_FAILURE_REPORT", "1")
56
+ assert report.milestones_enabled(_cfg()) is False
57
+
58
+
59
+ def test_opt_out_and_personal_mode_silence_milestones(monkeypatch):
60
+ monkeypatch.setenv("FIREKEEP_NO_FAILURE_REPORT", "1")
61
+ assert report.milestones_enabled(_cfg(ON)) is False
62
+ monkeypatch.delenv("FIREKEEP_NO_FAILURE_REPORT")
63
+ monkeypatch.setenv("FIREKEEP_BYPASS", "1")
64
+ assert report.milestones_enabled(_cfg(ON)) is False
65
+
66
+
67
+ def test_installed_sends_once_on_fresh_consented_install(posted, monkeypatch):
68
+ monkeypatch.setenv("FIREKEEP_INSTALL_REPORT_CONSENT", "1")
69
+ cfg = _cfg(ON)
70
+ report.milestone("installed", cfg=cfg)
71
+ report.milestone("installed", cfg=cfg)
72
+ assert [e["stage"] for e in posted] == ["installed"]
73
+ ev = posted[0]
74
+ assert set(ev) == {"id", "kind", "stage", "os", "arch", "client", "py"}
75
+ assert ev["kind"] == "milestone" and "error" not in ev
76
+
77
+
78
+ def test_installed_not_sent_without_answer_in_this_run(posted):
79
+ """An update re-exec or a later opt-in carries no fresh answer."""
80
+ report.milestone("installed", cfg=_cfg(ON))
81
+ assert posted == []
82
+
83
+
84
+ def test_connected_requires_installed_first(posted, monkeypatch):
85
+ cfg = _cfg(ON)
86
+ report.milestone("connected", cfg=cfg)
87
+ assert posted == []
88
+ monkeypatch.setenv("FIREKEEP_INSTALL_REPORT_CONSENT", "1")
89
+ report.milestone("installed", cfg=cfg)
90
+ report.milestone("connected", cfg=cfg)
91
+ report.milestone("connected", cfg=cfg)
92
+ assert [e["stage"] for e in posted] == ["installed", "connected"]
93
+
94
+
95
+ def test_unknown_stage_dropped(posted):
96
+ assert report.build_milestone("uninstalled") is None
97
+
98
+
99
+ def test_unwritable_marker_means_no_event(posted, monkeypatch, report_dir):
100
+ monkeypatch.setenv("FIREKEEP_INSTALL_REPORT_CONSENT", "1")
101
+ (report_dir / "report-milestones.json").write_text("[]", encoding="utf-8")
102
+ report.milestone("installed", cfg=_cfg(ON))
103
+ assert posted == []
104
+
105
+
106
+ def test_flush_sends_each_event_only_under_its_own_consent(posted, report_dir):
107
+ spool = report_dir / "report-spool.jsonl"
108
+ failure = report.build_event("install", "create-venv", error="disk-full")
109
+ mile = report.build_milestone("installed")
110
+ spool.write_text(json.dumps(failure) + "\n" + json.dumps(mile) + "\n",
111
+ encoding="utf-8")
112
+ report.flush(cfg=_cfg("[report]\nfailures = false\ninstalls = true\n"))
113
+ assert [e["kind"] for e in posted] == ["milestone"]
114
+
115
+
116
+ def test_interactive_yes_records_both_keys_and_marks_fresh(monkeypatch):
117
+ cfg = _cfg()
118
+ monkeypatch.setattr("sys.stdin", type("T", (), {"isatty": lambda self: True})())
119
+ monkeypatch.setattr(builtins, "input", lambda prompt="": "")
120
+ assert report.ask_consent(cfg) is True
121
+ assert cfg.get("report", "failures") == "true"
122
+ assert cfg.get("report", "installs") == "true"
123
+ assert report._fresh_install_consented() is True
124
+
125
+
126
+ def test_interactive_no_records_both_keys_false(monkeypatch):
127
+ cfg = _cfg()
128
+ monkeypatch.setattr("sys.stdin", type("T", (), {"isatty": lambda self: True})())
129
+ monkeypatch.setattr(builtins, "input", lambda prompt="": "n")
130
+ report.ask_consent(cfg)
131
+ assert cfg.get("report", "installs") == "false"
132
+ assert report._fresh_install_consented() is False
133
+
134
+
135
+ def test_bootstrap_relay_without_install_answer_records_failures_only(monkeypatch):
136
+ """FIREKEEP_FAILURE_REPORT=1 in the bootstrap exports FIREKEEP_REPORT_CONSENT
137
+ but not FIREKEEP_INSTALL_REPORT_CONSENT: installs stays unanswered."""
138
+ cfg = _cfg()
139
+ monkeypatch.setenv("FIREKEEP_REPORT_CONSENT", "1")
140
+
141
+ class Args:
142
+ pass
143
+ assert cli._apply_flags(cfg, Args()) is True
144
+ assert cfg.get("report", "failures") == "true"
145
+ assert not cfg.has_option("report", "installs")
146
+
147
+
148
+ def test_bootstrap_relay_with_install_answer_records_both(monkeypatch):
149
+ cfg = _cfg()
150
+ monkeypatch.setenv("FIREKEEP_REPORT_CONSENT", "1")
151
+ monkeypatch.setenv("FIREKEEP_INSTALL_REPORT_CONSENT", "1")
152
+
153
+ class Args:
154
+ pass
155
+ cli._apply_flags(cfg, Args())
156
+ assert cfg.get("report", "installs") == "true"
157
+
158
+
159
+ def test_prompt_states_the_milestone_scope():
160
+ assert "Once when this install succeeds" in report.CONSENT_PROMPT
161
+ assert "[report] installs = false" in report.CONSENT_PROMPT
162
+
163
+
164
+ def test_ps1_relays_install_consent_only_from_the_prompt():
165
+ """No PTY harness for PowerShell here: pin the shape textually. The
166
+ FIREKEEP_FAILURE_REPORT branch must not set the install relay; the Read-Host
167
+ branch must set it on both answers, with the milestone wording."""
168
+ from pathlib import Path
169
+ text = (Path(__file__).resolve().parents[1] / "bootstrap" / "install.ps1").read_text(
170
+ encoding="utf-8")
171
+ env_branch = text[text.index("} elseif ($env:FIREKEEP_FAILURE_REPORT) {"):
172
+ text.index("} elseif ([Environment]::UserInteractive")]
173
+ assert "FIREKEEP_INSTALL_REPORT_CONSENT" not in env_branch
174
+ prompt_branch = text[text.index("} elseif ([Environment]::UserInteractive"):
175
+ text.index("function Send-FailureReport")]
176
+ assert "Once when this install" in prompt_branch
177
+ assert prompt_branch.count("$env:FIREKEEP_INSTALL_REPORT_CONSENT = ") == 2