firekeep-client 1.7.2__tar.gz → 1.8.0__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.7.2/firekeep_client.egg-info → firekeep_client-1.8.0}/PKG-INFO +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/__init__.py +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/codex.py +3 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/childenv.py +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/cli.py +65 -2
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/gateway.py +9 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/promptrecall.py +13 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/shim.py +23 -7
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/state.py +85 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/updater.py +2 -2
- {firekeep_client-1.7.2 → firekeep_client-1.8.0/firekeep_client.egg-info}/PKG-INFO +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client.egg-info/SOURCES.txt +2 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/pyproject.toml +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_install.py +40 -2
- firekeep_client-1.8.0/tests/test_cli_whoami.py +101 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_e2e_bootstrap.py +2 -2
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_gateway_child_env.py +6 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_gateway_toolset.py +41 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_package.py +1 -1
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_promptrecall.py +24 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_shim_identity.py +124 -0
- firekeep_client-1.8.0/tests/test_state_session_report.py +106 -0
- firekeep_client-1.7.2/tests/test_ci_publishes_symdex.py +0 -30
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/LICENSE +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/NOTICE +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/README.md +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/base.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/claude.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/claude_desktop.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/kiro.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/opencode.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/adapters/pi.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/autoupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/background.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/backups.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/config_write.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/connect.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/contract/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/contract/matrix.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/decision/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/decision/board.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/decision/server.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/dexes.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/docdexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooklog.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/__init__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/__main__.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/_git.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/_mcp.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/destructive.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/post_tool.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/pre_tool.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/precompact.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/prompt.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/runbooks.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/session_end.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/session_start.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/hooks/stop.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/jobobject.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/join.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/joincode.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/maildexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/migrate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/nightshift.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/nightshiftdrain.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/pathenv.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/report.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/resolver.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/serverinit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/serverupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/sidecar.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/signing.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/stdio.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/symdexindex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/transport.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/wizard.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client/worktree_snapshot.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client.egg-info/dependency_links.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client.egg-info/entry_points.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client.egg-info/requires.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/firekeep_client.egg-info/top_level.txt +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/setup.cfg +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_attribution_headers.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_autoupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_background.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bash_layer_retired.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_consent_pty.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_docdex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_maildex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_ps1.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_reinstall.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_sh.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_symdex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_bootstrap_venv_provisioning.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_backup.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_dex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_docdex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_doctor.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_doctor_generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_doctor_report.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_entry.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_generic_install.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_hands.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_maildex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_onboarding.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_personal.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_uninstall.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_uninstall_generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_cli_update.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_config_migration.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_connect.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_decision_board_html.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_decision_bypass.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_decision_poll_ceiling.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_decision_registration.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_decision_server.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_dexes.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_docdexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_docs_reference_client_kit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_doctor_credential_expiry.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_doctor_embeddings.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_doctor_no_server.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_gateway.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_gateway_schema_slimming.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_generic_config.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_hooklog.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_import_boundary.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_init_self_enroll.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_init_stale_bundle.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_install_skill_contract.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_instruction_budget.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_jobobject.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_join.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_join_config_write.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_joincode.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_kit_smoke.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_maildexsync.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_make_release.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_memory_instructions.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_migration_entrypoints.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_nightshift.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_nightshiftdrain.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_pathenv.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_bootstrap_enums.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_capture.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_consent.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_consent_surfaces.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_flush_points.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_integration.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_schema.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_report_spool.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_resolver.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_resolver_bypass.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_resolver_single_connection.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_runbook_match_parity.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_runbook_state.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_scratch_ttl.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_serverinit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_serverupdate.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_shim_bridge.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_shim_bypass.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_shim_eof_exit.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_shim_fail_loud.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_shim_recovery.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_shim_skeleton.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_sidecar.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_sidecar_robustness.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_signing.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_state.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_stdio_encoding.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_symdexindex.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_transport.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_transport_sse.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_twin_removed.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_updater.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_wizard.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_wizard_generic.py +0 -0
- {firekeep_client-1.7.2 → firekeep_client-1.8.0}/tests/test_worktree_snapshot.py +0 -0
|
@@ -46,6 +46,9 @@ def _toml_block(venv_bin: Path) -> str:
|
|
|
46
46
|
# TOML literal (single-quoted) string: no escape processing -> Windows backslashes safe.
|
|
47
47
|
lines.append(f"command = '{cmd}'")
|
|
48
48
|
lines.append(f"args = [{arglist}]")
|
|
49
|
+
# Codex clears the env of stdio MCP servers; forward what Firekeep
|
|
50
|
+
# Studio sets per run (spec 2026-10-11 §7.2).
|
|
51
|
+
lines.append('env_vars = ["FIREKEEP_SESSION_REPORT_FILE"]')
|
|
49
52
|
lines.append("")
|
|
50
53
|
return "\n".join(lines).rstrip()
|
|
51
54
|
|
|
@@ -65,7 +65,7 @@ _KIT = frozenset({
|
|
|
65
65
|
"FIREKEEP_SHIM_DEBUG", "FIREKEEP_NO_JOB_OBJECT",
|
|
66
66
|
"FIREKEEP_REPORT_CONSENT", "FIREKEEP_REPORT_DIR",
|
|
67
67
|
"FIREKEEP_FAILURE_REPORT", "FIREKEEP_NO_FAILURE_REPORT",
|
|
68
|
-
"FIREKEEP_SESSION_ID", "NEXUS_AGENT_ID",
|
|
68
|
+
"FIREKEEP_SESSION_ID", "FIREKEEP_SESSION_REPORT_FILE", "NEXUS_AGENT_ID",
|
|
69
69
|
# hook-side knobs the client modules a child imports may consult
|
|
70
70
|
"FIREKEEP_AGENT_GOAL", "FIREKEEP_BRIEFING", "FIREKEEP_NO_AUTO_UPDATE",
|
|
71
71
|
"FIREKEEP_NO_AUTO_SYNC", "FIREKEEP_NO_AUTO_INDEX", "FIREKEEP_NO_AUTO_NIGHTSHIFT",
|
|
@@ -87,6 +87,44 @@ def cmd_version(args) -> int:
|
|
|
87
87
|
return 0
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
# --- whoami: identity facts for Studio 0.5 -----------------------------------
|
|
91
|
+
|
|
92
|
+
def cmd_whoami(args) -> int:
|
|
93
|
+
"""Identity facts a local client (Firekeep Studio) needs — never the API key.
|
|
94
|
+
|
|
95
|
+
Studio must not read ~/.firekeep/config itself because the file holds the
|
|
96
|
+
key, so this is the one place it learns the agent id (spec 2026-10-11 §7.1).
|
|
97
|
+
"""
|
|
98
|
+
cfg = None
|
|
99
|
+
try:
|
|
100
|
+
cfg = resolver.load_config(_config_path())
|
|
101
|
+
except resolver.ConfigMigrationConflict as exc:
|
|
102
|
+
print(f"firekeep: {exc}", file=sys.stderr)
|
|
103
|
+
return 3
|
|
104
|
+
except resolver.ConfigError:
|
|
105
|
+
cfg = None
|
|
106
|
+
agent: str | None = None
|
|
107
|
+
if cfg is not None:
|
|
108
|
+
try:
|
|
109
|
+
agent = resolver.agent_id(cfg)
|
|
110
|
+
except resolver.ConfigError:
|
|
111
|
+
agent = None
|
|
112
|
+
if agent == wizard.PLACEHOLDER_AGENT_ID:
|
|
113
|
+
agent = None # the installer skeleton value is "unconfigured"
|
|
114
|
+
facts = {
|
|
115
|
+
"agent_id": agent,
|
|
116
|
+
"kit_version": __version__,
|
|
117
|
+
"personal_mode": bool(resolver.is_bypassed()),
|
|
118
|
+
"server_configured": bool(cfg is not None and wizard._already_connected(cfg)),
|
|
119
|
+
}
|
|
120
|
+
if getattr(args, "json", False):
|
|
121
|
+
print(json.dumps(facts))
|
|
122
|
+
else:
|
|
123
|
+
for key, value in facts.items():
|
|
124
|
+
print(f"{key}: {value}")
|
|
125
|
+
return 0 if agent else 1
|
|
126
|
+
|
|
127
|
+
|
|
90
128
|
# --- install: venv, pip, ~/.firekeep bootstrap, adapter render -----------------
|
|
91
129
|
|
|
92
130
|
# `configured = false` is the sentinel that makes "never set up" a REPRESENTABLE
|
|
@@ -277,7 +315,18 @@ def _venv_has_pip(venv: Path) -> bool:
|
|
|
277
315
|
return True
|
|
278
316
|
|
|
279
317
|
|
|
280
|
-
def
|
|
318
|
+
def _is_live_venv(home: Path, venv: Path) -> bool:
|
|
319
|
+
"""True when the `current` link resolves to `venv` — the install every hook runs."""
|
|
320
|
+
current = home / CURRENT_LINK_NAME
|
|
321
|
+
if not os.path.lexists(current):
|
|
322
|
+
return False
|
|
323
|
+
try:
|
|
324
|
+
return os.path.normcase(os.path.realpath(current)) == os.path.normcase(os.path.realpath(venv))
|
|
325
|
+
except OSError:
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _create_venv(venv: Path, home: Path) -> None:
|
|
281
330
|
if _venv_bin(venv).exists():
|
|
282
331
|
if _venv_has_pip(venv):
|
|
283
332
|
return
|
|
@@ -285,6 +334,16 @@ def _create_venv(venv: Path) -> None:
|
|
|
285
334
|
# bootstrap that died between `uv venv` and `uv pip install` leaves exactly this.
|
|
286
335
|
# Short-circuiting here used to crash the checkout install later in _pip_install;
|
|
287
336
|
# rebuild in place instead (--clear reprovisions, pip included via ensurepip).
|
|
337
|
+
#
|
|
338
|
+
# Never the LIVE venv, though: a healthy bootstrap venv is pip-less too, and a
|
|
339
|
+
# checkout whose version matches the installed one lands here. On 2026-10-12 the
|
|
340
|
+
# --clear died on executables held by running sessions, leaving Scripts/ without
|
|
341
|
+
# a stdlib, and every hook on the machine failed until the venv was rebuilt.
|
|
342
|
+
if _is_live_venv(home, venv):
|
|
343
|
+
raise RuntimeError(
|
|
344
|
+
f"{venv} is the live install (`current` points at it) and has no pip; "
|
|
345
|
+
"rebuilding it would break every running session. Bump the checkout's "
|
|
346
|
+
"version, or run `firekeep update` to repair the installed release.")
|
|
288
347
|
_run([sys.executable, "-m", "venv", "--clear", str(venv)])
|
|
289
348
|
return
|
|
290
349
|
_run([sys.executable, "-m", "venv", str(venv)])
|
|
@@ -555,7 +614,7 @@ def cmd_install(args) -> int:
|
|
|
555
614
|
# Checkout install: provision a fresh versioned venv at its FINAL path
|
|
556
615
|
# (never moved — venvs aren't relocatable) and flip `current` to it below.
|
|
557
616
|
venv = home / VENVS_DIR_NAME / _kit_version(kit)
|
|
558
|
-
_create_venv(venv)
|
|
617
|
+
_create_venv(venv, home)
|
|
559
618
|
else:
|
|
560
619
|
venv = Path(sys.executable).resolve().parent.parent
|
|
561
620
|
python = _venv_python(venv)
|
|
@@ -3147,6 +3206,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
3147
3206
|
ver = sub.add_parser("version", help="print client version + skew status")
|
|
3148
3207
|
ver.set_defaults(func=cmd_version)
|
|
3149
3208
|
|
|
3209
|
+
who = sub.add_parser("whoami", help="print this machine's Firekeep identity (never the key)")
|
|
3210
|
+
who.add_argument("--json", action="store_true", help="one line of JSON for programs")
|
|
3211
|
+
who.set_defaults(func=cmd_whoami)
|
|
3212
|
+
|
|
3150
3213
|
inst = sub.add_parser("install", help="install/refresh the client kit")
|
|
3151
3214
|
# An unset runtime prepares every shipped adapter. Explicit --runtime (or the
|
|
3152
3215
|
# bootstrap's FIREKEEP_RUNTIME) remains available for a targeted re-render.
|
|
@@ -66,6 +66,15 @@ TOOLSET_PRESETS: dict[str, frozenset[str]] = {
|
|
|
66
66
|
"ctx_abandon_session", "ctx_list_sessions", "ctx_resume_session",
|
|
67
67
|
"ctx_get_shadow",
|
|
68
68
|
}),
|
|
69
|
+
# Firekeep Studio's Keep panel and per-turn receipts (Studio 0.5, spec
|
|
70
|
+
# 2026-10-11 §7.1). Reads plus knowledge feedback only: Studio never
|
|
71
|
+
# starts, updates or completes a Keep session — the agent owns those.
|
|
72
|
+
"studio": frozenset({
|
|
73
|
+
"memory_recall", "memory_feedback",
|
|
74
|
+
"skill_list", "skill_recall",
|
|
75
|
+
"relay_who_is_online", "relay_read",
|
|
76
|
+
"ctx_list_sessions", "replay_timeline", "corpus_sources",
|
|
77
|
+
}),
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
# A preset that narrows the tool surface also narrows the handshake text: the
|
|
@@ -286,6 +286,15 @@ def remember(agent: str, ids: list[str]) -> None:
|
|
|
286
286
|
hooklog.log_failure(_HOOK, f"proactive recall dedupe write failed: {e}")
|
|
287
287
|
|
|
288
288
|
|
|
289
|
+
def _keep_session(cfg) -> str | None:
|
|
290
|
+
"""The agent's current Keep session, so the pushed recall is attributed to
|
|
291
|
+
it in replay (Studio 0.5 receipts, spec §7.2). Best-effort: None on any error."""
|
|
292
|
+
try:
|
|
293
|
+
return state.current_session_id(resolver.agent_id(cfg))
|
|
294
|
+
except Exception: # noqa: BLE001
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
289
298
|
def recall(cfg, prompt: str) -> list:
|
|
290
299
|
"""POST the prompt to cortex `/memory/recall` and return its `sources`.
|
|
291
300
|
|
|
@@ -309,12 +318,15 @@ def recall(cfg, prompt: str) -> list:
|
|
|
309
318
|
memory filed under `infrastructure` and friends — 146 of them on the live store,
|
|
310
319
|
which is the defect `docs/guides/cortex-design-decisions.md` records.
|
|
311
320
|
|
|
321
|
+
It carries the agent's current Keep session (`X-Session-Id`) so the recall shows
|
|
322
|
+
in that session's replay timeline.
|
|
323
|
+
|
|
312
324
|
Returns [] for a DEGRADED response (vector search down, results graph-only).
|
|
313
325
|
Same call as Bridge makes: the floor below is calibrated on cosine, and a
|
|
314
326
|
graph-only set is a different scale, so pushing it would be pushing noise the
|
|
315
327
|
threshold cannot judge.
|
|
316
328
|
"""
|
|
317
|
-
ep = resolver.resolve("cortex", cfg=cfg)
|
|
329
|
+
ep = resolver.resolve("cortex", cfg=cfg, session_id=_keep_session(cfg))
|
|
318
330
|
body = {
|
|
319
331
|
"task": prompt[:MAX_TASK_CHARS],
|
|
320
332
|
"top_k": TOP_K,
|
|
@@ -159,8 +159,9 @@ class _StashSessionAuth(httpx.Auth):
|
|
|
159
159
|
# Tap-wired: what THIS process observed, or nothing.
|
|
160
160
|
sid = self._tap._session_id
|
|
161
161
|
else:
|
|
162
|
-
stash
|
|
163
|
-
|
|
162
|
+
# Fresh stash first; under Studio, the report file survives
|
|
163
|
+
# the stash clear a resumed runtime's session_start performs.
|
|
164
|
+
sid = state.current_session_id(self._agent)
|
|
164
165
|
if sid:
|
|
165
166
|
request.headers["X-Session-Id"] = sid
|
|
166
167
|
except Exception:
|
|
@@ -515,11 +516,15 @@ class _BridgeSessionTap:
|
|
|
515
516
|
return item
|
|
516
517
|
name = params.get("name")
|
|
517
518
|
rid = getattr(root, "id", None)
|
|
518
|
-
if rid is not None and
|
|
519
|
-
name in self._CAPTURE_TOOLS
|
|
520
|
-
or name in self._END_TOOLS
|
|
521
|
-
):
|
|
519
|
+
if rid is not None and name in self._CAPTURE_TOOLS:
|
|
522
520
|
self._pending[rid] = name
|
|
521
|
+
elif rid is not None and name in self._END_TOOLS:
|
|
522
|
+
# Remember WHICH session the call targets: an explicit id from
|
|
523
|
+
# the agent wins, else this process's captured id.
|
|
524
|
+
raw_args = params.get("arguments")
|
|
525
|
+
explicit = raw_args.get("session_id") if isinstance(raw_args, dict) else None
|
|
526
|
+
target = explicit if isinstance(explicit, str) and explicit else self._session_id
|
|
527
|
+
self._pending[rid] = (name, target)
|
|
523
528
|
if name in self._INJECT_TOOLS:
|
|
524
529
|
args = params.get("arguments")
|
|
525
530
|
if isinstance(args, dict):
|
|
@@ -546,8 +551,16 @@ class _BridgeSessionTap:
|
|
|
546
551
|
rid = getattr(root, "id", None)
|
|
547
552
|
if rid is None or rid not in self._pending:
|
|
548
553
|
return item
|
|
549
|
-
|
|
554
|
+
entry = self._pending.pop(rid)
|
|
555
|
+
target = None
|
|
556
|
+
if isinstance(entry, tuple):
|
|
557
|
+
name, target = entry
|
|
558
|
+
else:
|
|
559
|
+
name = entry
|
|
550
560
|
if name in self._END_TOOLS:
|
|
561
|
+
# Tombstone the session the call targeted before clearing.
|
|
562
|
+
if target:
|
|
563
|
+
state.end_session_report(target)
|
|
551
564
|
self._session_id = None
|
|
552
565
|
state.clear_session_stash(self._agent)
|
|
553
566
|
return item
|
|
@@ -557,6 +570,9 @@ class _BridgeSessionTap:
|
|
|
557
570
|
# Keep writing the agent-keyed disk stash: hook cores in other
|
|
558
571
|
# processes still read it, and it is the restart fallback.
|
|
559
572
|
state.write_session_stash(self._agent, session_id=sid)
|
|
573
|
+
# Studio 0.5: tell the launching Studio session which Keep
|
|
574
|
+
# session this agent is in (no-op unless the env var is set).
|
|
575
|
+
state.append_session_report(sid)
|
|
560
576
|
except Exception:
|
|
561
577
|
pass
|
|
562
578
|
return item
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
+
import re
|
|
9
10
|
import subprocess
|
|
10
11
|
import sys
|
|
11
12
|
import time
|
|
@@ -448,6 +449,90 @@ def clear_session_stash(agent_id: str) -> None:
|
|
|
448
449
|
pass
|
|
449
450
|
|
|
450
451
|
|
|
452
|
+
# Firekeep Studio 0.5 (spec 2026-10-11 §7.2): Studio launches each agent with
|
|
453
|
+
# this variable naming a per-Studio-session file. The bridge tap appends every
|
|
454
|
+
# Keep session id it captures, so Studio can build per-turn receipts from the
|
|
455
|
+
# sessions the AGENT started — Studio never starts one itself.
|
|
456
|
+
SESSION_REPORT_ENV = "FIREKEEP_SESSION_REPORT_FILE"
|
|
457
|
+
# Ids travel in an HTTP header (X-Session-Id): no whitespace, no newline, ever.
|
|
458
|
+
_SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _report_path() -> str | None:
|
|
462
|
+
raw = os.environ.get(SESSION_REPORT_ENV, "").strip()
|
|
463
|
+
return raw or None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def append_session_report(session_id: str) -> None:
|
|
467
|
+
"""Append a captured Keep session id to $FIREKEEP_SESSION_REPORT_FILE.
|
|
468
|
+
No-op when the variable is unset or the id is not header-safe. Never raises."""
|
|
469
|
+
try:
|
|
470
|
+
path = _report_path()
|
|
471
|
+
if not path or not isinstance(session_id, str) or not _SESSION_ID_RE.fullmatch(session_id):
|
|
472
|
+
return
|
|
473
|
+
with open(path, "a", encoding="utf-8", newline="\n") as fh:
|
|
474
|
+
fh.write(session_id + "\n")
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def end_session_report(session_id: str) -> None:
|
|
480
|
+
"""Mark a session as ended in $FIREKEEP_SESSION_REPORT_FILE by appending
|
|
481
|
+
'#ended <id>'. No-op when the variable is unset or the id is not header-safe.
|
|
482
|
+
Never raises."""
|
|
483
|
+
try:
|
|
484
|
+
path = _report_path()
|
|
485
|
+
if not path or not isinstance(session_id, str) or not _SESSION_ID_RE.fullmatch(session_id):
|
|
486
|
+
return
|
|
487
|
+
with open(path, "a", encoding="utf-8", newline="\n") as fh:
|
|
488
|
+
fh.write(f"#ended {session_id}\n")
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def reported_session_id() -> str | None:
|
|
494
|
+
"""The last active (not ended) header-safe id in $FIREKEEP_SESSION_REPORT_FILE.
|
|
495
|
+
A line '#ended <id>' marks an id as ended; scanning in order, the last active
|
|
496
|
+
id is returned, or None. A file whose mtime is older than the session stash
|
|
497
|
+
TTL is ignored (a stale file must not attribute a new day's work to a dead
|
|
498
|
+
session). Handles non-UTF-8 bytes gracefully."""
|
|
499
|
+
try:
|
|
500
|
+
path = _report_path()
|
|
501
|
+
if not path:
|
|
502
|
+
return None
|
|
503
|
+
if time.time() - os.path.getmtime(path) > _session_stash_ttl_seconds():
|
|
504
|
+
return None
|
|
505
|
+
last = None
|
|
506
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
507
|
+
for line in fh:
|
|
508
|
+
line = line.strip()
|
|
509
|
+
if line.startswith("#ended "):
|
|
510
|
+
# Extract the id after "#ended "
|
|
511
|
+
ended_id = line[7:] # len("#ended ") == 7
|
|
512
|
+
# Deliberately fail-closed: a tombstone clears only the
|
|
513
|
+
# most recent id, so "K1, K2, #ended K2" yields None even
|
|
514
|
+
# if K1 is still live. Tracking a set of ended ids would
|
|
515
|
+
# revive sessions that ended without writing a tombstone.
|
|
516
|
+
if ended_id == last:
|
|
517
|
+
last = None
|
|
518
|
+
elif _SESSION_ID_RE.fullmatch(line):
|
|
519
|
+
last = line
|
|
520
|
+
return last
|
|
521
|
+
except Exception:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def current_session_id(agent_id: str) -> str | None:
|
|
526
|
+
"""The Keep session this agent is working in: the fresh stash, else the
|
|
527
|
+
Studio report file (which survives the stash clear a resumed runtime's
|
|
528
|
+
session_start performs). Never raises."""
|
|
529
|
+
try:
|
|
530
|
+
stash = read_session_stash(agent_id) or {}
|
|
531
|
+
return stash.get("session_id") or reported_session_id()
|
|
532
|
+
except Exception:
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
|
|
451
536
|
# --- runbook bundle store (Enforced Runbooks Phase B) ------------------------
|
|
452
537
|
#
|
|
453
538
|
# Spec: docs/superpowers/specs/2026-08-15-enforced-runbooks-design.md ("Bundle").
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Release-manifest fetch, version compare, and verified download for `firekeep update`.
|
|
2
2
|
|
|
3
3
|
Stdlib-only (import boundary). Optional non-stdlib import: truststore (guarded) — OS-trust
|
|
4
|
-
for RELEASE-HOST fetches only. The
|
|
4
|
+
for RELEASE-HOST fetches only. The release host is NEVER hardcoded: the bootstrap knows the
|
|
5
5
|
URL it was fetched from and records it as [dist] base_url in ~/.firekeep/config, which is the
|
|
6
6
|
only place this module learns it from.
|
|
7
7
|
"""
|
|
@@ -18,7 +18,7 @@ from firekeep_client.transport import TransportError, get_json
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def dist_ssl_context() -> "ssl.SSLContext | None":
|
|
21
|
-
"""OS-trust SSL context for release-host fetches ONLY (
|
|
21
|
+
"""OS-trust SSL context for release-host fetches ONLY (a release host can sit
|
|
22
22
|
behind corporate TLS interception; the managed CPython's default bundle lacks the
|
|
23
23
|
interception CA). Scoped on purpose — truststore.inject_into_ssl() would replace
|
|
24
24
|
ssl.SSLContext process-wide and widen office ca_path pinning to 'pin OR OS store'
|
|
@@ -82,7 +82,6 @@ tests/test_bootstrap_reinstall.py
|
|
|
82
82
|
tests/test_bootstrap_sh.py
|
|
83
83
|
tests/test_bootstrap_symdex.py
|
|
84
84
|
tests/test_bootstrap_venv_provisioning.py
|
|
85
|
-
tests/test_ci_publishes_symdex.py
|
|
86
85
|
tests/test_cli.py
|
|
87
86
|
tests/test_cli_backup.py
|
|
88
87
|
tests/test_cli_dex.py
|
|
@@ -100,6 +99,7 @@ tests/test_cli_personal.py
|
|
|
100
99
|
tests/test_cli_uninstall.py
|
|
101
100
|
tests/test_cli_uninstall_generic.py
|
|
102
101
|
tests/test_cli_update.py
|
|
102
|
+
tests/test_cli_whoami.py
|
|
103
103
|
tests/test_config_migration.py
|
|
104
104
|
tests/test_connect.py
|
|
105
105
|
tests/test_decision_board_html.py
|
|
@@ -166,6 +166,7 @@ tests/test_sidecar.py
|
|
|
166
166
|
tests/test_sidecar_robustness.py
|
|
167
167
|
tests/test_signing.py
|
|
168
168
|
tests/test_state.py
|
|
169
|
+
tests/test_state_session_report.py
|
|
169
170
|
tests/test_stdio_encoding.py
|
|
170
171
|
tests/test_symdexindex.py
|
|
171
172
|
tests/test_transport.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "firekeep-client"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.8.0"
|
|
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"
|
|
@@ -596,10 +596,48 @@ def test_create_venv_recreates_a_pipless_venv(tmp_path, monkeypatch):
|
|
|
596
596
|
calls = []
|
|
597
597
|
monkeypatch.setattr(cli, "_run", lambda cmd, **kw: calls.append(list(cmd)))
|
|
598
598
|
monkeypatch.setattr(cli, "_venv_has_pip", lambda v: False)
|
|
599
|
-
cli._create_venv(venv)
|
|
599
|
+
cli._create_venv(venv, tmp_path)
|
|
600
600
|
assert calls and "--clear" in calls[0], f"expected a --clear rebuild, got {calls}"
|
|
601
601
|
|
|
602
602
|
|
|
603
|
+
def test_create_venv_never_clears_the_live_venv(tmp_path, monkeypatch):
|
|
604
|
+
"""Live incident 2026-10-12: a checkout `install` run while the checkout's version
|
|
605
|
+
equalled the installed one targeted venvs/<V> — the venv `current` points at. The
|
|
606
|
+
bootstrap's uv venv ships no pip, so the pip-less branch `--clear`ed it; executables
|
|
607
|
+
held by running sessions stopped the clear halfway, leaving Scripts/ with no stdlib
|
|
608
|
+
or pyvenv.cfg, and every hook on the machine failed. The live venv is never cleared."""
|
|
609
|
+
venv = tmp_path / "venvs" / "1.7.2"
|
|
610
|
+
bindir = venv / ("Scripts" if cli.os.name == "nt" else "bin")
|
|
611
|
+
bindir.mkdir(parents=True)
|
|
612
|
+
cli._point_current(tmp_path, venv)
|
|
613
|
+
calls = []
|
|
614
|
+
monkeypatch.setattr(cli, "_run", lambda cmd, **kw: calls.append(list(cmd)))
|
|
615
|
+
monkeypatch.setattr(cli, "_venv_has_pip", lambda v: False) # exactly a uv venv
|
|
616
|
+
with pytest.raises(RuntimeError, match="current"):
|
|
617
|
+
cli._create_venv(venv, tmp_path)
|
|
618
|
+
assert calls == [], f"the live venv must not be rebuilt, got {calls}"
|
|
619
|
+
assert bindir.is_dir()
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def test_checkout_install_of_the_installed_version_leaves_the_live_venv(install_env, monkeypatch, tmp_path, capsys):
|
|
623
|
+
"""The incident path end to end: the checkout reads the version already installed
|
|
624
|
+
(1.2.3 here), `current` points at venvs/1.2.3, and that bootstrap venv has no pip."""
|
|
625
|
+
home, runs, _ = install_env
|
|
626
|
+
live = home / "venvs" / "1.2.3"
|
|
627
|
+
(live / ("Scripts" if os.name == "nt" else "bin")).mkdir(parents=True)
|
|
628
|
+
cli._point_current(home, live)
|
|
629
|
+
monkeypatch.setattr(cli, "_venv_has_pip", lambda v: False)
|
|
630
|
+
pips = []
|
|
631
|
+
monkeypatch.setattr(cli, "_pip_install", lambda py, *pkgs, **k: pips.append(pkgs))
|
|
632
|
+
_checkout(tmp_path, monkeypatch)
|
|
633
|
+
|
|
634
|
+
rc = cli.main(["install", "--runtime", "claude"])
|
|
635
|
+
assert rc == 1
|
|
636
|
+
assert "install failed at 'create venv'" in capsys.readouterr().err
|
|
637
|
+
assert not [r for r in runs if "venv" in r], f"live venv rebuilt: {runs}"
|
|
638
|
+
assert pips == []
|
|
639
|
+
|
|
640
|
+
|
|
603
641
|
def test_create_venv_skips_a_healthy_venv(tmp_path, monkeypatch):
|
|
604
642
|
venv = tmp_path / "venv"
|
|
605
643
|
bindir = venv / ("Scripts" if cli.os.name == "nt" else "bin")
|
|
@@ -607,7 +645,7 @@ def test_create_venv_skips_a_healthy_venv(tmp_path, monkeypatch):
|
|
|
607
645
|
calls = []
|
|
608
646
|
monkeypatch.setattr(cli, "_run", lambda cmd, **kw: calls.append(list(cmd)))
|
|
609
647
|
monkeypatch.setattr(cli, "_venv_has_pip", lambda v: True)
|
|
610
|
-
cli._create_venv(venv)
|
|
648
|
+
cli._create_venv(venv, tmp_path)
|
|
611
649
|
assert calls == []
|
|
612
650
|
|
|
613
651
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""`firekeep whoami` — identity facts for local clients (Firekeep Studio 0.5,
|
|
2
|
+
spec §7.1). Studio must never read ~/.firekeep/config itself: it holds the key."""
|
|
3
|
+
import json
|
|
4
|
+
import textwrap
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from firekeep_client import __version__, cli
|
|
9
|
+
|
|
10
|
+
SECRET = "fk_live_do_not_print_me"
|
|
11
|
+
|
|
12
|
+
CONFIG = textwrap.dedent(f"""\
|
|
13
|
+
[identity]
|
|
14
|
+
agent_id = tester
|
|
15
|
+
|
|
16
|
+
[server]
|
|
17
|
+
kind = ports
|
|
18
|
+
scheme = http
|
|
19
|
+
host = 10.0.0.5
|
|
20
|
+
verify_tls = false
|
|
21
|
+
api_key = {SECRET}
|
|
22
|
+
""")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def config_file(tmp_path, monkeypatch):
|
|
27
|
+
cfg = tmp_path / ".firekeep" / "config"
|
|
28
|
+
cfg.parent.mkdir(parents=True)
|
|
29
|
+
cfg.write_text(CONFIG, encoding="utf-8")
|
|
30
|
+
monkeypatch.setenv("FIREKEEP_CONFIG", str(cfg))
|
|
31
|
+
monkeypatch.setenv("FIREKEEP_CACHE_DIR", str(tmp_path / "cache"))
|
|
32
|
+
monkeypatch.delenv("FIREKEEP_AGENT_ID", raising=False)
|
|
33
|
+
monkeypatch.setattr("firekeep_client.state._private", lambda _p: None)
|
|
34
|
+
monkeypatch.setattr("firekeep_client.resolver.is_bypassed", lambda *a, **k: False)
|
|
35
|
+
return cfg
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_whoami_json_reports_identity(config_file, capsys):
|
|
39
|
+
assert cli.main(["whoami", "--json"]) == 0
|
|
40
|
+
out = capsys.readouterr().out
|
|
41
|
+
assert json.loads(out) == {
|
|
42
|
+
"agent_id": "tester",
|
|
43
|
+
"kit_version": __version__,
|
|
44
|
+
"personal_mode": False,
|
|
45
|
+
"server_configured": True,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_whoami_never_prints_the_api_key(config_file, capsys):
|
|
50
|
+
cli.main(["whoami", "--json"])
|
|
51
|
+
cli.main(["whoami"])
|
|
52
|
+
captured = capsys.readouterr()
|
|
53
|
+
assert SECRET not in captured.out
|
|
54
|
+
assert SECRET not in captured.err
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_whoami_honours_agent_id_env(config_file, monkeypatch, capsys):
|
|
58
|
+
monkeypatch.setenv("FIREKEEP_AGENT_ID", "tester-studio-panel")
|
|
59
|
+
cli.main(["whoami", "--json"])
|
|
60
|
+
assert json.loads(capsys.readouterr().out)["agent_id"] == "tester-studio-panel"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_whoami_reports_personal_mode(config_file, monkeypatch, capsys):
|
|
64
|
+
monkeypatch.setattr("firekeep_client.resolver.is_bypassed", lambda *a, **k: True)
|
|
65
|
+
cli.main(["whoami", "--json"])
|
|
66
|
+
assert json.loads(capsys.readouterr().out)["personal_mode"] is True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_whoami_reports_bypass_env_as_personal_mode(config_file, monkeypatch, capsys):
|
|
70
|
+
monkeypatch.undo() # drop is_bypassed patch; exercise the real predicate
|
|
71
|
+
monkeypatch.setenv("FIREKEEP_CONFIG", str(config_file))
|
|
72
|
+
monkeypatch.setenv("FIREKEEP_CACHE_DIR", str(config_file.parent / "cache"))
|
|
73
|
+
monkeypatch.setenv("FIREKEEP_BYPASS", "1")
|
|
74
|
+
cli.main(["whoami", "--json"])
|
|
75
|
+
assert json.loads(capsys.readouterr().out)["personal_mode"] is True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_whoami_placeholder_agent_id_is_unconfigured(config_file, capsys):
|
|
79
|
+
config_file.write_text(
|
|
80
|
+
config_file.read_text(encoding="utf-8").replace("agent_id = tester", "agent_id = CHANGEME"),
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
assert cli.main(["whoami", "--json"]) == 1
|
|
84
|
+
assert json.loads(capsys.readouterr().out)["agent_id"] is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_whoami_without_config_is_null_not_a_crash(tmp_path, monkeypatch, capsys):
|
|
88
|
+
monkeypatch.setenv("FIREKEEP_CONFIG", str(tmp_path / "missing" / "config"))
|
|
89
|
+
monkeypatch.delenv("FIREKEEP_AGENT_ID", raising=False)
|
|
90
|
+
monkeypatch.setattr("firekeep_client.resolver.is_bypassed", lambda *a, **k: False)
|
|
91
|
+
assert cli.main(["whoami", "--json"]) == 1
|
|
92
|
+
data = json.loads(capsys.readouterr().out)
|
|
93
|
+
assert data["agent_id"] is None
|
|
94
|
+
assert data["server_configured"] is False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_whoami_plain_text_is_one_fact_per_line(config_file, capsys):
|
|
98
|
+
assert cli.main(["whoami"]) == 0
|
|
99
|
+
out = capsys.readouterr().out.splitlines()
|
|
100
|
+
assert out[0] == "agent_id: tester"
|
|
101
|
+
assert f"kit_version: {__version__}" in out
|
|
@@ -83,8 +83,8 @@ VERSION = tomllib.loads((CLIENT / "pyproject.toml").read_text())["project"]["ver
|
|
|
83
83
|
@pytest.fixture
|
|
84
84
|
def release(tmp_path):
|
|
85
85
|
"""Build a REAL release: real wheel, real mirrored uv, real make_release.py — then fan it
|
|
86
|
-
out into the REAL version-addressed layout
|
|
87
|
-
|
|
86
|
+
out into the REAL version-addressed layout release.yml publishes:
|
|
87
|
+
BASE/latest/{install.sh,install.ps1,latest.json} (the stable
|
|
88
88
|
entry point) + BASE/<version>/{SHA256SUMS,uv-<target>,wheel} (every version keeps its own
|
|
89
89
|
directory). Serving flat (the old shape) would silently resurrect C1/C3 — a tag-pinned
|
|
90
90
|
base whose latest.json points at the release it shipped inside, forever."""
|
|
@@ -345,3 +345,9 @@ def test_every_env_name_a_child_reads_is_classified():
|
|
|
345
345
|
"child may see it, CHILD_EXTRAS[<child>] if it is that child's own secret, "
|
|
346
346
|
"NOT_FORWARDED if no gateway child needs it"
|
|
347
347
|
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_session_report_file_reaches_backend_children():
|
|
351
|
+
from firekeep_client import childenv
|
|
352
|
+
|
|
353
|
+
assert "FIREKEEP_SESSION_REPORT_FILE" in childenv.ALLOWED_NAMES
|
|
@@ -151,3 +151,44 @@ def test_chat_preset_is_exactly_the_decided_surface():
|
|
|
151
151
|
"ctx_abandon_session", "ctx_list_sessions", "ctx_resume_session",
|
|
152
152
|
"ctx_get_shadow",
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
STUDIO_SURFACE = frozenset({
|
|
157
|
+
"memory_recall", "memory_feedback",
|
|
158
|
+
"skill_list", "skill_recall",
|
|
159
|
+
"relay_who_is_online", "relay_read",
|
|
160
|
+
"ctx_list_sessions", "replay_timeline", "corpus_sources",
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_studio_preset_is_exactly_the_decided_surface():
|
|
165
|
+
from firekeep_client.gateway import TOOLSET_PRESETS
|
|
166
|
+
|
|
167
|
+
assert TOOLSET_PRESETS["studio"] == STUDIO_SURFACE
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_studio_preset_cannot_write_sessions_or_memories():
|
|
171
|
+
"""Studio reads the Keep and thumbs knowledge; it never owns a session
|
|
172
|
+
(spec §7.2) and never learns on the user's behalf."""
|
|
173
|
+
from firekeep_client.gateway import TOOLSET_PRESETS
|
|
174
|
+
|
|
175
|
+
forbidden = {"ctx_start_session", "ctx_resume_session", "ctx_update",
|
|
176
|
+
"ctx_complete_session", "ctx_abandon_session", "memory_learn",
|
|
177
|
+
"vault_retrieve", "skill_create"}
|
|
178
|
+
assert not (TOOLSET_PRESETS["studio"] & forbidden)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_studio_preset_resolves_from_env(monkeypatch):
|
|
182
|
+
monkeypatch.delenv("FIREKEEP_TOOLS_ALLOW", raising=False)
|
|
183
|
+
monkeypatch.setenv("FIREKEEP_TOOLSET", "studio")
|
|
184
|
+
label, names = _active_toolset()
|
|
185
|
+
assert label == "studio"
|
|
186
|
+
assert names == STUDIO_SURFACE
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_unknown_preset_message_lists_studio(monkeypatch):
|
|
190
|
+
monkeypatch.delenv("FIREKEEP_TOOLS_ALLOW", raising=False)
|
|
191
|
+
monkeypatch.setenv("FIREKEEP_TOOLSET", "studoi")
|
|
192
|
+
with pytest.raises(SystemExit) as exc:
|
|
193
|
+
_active_toolset()
|
|
194
|
+
assert "chat, studio" in str(exc.value)
|