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.
- {firekeep_client-1.8.0/firekeep_client.egg-info → firekeep_client-1.8.2}/PKG-INFO +4 -4
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/README.md +3 -3
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/__init__.py +1 -1
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/childenv.py +3 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/cli.py +5 -2
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/session_start.py +2 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/report.py +146 -10
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/serverupdate.py +23 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2/firekeep_client.egg-info}/PKG-INFO +4 -4
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/SOURCES.txt +1 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/pyproject.toml +1 -1
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_consent_pty.py +20 -5
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_package.py +1 -1
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_consent_surfaces.py +1 -1
- firekeep_client-1.8.2/tests/test_report_milestones.py +177 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_serverupdate.py +38 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/LICENSE +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/NOTICE +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/__init__.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/base.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/claude.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/claude_desktop.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/codex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/generic.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/kiro.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/opencode.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/adapters/pi.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/autoupdate.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/background.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/backups.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/config_write.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/connect.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/contract/__init__.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/contract/matrix.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/decision/__init__.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/decision/board.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/decision/server.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/dexes.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/docdexsync.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/gateway.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooklog.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/__init__.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/__main__.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/_git.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/_mcp.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/destructive.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/post_tool.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/pre_tool.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/precompact.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/prompt.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/runbooks.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/session_end.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/hooks/stop.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/jobobject.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/join.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/joincode.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/maildexsync.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/migrate.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/nightshift.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/nightshiftdrain.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/pathenv.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/promptrecall.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/resolver.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/serverinit.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/shim.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/sidecar.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/signing.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/state.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/stdio.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/symdexindex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/transport.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/updater.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/wizard.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client/worktree_snapshot.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/dependency_links.txt +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/entry_points.txt +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/requires.txt +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/firekeep_client.egg-info/top_level.txt +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/setup.cfg +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_attribution_headers.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_autoupdate.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_background.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bash_layer_retired.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_docdex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_maildex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_ps1.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_reinstall.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_sh.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_symdex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_bootstrap_venv_provisioning.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_backup.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_dex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_docdex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_doctor.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_doctor_generic.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_doctor_report.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_entry.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_generic_install.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_hands.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_install.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_maildex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_onboarding.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_personal.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_uninstall.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_uninstall_generic.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_update.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_cli_whoami.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_config_migration.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_connect.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_board_html.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_bypass.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_poll_ceiling.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_registration.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_decision_server.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_dexes.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_docdexsync.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_docs_reference_client_kit.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_doctor_credential_expiry.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_doctor_embeddings.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_doctor_no_server.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_e2e_bootstrap.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway_child_env.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway_schema_slimming.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_gateway_toolset.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_generic_config.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_hooklog.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_import_boundary.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_init_self_enroll.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_init_stale_bundle.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_install_skill_contract.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_instruction_budget.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_jobobject.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_join.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_join_config_write.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_joincode.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_kit_smoke.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_maildexsync.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_make_release.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_memory_instructions.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_migration_entrypoints.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_nightshift.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_nightshiftdrain.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_pathenv.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_promptrecall.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_bootstrap_enums.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_capture.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_consent.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_flush_points.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_integration.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_schema.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_report_spool.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_resolver.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_resolver_bypass.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_resolver_single_connection.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_runbook_match_parity.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_runbook_state.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_scratch_ttl.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_serverinit.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_bridge.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_bypass.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_eof_exit.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_fail_loud.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_identity.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_recovery.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_shim_skeleton.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_sidecar.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_sidecar_robustness.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_signing.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_state.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_state_session_report.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_stdio_encoding.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_symdexindex.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_transport.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_transport_sse.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_twin_removed.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_updater.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_wizard.py +0 -0
- {firekeep_client-1.8.0 → firekeep_client-1.8.2}/tests/test_wizard_generic.py +0 -0
- {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.
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
@@ -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 =
|
|
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
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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}"
|
|
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
|
|
@@ -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
|
|
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
|