diff --git a/.github/scripts/windows_test_assignments.json b/.github/scripts/windows_test_assignments.json index 61e2e3811f..52ffe6bae0 100644 --- a/.github/scripts/windows_test_assignments.json +++ b/.github/scripts/windows_test_assignments.json @@ -82,6 +82,7 @@ "tests/test_engine/test_coding_mode.py", "tests/test_engine/test_done_text_snapshot_consumers.py", "tests/test_engine/test_execution_status_carrier_chain.py", + "tests/test_engine/test_file_parse_reliability.py", "tests/test_engine/test_final_diff_contract.py", "tests/test_engine/test_final_diff_salvage_and_endgame_freeze_levers.py", "tests/test_engine/test_goal_context_prompt.py", @@ -98,6 +99,7 @@ "tests/test_engine/test_provider_activity.py", "tests/test_engine/test_provider_health_ledger.py", "tests/test_engine/test_reasoning_retry_and_deadline_thinking_levers.py", + "tests/test_engine/test_reliability_runtime_seams.py", "tests/test_engine/test_resolve_turn_thinking.py", "tests/test_engine/test_route_plan.py", "tests/test_engine/test_router_calibration.py", @@ -270,6 +272,27 @@ "tests/test_skills_hub_source.py", "tests/test_skills_manifest.py", "tests/test_skills_third_party_notices.py", + "tests/test_telemetry/test_client_runtime.py", + "tests/test_telemetry/test_consent.py", + "tests/test_telemetry/test_contracts.py", + "tests/test_telemetry/test_file_parse_facts.py", + "tests/test_telemetry/test_growth_eligibility.py", + "tests/test_telemetry/test_growth_sink.py", + "tests/test_telemetry/test_growth_state.py", + "tests/test_telemetry/test_identity.py", + "tests/test_telemetry/test_outbox.py", + "tests/test_telemetry/test_privacy.py", + "tests/test_telemetry/test_recorder.py", + "tests/test_telemetry/test_reliability_sink.py", + "tests/test_telemetry/test_server_growth_producer.py", + "tests/test_telemetry/test_uploader.py", + "tests/test_telemetry_server/test_collector.py", + "tests/test_telemetry_server/test_dashboard.py", + "tests/test_telemetry_server/test_dashboard_auth.py", + "tests/test_telemetry_server/test_dashboard_queries.py", + "tests/test_telemetry_server/test_producer_auth.py", + "tests/test_telemetry_server/test_runtime.py", + "tests/test_telemetry_server/test_storage.py", "tests/test_thinking_level_propagation.py", "tests/test_toolcomp_matcher_levers.py", "tests/test_toolcomp_matcher_safety.py", @@ -505,6 +528,7 @@ "tests/test_gateway/test_config_redact_channel_crypto.py", "tests/test_gateway/test_config_router_budget.py", "tests/test_gateway/test_config_secret_inheritance.py", + "tests/test_gateway/test_config_telemetry_consent_sources.py", "tests/test_gateway/test_config_tier_provider_mismatch.py", "tests/test_gateway/test_config_version.py", "tests/test_gateway/test_config_ws_writer_queue.py", @@ -616,6 +640,7 @@ "tests/test_gateway/test_rpc_skills_install_visibility.py", "tests/test_gateway/test_rpc_skills_reload.py", "tests/test_gateway/test_rpc_storage_busy.py", + "tests/test_gateway/test_rpc_telemetry_consent.py", "tests/test_gateway/test_rpc_tools_visibility.py", "tests/test_gateway/test_rpc_usage.py", "tests/test_gateway/test_rpc_wizard.py", @@ -666,6 +691,7 @@ "tests/test_gateway/test_task_runtime_terminal_message.py", "tests/test_gateway/test_task_runtime_turn_committed.py", "tests/test_gateway/test_task_session_lifecycle.py", + "tests/test_gateway/test_telemetry_runtime_wiring.py", "tests/test_gateway/test_terminal_activity.py", "tests/test_gateway/test_terminal_reply.py", "tests/test_gateway/test_transcript_attachment_persistence.py", @@ -1400,6 +1426,10 @@ "tests/test_skills_paths.py", "tests/test_skills_provenance_contract.py", "tests/test_squilla_router_runtime_diagnostics.py", + "tests/test_telemetry/test_desktop_ingress.py", + "tests/test_telemetry/test_desktop_state.py", + "tests/test_telemetry/test_protocol_manifest_artifact.py", + "tests/test_telemetry_server/test_legacy_installations.py", "tests/test_tools/test_apply_patch_atomic.py", "tests/test_tools/test_apply_patch_gates.py", "tests/test_tools/test_artifact_range_grants.py", diff --git a/.github/scripts/windows_test_durations.json b/.github/scripts/windows_test_durations.json index 3e4074a512..e17f686159 100644 --- a/.github/scripts/windows_test_durations.json +++ b/.github/scripts/windows_test_durations.json @@ -85,7 +85,6 @@ "tests/contracts/test_gateway_contract_toolchain_integration.py": 0.01, "tests/contracts/test_goals_contract.py": 0.01, "tests/contracts/test_sandbox_runtime_contract.py": 0.01, - "tests/contracts/test_turn_commands_contract.py": 0.01, "tests/contracts/test_sessions_changed_contract.py": 0.01, "tests/contracts/test_sessions_directory_subscription_contract.py": 0.01, "tests/contracts/test_sessions_lifecycle_contract.py": 0.01, @@ -93,6 +92,7 @@ "tests/contracts/test_sessions_resolve_contract.py": 0.01, "tests/contracts/test_sessions_routing_contract.py": 0.01, "tests/contracts/test_sessions_search_contract.py": 0.01, + "tests/contracts/test_turn_commands_contract.py": 0.01, "tests/functional/test_agent_context_boundary_e2e.py": 0.062, "tests/functional/test_gateway_attachment_history_e2e.py": 4.616, "tests/functional/test_gateway_compaction_e2e.py": 0.01, @@ -115,6 +115,7 @@ "tests/test_application/test_provider_configuration.py": 0.01, "tests/test_application/test_sandbox_runtime.py": 0.01, "tests/test_application/test_session_directory.py": 0.01, + "tests/test_application/test_session_history.py": 0.01, "tests/test_application/test_session_lifecycle.py": 0.01, "tests/test_application/test_session_maintenance.py": 0.01, "tests/test_application/test_session_read.py": 0.01, @@ -349,6 +350,7 @@ "tests/test_engine/test_endgame_directive_and_cap_levers.py": 6.429, "tests/test_engine/test_ensemble_progress_forwarding.py": 0.025, "tests/test_engine/test_execution_status_carrier_chain.py": 0.014, + "tests/test_engine/test_file_parse_reliability.py": 0.01, "tests/test_engine/test_final_diff_contract.py": 0.032, "tests/test_engine/test_final_diff_salvage_and_endgame_freeze_levers.py": 8.245, "tests/test_engine/test_finalize_evidence_gate.py": 0.28, @@ -389,6 +391,7 @@ "tests/test_engine/test_provider_mismatch_veto.py": 0.192, "tests/test_engine/test_provider_request_correlation.py": 0.019, "tests/test_engine/test_reasoning_retry_and_deadline_thinking_levers.py": 0.183, + "tests/test_engine/test_reliability_runtime_seams.py": 0.01, "tests/test_engine/test_render_volatile_block.py": 0.046, "tests/test_engine/test_resolve_turn_thinking.py": 0.035, "tests/test_engine/test_route_plan.py": 0.01, @@ -550,6 +553,7 @@ "tests/test_gateway/test_channel_reply_delivery_guard.py": 0.037, "tests/test_gateway/test_channel_session_and_busy_policy.py": 0.048, "tests/test_gateway/test_channel_turn_ingress.py": 11.876, + "tests/test_gateway/test_chat_history_characterization.py": 0.01, "tests/test_gateway/test_chat_meta_command_static.py": 0.01, "tests/test_gateway/test_chat_meta_ribbon_static.py": 0.01, "tests/test_gateway/test_chat_static_assets.py": 0.01, @@ -568,6 +572,7 @@ "tests/test_gateway/test_config_redact_channel_crypto.py": 0.109, "tests/test_gateway/test_config_router_budget.py": 0.121, "tests/test_gateway/test_config_secret_inheritance.py": 0.915, + "tests/test_gateway/test_config_telemetry_consent_sources.py": 0.01, "tests/test_gateway/test_config_tier_provider_mismatch.py": 0.103, "tests/test_gateway/test_config_version.py": 1.372, "tests/test_gateway/test_config_ws_writer_queue.py": 0.448, @@ -576,6 +581,7 @@ "tests/test_gateway/test_control_ui_locale.py": 0.166, "tests/test_gateway/test_conversation_ancillary_adapter.py": 0.01, "tests/test_gateway/test_conversation_ancillary_application.py": 0.01, + "tests/test_gateway/test_conversation_runtime_adapter.py": 0.01, "tests/test_gateway/test_cron_result_payload.py": 0.01, "tests/test_gateway/test_cron_scheduler_adapter.py": 0.01, "tests/test_gateway/test_cron_scheduler_application.py": 0.01, @@ -588,6 +594,7 @@ "tests/test_gateway/test_event_bridge_meta_events.py": 0.01, "tests/test_gateway/test_fair_queuing.py": 0.203, "tests/test_gateway/test_force_reset_drain.py": 0.049, + "tests/test_gateway/test_goal_plan_contract_adapters.py": 0.01, "tests/test_gateway/test_goal_registry_cleanup.py": 0.071, "tests/test_gateway/test_goal_rpc.py": 47.225, "tests/test_gateway/test_graceful_shutdown_drain.py": 0.379, @@ -606,8 +613,11 @@ "tests/test_gateway/test_named_tokens.py": 0.584, "tests/test_gateway/test_no_head_blocking.py": 0.078, "tests/test_gateway/test_no_split_brain_lock.py": 0.09, + "tests/test_gateway/test_observability_adapter.py": 0.01, + "tests/test_gateway/test_observability_application.py": 0.01, "tests/test_gateway/test_origin_guard.py": 5.561, "tests/test_gateway/test_p1a_exact_abort_contract.py": 0.369, + "tests/test_gateway/test_pending_input_queue_adapter.py": 0.01, "tests/test_gateway/test_pidfile_lock.py": 0.16, "tests/test_gateway/test_plan_rpc.py": 6.616, "tests/test_gateway/test_project_workspace_execution.py": 29.107, @@ -664,9 +674,6 @@ "tests/test_gateway/test_rpc_run_mode_preference.py": 0.168, "tests/test_gateway/test_rpc_sandbox_runtime.py": 0.054, "tests/test_gateway/test_rpc_sandbox_setup.py": 0.012, - "tests/test_gateway/test_observability_adapter.py": 0.01, - "tests/test_gateway/test_observability_application.py": 0.01, - "tests/test_gateway/test_pending_input_queue_adapter.py": 0.01, "tests/test_gateway/test_rpc_selflearning_status.py": 0.209, "tests/test_gateway/test_rpc_session_services.py": 0.01, "tests/test_gateway/test_rpc_sessions.py": 35.554, @@ -677,6 +684,7 @@ "tests/test_gateway/test_rpc_skills_install_visibility.py": 1.42, "tests/test_gateway/test_rpc_skills_reload.py": 0.55, "tests/test_gateway/test_rpc_storage_busy.py": 0.025, + "tests/test_gateway/test_rpc_telemetry_consent.py": 0.01, "tests/test_gateway/test_rpc_tools_visibility.py": 0.385, "tests/test_gateway/test_rpc_usage.py": 0.094, "tests/test_gateway/test_rpc_wizard.py": 0.01, @@ -688,16 +696,12 @@ "tests/test_gateway/test_sandbox_v2_routes.py": 0.148, "tests/test_gateway/test_search_runtime_config_gateway.py": 0.01, "tests/test_gateway/test_security_headers.py": 0.037, - "tests/test_application/test_session_history.py": 0.01, - "tests/test_gateway/test_session_model_routing.py": 0.01, - "tests/test_gateway/test_session_model_routing_rpc.py": 0.01, - "tests/test_gateway/test_chat_history_characterization.py": 0.01, - "tests/test_gateway/test_conversation_runtime_adapter.py": 0.01, - "tests/test_gateway/test_goal_plan_contract_adapters.py": 0.01, - "tests/test_gateway/test_session_preview_adapter.py": 0.01, "tests/test_gateway/test_session_history_adapter.py": 0.01, "tests/test_gateway/test_session_lifecycle_adapter.py": 0.01, "tests/test_gateway/test_session_maintenance_adapter.py": 0.01, + "tests/test_gateway/test_session_model_routing.py": 0.01, + "tests/test_gateway/test_session_model_routing_rpc.py": 0.01, + "tests/test_gateway/test_session_preview_adapter.py": 0.01, "tests/test_gateway/test_session_read_adapter.py": 0.01, "tests/test_gateway/test_session_read_contract_registration.py": 0.01, "tests/test_gateway/test_session_streams.py": 0.038, @@ -711,10 +715,9 @@ "tests/test_gateway/test_skill_catalog_application.py": 0.01, "tests/test_gateway/test_skill_management_adapter.py": 0.01, "tests/test_gateway/test_skill_management_application.py": 0.01, + "tests/test_gateway/test_skill_management_service_injection.py": 0.734, "tests/test_gateway/test_skill_proposal_review_adapter.py": 0.01, "tests/test_gateway/test_skill_proposal_review_application.py": 0.01, - "tests/test_gateway/test_turn_admission_adapter.py": 0.01, - "tests/test_gateway/test_skill_management_service_injection.py": 0.734, "tests/test_gateway/test_spawn_group_pagination.py": 0.01, "tests/test_gateway/test_spawn_group_tracker.py": 0.01, "tests/test_gateway/test_static_cache_header.py": 0.574, @@ -732,9 +735,11 @@ "tests/test_gateway/test_task_runtime_terminal_message.py": 4.014, "tests/test_gateway/test_task_runtime_turn_committed.py": 0.01, "tests/test_gateway/test_task_session_lifecycle.py": 1.051, + "tests/test_gateway/test_telemetry_runtime_wiring.py": 0.01, "tests/test_gateway/test_terminal_activity.py": 0.01, "tests/test_gateway/test_terminal_reply.py": 0.045, "tests/test_gateway/test_transcript_attachment_persistence.py": 0.135, + "tests/test_gateway/test_turn_admission_adapter.py": 0.01, "tests/test_gateway/test_turn_committed_sqlite_timing.py": 0.01, "tests/test_gateway/test_turn_ingress_fork.py": 2.731, "tests/test_gateway/test_turn_ingress_intents.py": 19.925, @@ -933,8 +938,8 @@ "tests/test_provider/test_error_secret_boundary.py": 0.194, "tests/test_provider/test_failure_classification_parity.py": 0.353, "tests/test_provider/test_failure_injection.py": 0.066, - "tests/test_provider/test_live_catalog.py": 0.089, "tests/test_provider/test_list_models_provider_identity.py": 0.01, + "tests/test_provider/test_live_catalog.py": 0.089, "tests/test_provider/test_models_dev_snapshot_costs.py": 0.01, "tests/test_provider/test_preset_registry.py": 0.04, "tests/test_provider/test_reasoning_dialects.py": 0.021, @@ -1345,6 +1350,31 @@ "tests/test_skills_tree.py": 0.515, "tests/test_squilla_router_runtime_diagnostics.py": 0.01, "tests/test_subprocess_encoding.py": 0.069, + "tests/test_telemetry/test_client_runtime.py": 0.01, + "tests/test_telemetry/test_consent.py": 0.01, + "tests/test_telemetry/test_contracts.py": 0.01, + "tests/test_telemetry/test_desktop_ingress.py": 0.01, + "tests/test_telemetry/test_desktop_state.py": 0.01, + "tests/test_telemetry/test_file_parse_facts.py": 0.01, + "tests/test_telemetry/test_growth_eligibility.py": 0.01, + "tests/test_telemetry/test_growth_sink.py": 0.01, + "tests/test_telemetry/test_growth_state.py": 0.01, + "tests/test_telemetry/test_identity.py": 0.01, + "tests/test_telemetry/test_outbox.py": 0.01, + "tests/test_telemetry/test_privacy.py": 0.01, + "tests/test_telemetry/test_protocol_manifest_artifact.py": 0.01, + "tests/test_telemetry/test_recorder.py": 0.01, + "tests/test_telemetry/test_reliability_sink.py": 0.01, + "tests/test_telemetry/test_server_growth_producer.py": 0.01, + "tests/test_telemetry/test_uploader.py": 0.01, + "tests/test_telemetry_server/test_collector.py": 0.01, + "tests/test_telemetry_server/test_dashboard.py": 0.01, + "tests/test_telemetry_server/test_dashboard_auth.py": 0.01, + "tests/test_telemetry_server/test_dashboard_queries.py": 0.01, + "tests/test_telemetry_server/test_legacy_installations.py": 0.01, + "tests/test_telemetry_server/test_producer_auth.py": 0.01, + "tests/test_telemetry_server/test_runtime.py": 0.01, + "tests/test_telemetry_server/test_storage.py": 0.01, "tests/test_thinking_level_propagation.py": 0.217, "tests/test_toolcomp_matcher_levers.py": 0.212, "tests/test_toolcomp_matcher_safety.py": 0.37, @@ -1404,8 +1434,8 @@ "tests/test_tools/test_plan_access.py": 1.6, "tests/test_tools/test_policy_agents.py": 0.061, "tests/test_tools/test_policy_config_boundary.py": 0.13, - "tests/test_tools/test_presentation_rules.py": 0.01, "tests/test_tools/test_policy_runtime_boundary.py": 0.026, + "tests/test_tools/test_presentation_rules.py": 0.01, "tests/test_tools/test_projected_arguments.py": 0.03, "tests/test_tools/test_registry_visibility.py": 0.134, "tests/test_tools/test_registry_visibility_boundary.py": 0.03, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3c1b156b9..8e57e7e573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -768,6 +768,12 @@ jobs: node scripts/test-onboarding-flow-coordinator.mjs node scripts/test-onboarding-save-telemetry.mjs node scripts/test-ci-case-telemetry.mjs + node scripts/test-telemetry-consent.mjs + node scripts/test-telemetry-early-spool.mjs + node scripts/test-telemetry-build-identity.mjs + node scripts/test-telemetry-reliability.mjs + node scripts/test-telemetry-growth.mjs + node scripts/test-telemetry-protocol.mjs node ../../.github/scripts/verify-sandbox-package.mjs --source ubuntu-quality: diff --git a/PRIVACY.md b/PRIVACY.md index a1d4219437..dabd34cabd 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -43,13 +43,11 @@ corresponding feature is enabled by configuration or user action. ## Network Observability Controls -OpenSquilla groups background network observability and the optional -pseudonymous installation identifier attached to official TokenRhythm API -requests under one switch. Enable it to disable automatic install telemetry, -daily aggregate usage telemetry, passive update checks, automatic desktop -update checks, and that TokenRhythm request identifier. Changes to the -TokenRhythm identifier policy apply to the next request without requiring a -restart: +OpenSquilla exposes separate consent controls for **Reliability diagnostics** +and **Product and growth analytics**. An unset choice, an explicit decline, an +incomplete consent receipt, or a stale notice version is treated as disabled. +The global control below is a hard veto over both telemetry scopes, passive +update checks, and automatic desktop update checks: ```sh OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY=true @@ -69,9 +67,10 @@ OPENSQUILLA_TELEMETRY_DISABLED=true OPENSQUILLA_UPDATE_CHECK_DISABLED=true ``` -`OPENSQUILLA_TELEMETRY_DISABLED=true` also suppresses the optional TokenRhythm -installation identifier. Setting only -`OPENSQUILLA_UPDATE_CHECK_DISABLED=true` does not suppress it. +`OPENSQUILLA_TELEMETRY_DISABLED=true` remains a hard veto for both new +telemetry scopes. It does not grant consent and does not reactivate retired +legacy telemetry. `OPENSQUILLA_UPDATE_CHECK_DISABLED=true` applies only to +update checks. Manual user-initiated actions may still contact network services after user intent, including release downloads and configured providers, search, channels, @@ -79,98 +78,66 @@ automation, or integrations. Update-availability checks, including `opensquilla version --check` and the desktop manual check, do not bypass the unified or legacy opt-out controls. -## Installation Telemetry - -OpenSquilla uses pseudonymous installation telemetry to estimate install -counts, version adoption, and runtime compatibility. Telemetry is sent on first -gateway startup and once per OpenSquilla version. Uploads use a short timeout -and never block startup. - -Telemetry payloads include: - -- schema version -- locally generated stable `install_id` digest -- OpenSquilla version -- event type, such as `install` or `version_seen` -- install method, such as `pip`, `source`, `docker`, `desktop`, or `unknown` -- operating system, OS version, CPU architecture, and Python major/minor version -- first-seen and sent timestamps -- CI/test-environment marker - -The `install_id` is a local one-way SHA-256 digest derived from usable MAC -addresses, then local IP addresses when no MAC is available, with a random -persisted fallback. Raw MAC addresses and raw IP addresses are not uploaded. - -Telemetry does not include usernames, hostnames, local paths, API keys, -provider configuration, chat content, session content, memory content, agent -content, file names, or file contents. Source IP addresses may be visible to -HTTP servers at the transport layer, but are not part of the telemetry payload. - -Use the unified network observability switch above to opt out before startup. -The legacy telemetry opt-out `OPENSQUILLA_TELEMETRY_DISABLED=true` remains -honored for compatibility. - -CI and test environments automatically suppress installation telemetry before -an installation identifier is generated or uploaded. - -Advanced deployments can direct installation and usage telemetry to independent -routes on their own service: - -```sh -OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install -OPENSQUILLA_USAGE_TELEMETRY_ENDPOINT=https://example.com/v1/usage -``` - -## TokenRhythm Installation Identifier - -By default, OpenSquilla may add this optional header to requests sent directly -to the official TokenRhythm HTTPS API: - -```http -X-OpenSquilla-Install-Id: -``` - -This is a pseudonymous installation-level identifier. It is stable across -sessions and reuses the same locally persisted `install_id` described above, -including its MAC-address, local-IP, and random persisted fallback order. Raw -MAC addresses and raw IP addresses are never placed in the header or sent as -part of the request. Identifier resolution happens in the background; if it is -not ready or fails validation, OpenSquilla omits the header and continues the -request normally. - -The header is allowed only for direct API targets on -`https://tokenrhythm.studio` and `https://api.tokenrhythm.studio`, using the -default HTTPS port or an explicit port `443`. OpenSquilla does not attach it to -HTTP URLs, URLs with user information, nonstandard ports, lookalike domains, -custom proxies, OpenRouter, other providers, browser registration pages, -returned image or CDN downloads, or redirected nonofficial targets. It is not -placed in request bodies or query strings, and provider traces record only -whether it was present, not its value. The raw value is also excluded from -logs, errors, and serialized configuration. - -The unified network-observability switch and the legacy telemetry opt-out both -suppress generation and transmission of this header. CI and test environments -suppress it automatically. The legacy update-check opt-out alone does not. - -TokenRhythm services must treat this header as optional and untrusted. It must -not be used for authentication, authorization, billing, rate limiting, or -anti-abuse decisions. - -## Daily Aggregate Usage Telemetry - -OpenSquilla uses the same telemetry service with a dedicated `/v1/usage` route -and the unified network observability switch for content-free daily usage -aggregates. It records only completed top-level interactive turns. While the -gateway is running, it attempts to upload pending cumulative UTC-day snapshots -at startup and once per hour, including the current day. Heartbeats, scheduled -jobs, subagents, and incomplete turns are excluded. - -Daily payloads include the existing `install_id`, OpenSquilla version, UTC day, -send timestamp, a retry-stable event ID, completed conversation count, and -aggregate input, output, cached, and cache-write token counts. They do not -include prompts, responses, provider or model names, channels, session -identifiers, costs, tools, file names, or file contents. Failed uploads remain -pending locally and are retried later. +## Optional Telemetry + +### Reliability diagnostics + +When separately consented for the current notice, OpenSquilla may record the +result, bounded duration, enumerated error code, and other closed attributes +for app startup, Gateway startup, detected crashes, AI turns, tool calls, file +parsing, updates, and session performance. Reliability uses a random +`app_session_id`; it does not use an account identifier. Crash events contain +only a one-way error fingerprint, component, version, and bounded runtime facts. +Complete exception messages and stacks remain local. + +### Product and growth analytics + +When separately consented for the current notice, OpenSquilla may record +one-time funnel milestones for acquisition, onboarding completion, first app +readiness, registration, first turn start, and first successful response. +Growth uses random, purpose-specific `acquisition_id` and +`analytics_user_id` values. The analytics user ID is not a raw account ID or a +hash of one, is not shared with Reliability, and is deleted locally when Growth +consent is withdrawn. + +Website, CDN, and account-service milestones must be emitted by those services +at their authoritative transaction boundary. They use independent server-side +signing credentials that are never shipped in browser JavaScript, installers, +or the desktop app. Ordinary installers without a consented, signed acquisition +token do not emit installation events, and the desktop does not infer an +external registration result. + +### Collection and upload rules + +Both scopes use a strict field whitelist and reject unknown fields. They write +to separate bounded local SQLite queues and upload batches to separate routes: +`/v1/reliability/events` and `/v1/growth/events`. Consent is checked before +local collection and again immediately before network upload. Offline retries +reuse `event_id` for deduplication. Growth events are not sampled. + +Telemetry payloads never include prompts, responses, provider configuration, +agent configuration, tool arguments, task parameters, file names, file paths, +file contents, raw exception messages, complete stacks, usernames, hostnames, +API keys, raw account IDs, order data, IP addresses, MAC addresses, or device +fingerprints. Source IP addresses may be visible to network servers at the +transport layer, but are not telemetry fields and are never used to join +website and client identities. + +CI, test, and `DO_NOT_TRACK` environments fail closed for both scopes. A remote +or local forced-off state pauses sending without manufacturing or changing a +saved consent decision. Withdrawing a scope's consent deletes that scope's +pending local telemetry; withdrawing Growth consent also deletes its local +analytics identity. + +### Retired legacy telemetry + +The automatic installation upload at `/v1/install`, the daily token aggregate +at `/v1/usage`, and the `X-OpenSquilla-Install-Id` provider header are retired. +Production code no longer starts those upload loops, records daily usage for +them, derives an installation identifier from MAC or local IP data, or attaches +that identifier to provider requests. Legacy modules and environment-variable +names remain only for source/configuration compatibility and cannot opt a user +into telemetry v2. ## Logs And Diagnostics diff --git a/README.md b/README.md index e5cf0e1d72..d658149df3 100644 --- a/README.md +++ b/README.md @@ -389,54 +389,34 @@ full reference. --- -## Installation Privacy - -OpenSquilla uses pseudonymous installation telemetry to estimate install -counts, version adoption, and runtime compatibility. Data is sent on first -gateway startup and once per OpenSquilla version. It also records content-free daily -aggregates of completed top-level conversations and token usage by UTC date, -and attempts to upload pending cumulative UTC-day snapshots to the telemetry -service at startup and once per hour. OpenSquilla may also make -passive update checks, including automatic desktop update checks at startup -and, while the app remains open, at most once per day. Uploads use a short -timeout and never block startup. - -See [`PRIVACY.md`](PRIVACY.md) for the full privacy policy covering local data, -provider requests, network observability, logs, release downloads, and deletion. - -What is sent: - -- schema version -- locally generated stable `install_id` digest -- OpenSquilla version -- event type (`install`, `version_seen`, or `daily_usage`) -- install method (`pip`, `source`, `docker`, `desktop`, or `unknown`) -- operating system, OS version, CPU architecture, and Python major/minor - version -- first-seen and sent timestamps -- CI/test-environment marker (`ci_environment`) -- completed UTC day, conversation count, and aggregate input/output/cache/cache-write - token counts for daily-usage events - -The `install_id` is a local one-way SHA-256 digest derived from usable MAC -addresses, then local IP addresses when no MAC is available, with a random -persisted fallback. Raw MAC/IP values are not uploaded. - -By default, requests sent directly to the official TokenRhythm HTTPS API may -also carry the same pseudonymous, cross-session installation identifier in the -optional `X-OpenSquilla-Install-Id` header. Only the exact official -`tokenrhythm.studio` and `api.tokenrhythm.studio` HTTPS hosts on port 443 are -eligible; custom proxies, OpenRouter, other providers, browser pages, -redirected nonofficial targets, and returned image/CDN downloads are excluded. -The raw MAC/IP values are never sent. The header is omitted if its background -resolution is not ready or fails, so requests continue normally. - -What is not sent: usernames, hostnames, paths, API keys, provider config, -chat/session/memory/agent content, file names, or file contents. Source IP may -be visible to HTTP servers at the transport layer, but is not part of the -payload. - -To disable non-user-initiated network observability before startup: +## Telemetry Privacy + +OpenSquilla has two isolated, optional telemetry scopes. Both remain off until +the user saves consent for the current notice version in Privacy settings: + +- **Reliability diagnostics** records bounded operation results for app and + Gateway startup, crashes, turns, tools, file parsing, updates, and session + performance. +- **Product and growth analytics** records one-time acquisition, onboarding, + app-readiness, registration, and first-successful-turn milestones. + +Each scope has its own consent, random purpose-specific identifier, durable +queue, upload endpoint, retention policy, and deletion path. Reliability events +go to `/v1/reliability/events`; growth events go to `/v1/growth/events`. +Retries reuse `event_id` for server-side deduplication, and growth events are +not sampled. + +Telemetry never includes prompts, responses, file names, file paths, file +contents, tool arguments, task parameters, provider configuration, raw account +IDs, order data, MAC addresses, IP addresses, or device fingerprints. Complete +crash stacks stay local unless the user explicitly prepares and shares a +support bundle. + +The former automatic `/v1/install` upload, `/v1/usage` daily token aggregate, +and `X-OpenSquilla-Install-Id` provider header are retired. OpenSquilla no +longer creates or sends an identifier derived from a MAC address or local IP. + +To force all non-user-initiated network observability off before startup: ```sh OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY=true @@ -449,15 +429,13 @@ or set: disable_network_observability = true ``` -That unified switch covers automatic install telemetry, daily aggregate usage -telemetry, passive update checks, and automatic desktop update checks at -startup and during long-running app sessions, as well as the TokenRhythm -installation header. Explicit update-availability checks remain disabled while -the unified or legacy opt-out is active. CI and test environments also -suppress the installation header and installation telemetry automatically. -Other user-initiated actions may still -contact network services after user intent, including release downloads and -configured providers, search, or channels. +This is a hard veto over both telemetry scopes, passive update checks, and +automatic desktop update checks. It does not create, erase, or replace either +saved per-scope consent decision. CI, test, and `DO_NOT_TRACK` environments +also fail closed for telemetry. Other user-initiated actions may still contact +configured providers, search services, channels, or release hosts. +Explicit update-availability checks remain disabled while the unified or +legacy update opt-out controls are active. Legacy opt-out environment variables remain honored: @@ -466,18 +444,9 @@ OPENSQUILLA_TELEMETRY_DISABLED=true OPENSQUILLA_UPDATE_CHECK_DISABLED=true ``` -The legacy telemetry opt-out suppresses the TokenRhythm installation header; -the update-check opt-out by itself does not. TokenRhythm must treat the header -as optional and untrusted, and must not use it for authentication, -authorization, billing, rate limiting, or anti-abuse decisions. See -[`PRIVACY.md`](PRIVACY.md#tokenrhythm-installation-identifier) for the complete -target and data-handling rules. - -Advanced deployments can use their own installation telemetry endpoint: - -```sh -OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install -``` +The legacy telemetry variable is retained only as a global telemetry veto; it +does not re-enable the retired endpoints. See [`PRIVACY.md`](PRIVACY.md) for +the complete data, consent, deletion, update, and external-producer rules. --- diff --git a/contracts/gateway/v4/telemetry/telemetry-client-launch-record.schema.json b/contracts/gateway/v4/telemetry/telemetry-client-launch-record.schema.json new file mode 100644 index 0000000000..0622e5e75e --- /dev/null +++ b/contracts/gateway/v4/telemetry/telemetry-client-launch-record.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opensquilla.dev/contracts/gateway/v4/telemetry/telemetry-client-launch-record.schema.json", + "title": "OpenSquilla telemetry.client_launch.record Contract", + "type": "object", + "additionalProperties": false, + "properties": { + "request": { "$ref": "#/$defs/Request" }, + "response": { "$ref": "#/$defs/Response" } + }, + "x-opensquilla-wire": { + "protocol": "opensquilla-websocket-json", + "version": 4, + "compatibility": "exact-json-tree" + }, + "x-opensquilla-codegen": { + "python": { "tool": "datamodel-code-generator", "version": "0.75.1", "target": "pydantic_v2.BaseModel" }, + "typescript": { "tool": "json-schema-to-typescript", "version": "15.0.4" }, + "runtimeValidation": { "tool": "ajv", "version": "8.17.1", "mode": "standalone-adapter-only" } + }, + "x-opensquilla-method": { + "name": "telemetry.client_launch.record", + "kind": "command", + "scope": "operator.write", + "guestAllowed": false, + "idempotency": "idempotent", + "timeout": { "policy": "transport" }, + "capability": { "kind": "method-availability", "name": "telemetry.client_launch.record" }, + "request": "#/$defs/Request", + "params": "#/$defs/Params", + "response": "#/$defs/Response", + "result": "#/$defs/Result", + "errors": [ + { "code": "INVALID_REQUEST" }, + { "code": "UNAUTHORIZED" }, + { "code": "INTERNAL_ERROR" } + ] + }, + "$defs": { + "Params": { "type": "object", "additionalProperties": false }, + "Request": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id", "method"], + "properties": { + "type": { "const": "req" }, + "id": { "type": "string" }, + "method": { "const": "telemetry.client_launch.record" }, + "params": { + "anyOf": [ + { "$ref": "#/$defs/Params" }, + { "type": "null" } + ] + } + } + }, + "Result": { + "type": "object", + "additionalProperties": false, + "required": ["recorded"], + "properties": { "recorded": { "type": "boolean" } } + }, + "RpcError": { + "type": "object", + "additionalProperties": true, + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + }, + "Ok": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id", "ok", "payload"], + "properties": { + "type": { "const": "res" }, + "id": { "type": "string" }, + "ok": { "const": true }, + "payload": { "$ref": "#/$defs/Result" } + } + }, + "Err": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id", "ok", "error"], + "properties": { + "type": { "const": "res" }, + "id": { "type": "string" }, + "ok": { "const": false }, + "payload": { "type": "null" }, + "error": { "$ref": "#/$defs/RpcError" } + } + }, + "Response": { "oneOf": [{ "$ref": "#/$defs/Ok" }, { "$ref": "#/$defs/Err" }] } + } +} diff --git a/contracts/gateway/v4/telemetry/telemetry-consent-set.schema.json b/contracts/gateway/v4/telemetry/telemetry-consent-set.schema.json new file mode 100644 index 0000000000..5d6b76b54f --- /dev/null +++ b/contracts/gateway/v4/telemetry/telemetry-consent-set.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opensquilla.dev/contracts/gateway/v4/telemetry/telemetry-consent-set.schema.json", + "title": "OpenSquilla telemetry.consent.set Contract", + "type": "object", + "additionalProperties": false, + "properties": { + "request": { "$ref": "#/$defs/Request" }, + "response": { "$ref": "#/$defs/Response" } + }, + "x-opensquilla-wire": { + "protocol": "opensquilla-websocket-json", + "version": 4, + "compatibility": "exact-json-tree" + }, + "x-opensquilla-codegen": { + "python": { "tool": "datamodel-code-generator", "version": "0.75.1", "target": "pydantic_v2.BaseModel" }, + "typescript": { "tool": "json-schema-to-typescript", "version": "15.0.4" }, + "runtimeValidation": { "tool": "ajv", "version": "8.17.1", "mode": "standalone-adapter-only" } + }, + "x-opensquilla-method": { + "name": "telemetry.consent.set", + "kind": "command", + "scope": "operator.write", + "guestAllowed": false, + "idempotency": "idempotent", + "timeout": { "policy": "transport" }, + "capability": { "kind": "method-availability", "name": "telemetry.consent.set" }, + "request": "#/$defs/Request", + "params": "#/$defs/Params", + "response": "#/$defs/Response", + "result": "#/$defs/Result", + "errors": [ + { "code": "INVALID_REQUEST" }, + { "code": "UNAUTHORIZED" }, + { "code": "UNAVAILABLE", "retryable": true }, + { "code": "TELEMETRY_CONSENT_FORCED_OFF" }, + { "code": "TELEMETRY_CONSENT_PERSIST_FAILED", "retryable": true }, + { "code": "TELEMETRY_CONSENT_CLEANUP_FAILED", "retryable": true }, + { "code": "TELEMETRY_CONSENT_MIRROR_FAILED", "retryable": true }, + { "code": "INTERNAL_ERROR" } + ] + }, + "$defs": { + "Params": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "enabled"], + "properties": { + "scope": { "enum": ["reliability", "growth"] }, + "enabled": { "type": "boolean" } + } + }, + "Request": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id", "method", "params"], + "properties": { + "type": { "const": "req" }, + "id": { "type": "string" }, + "method": { "const": "telemetry.consent.set" }, + "params": { "$ref": "#/$defs/Params" } + } + }, + "Result": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "enabled", "noticeVersion", "consentedAtUtc", "changed", "cleanupPerformed", "cleanupComplete"], + "properties": { + "scope": { "enum": ["reliability", "growth"] }, + "enabled": { "type": "boolean" }, + "noticeVersion": { "type": ["string", "null"] }, + "consentedAtUtc": { "type": ["string", "null"] }, + "changed": { "type": "boolean" }, + "cleanupPerformed": { "type": "boolean" }, + "cleanupComplete": { "const": true } + } + }, + "RpcError": { + "type": "object", + "additionalProperties": true, + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + }, + "Ok": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id", "ok", "payload"], + "properties": { + "type": { "const": "res" }, + "id": { "type": "string" }, + "ok": { "const": true }, + "payload": { "$ref": "#/$defs/Result" } + } + }, + "Err": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id", "ok", "error"], + "properties": { + "type": { "const": "res" }, + "id": { "type": "string" }, + "ok": { "const": false }, + "payload": { "type": "null" }, + "error": { "$ref": "#/$defs/RpcError" } + } + }, + "Response": { "oneOf": [{ "$ref": "#/$defs/Ok" }, { "$ref": "#/$defs/Err" }] } + } +} diff --git a/desktop/electron/package.json b/desktop/electron/package.json index 723b66acdc..6833cac7b9 100644 --- a/desktop/electron/package.json +++ b/desktop/electron/package.json @@ -56,6 +56,12 @@ "test:mock-update-flow": "npm run build && node scripts/test-mock-update-flow.mjs", "test:onboarding-coordinator": "npm run build && node scripts/test-onboarding-flow-coordinator.mjs", "test:onboarding-telemetry": "npm run build && node scripts/test-onboarding-save-telemetry.mjs", + "test:telemetry-consent": "npm run build && node scripts/test-telemetry-consent.mjs", + "test:telemetry-early-spool": "npm run build && node scripts/test-telemetry-early-spool.mjs", + "test:telemetry-protocol": "npm run build && node scripts/test-telemetry-protocol.mjs", + "test:telemetry-build-identity": "npm run build && node scripts/test-telemetry-build-identity.mjs", + "test:telemetry-reliability": "npm run build && node scripts/test-telemetry-reliability.mjs", + "test:telemetry-growth": "npm run build && node scripts/test-telemetry-growth.mjs", "test:router-tier-normalization": "npm run build && node scripts/test-router-tier-normalization.mjs", "test:onboarding-flow": "npm run build && node scripts/test-onboarding-flow.mjs", "start": "electron .", diff --git a/desktop/electron/scripts/test-onboarding-flow.mjs b/desktop/electron/scripts/test-onboarding-flow.mjs index 57f8533cc1..a04db1dba5 100644 --- a/desktop/electron/scripts/test-onboarding-flow.mjs +++ b/desktop/electron/scripts/test-onboarding-flow.mjs @@ -1,5 +1,5 @@ import { strict as assert } from 'node:assert' -import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' @@ -51,6 +51,15 @@ async function fileExists(path) { } } +async function readDirectoryOrEmpty(path) { + try { + return await readdir(path) + } catch (error) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + async function startOnboardingProbeServer(initialMode = 'success') { let mode = initialMode const requests = [] @@ -116,6 +125,12 @@ async function setOnboardingBaseUrl(page, baseUrl) { }, baseUrl) } +function isManagedTelemetrySpoolEntry(name) { + return name.endsWith('.ready') + || name.includes('.processing.') + || (name.startsWith('.') && name.endsWith('.tmp')) +} + async function readOnboardingTelemetry(userDataDir) { const source = await readFile(join(userDataDir, 'logs', 'desktop.log'), 'utf8') return source @@ -478,6 +493,11 @@ async function installPendingSaveStub(app) { }) } +async function chooseTelemetryConsent(page, reliability = false, growth = false) { + await page.locator(`input[name="reliabilityDiagnosticsEnabled"][value="${reliability}"]`).check() + await page.locator(`input[name="productAnalyticsEnabled"][value="${growth}"]`).check() +} + async function pendingSaveState(app) { return await app.evaluate(() => { const state = globalThis.__opensquillaOnboardingSaveTest @@ -594,6 +614,7 @@ async function verifySubmitFeedbackAndSingleFlight() { const apiKey = page.locator('#apiKey') await apiKey.fill('synthetic-submit-key') await page.locator('#onboardingLocale').selectOption('de') + await chooseTelemetryConsent(page, true, false) await installPendingSaveStub(app) await page.locator('#providerSelectToggle').click() @@ -626,6 +647,8 @@ async function verifySubmitFeedbackAndSingleFlight() { await assertSubmitActionsDoNotOverlap(page) const firstState = await pendingSaveState(app) assert.equal(firstState.lastPayload?.apiKey, 'synthetic-submit-key') + assert.equal(firstState.lastPayload?.reliabilityDiagnosticsEnabled, true) + assert.equal(firstState.lastPayload?.productAnalyticsEnabled, false) await page.locator('#finish').evaluate((button) => { button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) @@ -725,6 +748,7 @@ async function verifyProbeBeforePersistenceAndRetry() { await page.locator('[data-provider-option="openai"]').click() await page.locator('#apiKey').fill(syntheticKey) await setOnboardingBaseUrl(page, probeServer.baseUrl) + await chooseTelemetryConsent(page, true, false) const submittedModel = await page.locator('#model').inputValue() await page.locator('#finish').click() @@ -795,7 +819,10 @@ try { const desktopPage = await waitFor(async () => { for (const candidate of app.windows()) { if (candidate.isClosed()) continue - if (candidate.url().startsWith('opensquilla-app://desktop/')) return candidate + if ( + candidate.url().startsWith('opensquilla-app://desktop/') + && await candidate.locator('#app').count() === 1 + ) return candidate } return null }, 'local Desktop renderer') @@ -1110,6 +1137,42 @@ try { assert.equal(await page.locator('#searchApiKeyError').innerText(), '') assert.equal(await page.locator('#apiKey').inputValue(), 'synthetic-tokenrhythm-key') await setOnboardingBaseUrl(page, successfulProbeServer.baseUrl) + assert.equal( + await page.locator('input[name="reliabilityDiagnosticsEnabled"]:checked').count(), + 0, + 'stability diagnostics must remain unset until the user chooses', + ) + assert.equal( + await page.locator('input[name="productAnalyticsEnabled"]:checked').count(), + 0, + 'growth analytics must remain unset until the user chooses', + ) + await page.locator('#finish').click() + assert.match(await page.locator('#telemetryConsentError').innerText(), /Choose an option for both data categories/) + assert.equal( + await page.locator('input[name="reliabilityDiagnosticsEnabled"][value="true"]').getAttribute('aria-invalid'), + 'true', + ) + const earlySpoolRoot = join( + userDataDir, + 'opensquilla', + 'state', + 'telemetry', + 'desktop-early-spool', + ) + await mkdir(join(earlySpoolRoot, 'reliability'), { recursive: true }) + await mkdir(join(earlySpoolRoot, 'growth'), { recursive: true }) + await writeFile(join(earlySpoolRoot, 'reliability', 'keep.ready'), '{}') + await writeFile(join(earlySpoolRoot, 'growth', 'remove.ready'), '{}') + const growthIdentityPath = join( + userDataDir, + 'opensquilla', + 'state', + 'telemetry', + 'growth_identity.json', + ) + await writeFile(growthIdentityPath, '{"synthetic":"identity"}\n') + await chooseTelemetryConsent(page, true, false) await page.locator('#finish').click() const saved = await waitFor(async () => { @@ -1129,6 +1192,61 @@ try { assert.equal(credential.provider, 'tokenrhythm') assert.equal(credential.modelRoutingMode, 'squilla_router') assert.equal(credential.routerMode, 'recommended') + assert.match(config, /reliability_diagnostics_enabled = true/) + assert.match(config, /reliability_notice_version = "reliability-v1"/) + assert.match(config, /reliability_consented_at_utc = "[^"\r\n]+Z"/) + assert.match(config, /product_analytics_enabled = false/) + assert.doesNotMatch(config, /product_analytics_notice_version/) + assert.doesNotMatch(config, /product_analytics_consented_at_utc/) + const consentMirror = JSON.parse(await readFile( + join(userDataDir, 'opensquilla', 'state', 'telemetry', 'desktop-consent-mirror.json'), + 'utf8', + )) + assert.deepEqual(consentMirror.reliability, { + enabled: true, + notice_version: 'reliability-v1', + consented_at_utc: config.match(/reliability_consented_at_utc = "([^"\r\n]+)"/)?.[1], + forced_off: false, + }) + assert.deepEqual(consentMirror.growth, { + enabled: false, + notice_version: null, + consented_at_utc: null, + forced_off: false, + }) + const reliabilityCollectionSuppressed = [ + 'OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY', + 'OPENSQUILLA_TELEMETRY_DISABLED', + 'DO_NOT_TRACK', + 'OPENSQUILLA_PRIVACY_DISABLE_RELIABILITY_DIAGNOSTICS', + 'CI', + 'GITHUB_ACTIONS', + 'OPENSQUILLA_TESTING', + ].some((name) => ['1', 'true', 'yes', 'on'].includes( + String(process.env[name] || '').trim().toLowerCase(), + )) || String(process.env.PYTEST_CURRENT_TEST || '').trim().length > 0 + const expectedReliabilitySpool = reliabilityCollectionSuppressed + ? ['keep.ready'] + : ['.desktop-reliability-session.tmp', 'keep.ready'] + const reliabilitySpool = await waitFor(async () => { + const entries = (await readDirectoryOrEmpty(join(earlySpoolRoot, 'reliability'))).sort() + return JSON.stringify(entries) === JSON.stringify(expectedReliabilitySpool) ? entries : null + }, 'consent-gated reliability session marker') + assert.deepEqual( + reliabilitySpool, + expectedReliabilitySpool, + reliabilityCollectionSuppressed + ? 'automated environments must preserve existing local events without collecting new ones' + : 'enabling reliability must preserve its queued event and start the consent-gated session marker', + ) + const remainingGrowthSpool = await readDirectoryOrEmpty(join(earlySpoolRoot, 'growth')) + assert.deepEqual(remainingGrowthSpool, []) + assert.equal(remainingGrowthSpool.some(isManagedTelemetrySpoolEntry), false) + await assert.rejects( + () => readFile(growthIdentityPath), + (error) => error?.code === 'ENOENT', + 'declining growth analytics must delete the random analytics identity', + ) assert.equal(credential.routerDefaultTier, 'c1') assert.equal(credential.model, 'deepseek-v4-pro-0813') assert.equal(credential.routerTiers.c0.model, 'deepseek-v4-flash-0731') diff --git a/desktop/electron/scripts/test-profile-import-flow.mjs b/desktop/electron/scripts/test-profile-import-flow.mjs index 02a36d4839..cded68b6e0 100644 --- a/desktop/electron/scripts/test-profile-import-flow.mjs +++ b/desktop/electron/scripts/test-profile-import-flow.mjs @@ -298,6 +298,11 @@ async function onboardingPage(app) { }, 'Desktop onboarding') } +async function chooseTelemetryConsent(page, reliability = false, growth = false) { + await page.locator(`input[name="reliabilityDiagnosticsEnabled"][value="${reliability}"]`).check() + await page.locator(`input[name="productAnalyticsEnabled"][value="${growth}"]`).check() +} + async function captureOnboarding(app, path) { const base64 = await app.evaluate(async ({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows().find((candidate) => ( @@ -351,6 +356,7 @@ async function selectOllamaAndCompleteOnboarding(page) { if (!(await page.locator('#model').inputValue()).trim()) { await page.locator('#model').fill('synthetic-local-model') } + await chooseTelemetryConsent(page, false, false) await page.locator('#finish').click() } @@ -584,6 +590,7 @@ try { join(settingsUserData, 'migration-provider-setup.json'), ) await requiredKeyOnboarding.locator('#apiKey').fill('synthetic-new-imported-key') + await chooseTelemetryConsent(requiredKeyOnboarding, false, false) await requiredKeyOnboarding.locator('#finish').click() const rejectedProbeError = await waitFor(async () => { @@ -643,10 +650,18 @@ try { 'Bearer synthetic-new-imported-key', ) assert.equal(JSON.parse(fakeProvider.requests[1].body).model, 'gpt-5.4-mini') - assert.deepEqual( - await readFile(join(settingsTarget, 'config.toml')), - importedConfigBeforeCredential, - 'provider adoption rewrote imported config.toml', + const adoptedConfig = await readFile(join(settingsTarget, 'config.toml'), 'utf8') + assert.match(adoptedConfig, /reliability_diagnostics_enabled = false/) + assert.match(adoptedConfig, /product_analytics_enabled = false/) + assert.doesNotMatch(adoptedConfig, /(?:reliability|product_analytics)_notice_version/) + assert.doesNotMatch(adoptedConfig, /(?:reliability|product_analytics)_consented_at_utc/) + assert.equal( + adoptedConfig.replace( + /^reliability_diagnostics_enabled = false\r?\nproduct_analytics_enabled = false\r?\n/m, + '', + ), + importedConfigBeforeCredential.toString('utf8'), + 'provider adoption changed imported config beyond explicit telemetry decisions', ) assert.deepEqual( await readFile(join(settingsTarget, '.env')), diff --git a/desktop/electron/scripts/test-telemetry-build-identity.mjs b/desktop/electron/scripts/test-telemetry-build-identity.mjs new file mode 100644 index 0000000000..acf7bc62f9 --- /dev/null +++ b/desktop/electron/scripts/test-telemetry-build-identity.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' + +import { + readSourceCommitId, + sourceTelemetryVersion, +} from '../dist/telemetry/build-identity.js' + +const COMMITS = { + detached: '0123456789abcdef0123456789abcdef01234567', + loose: '89abcdef0123456789abcdef0123456789abcdef', + packed: 'fedcba9876543210fedcba9876543210fedcba98', + worktree: '00112233445566778899aabbccddeeff00112233', +} + +const root = mkdtempSync(join(tmpdir(), 'opensquilla-build-identity-')) + +function checkout(name) { + const checkoutRoot = join(root, name) + mkdirSync(join(checkoutRoot, 'src', 'opensquilla'), { recursive: true }) + writeFileSync(join(checkoutRoot, 'pyproject.toml'), '[project]\nname = "opensquilla"\n') + return checkoutRoot +} + +function writeGitDirectory(checkoutRoot) { + const gitRoot = join(checkoutRoot, '.git') + mkdirSync(gitRoot, { recursive: true }) + return gitRoot +} + +try { + // Detached HEAD is the simplest source checkout and must not require refs. + { + const checkoutRoot = checkout('detached') + const gitRoot = writeGitDirectory(checkoutRoot) + writeFileSync(join(gitRoot, 'HEAD'), `${COMMITS.detached}\n`) + assert.equal(readSourceCommitId(checkoutRoot), COMMITS.detached) + } + + // A normal symbolic HEAD resolves through a bounded loose ref file. + { + const checkoutRoot = checkout('loose') + const gitRoot = writeGitDirectory(checkoutRoot) + mkdirSync(join(gitRoot, 'refs', 'heads'), { recursive: true }) + writeFileSync(join(gitRoot, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitRoot, 'refs', 'heads', 'main'), `${COMMITS.loose}\n`) + assert.equal(readSourceCommitId(checkoutRoot), COMMITS.loose) + } + + // Packed refs are used after Git's maintenance/prune operations. + { + const checkoutRoot = checkout('packed') + const gitRoot = writeGitDirectory(checkoutRoot) + writeFileSync(join(gitRoot, 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(gitRoot, 'packed-refs'), `# pack-refs with: peeled fully-peeled\n${COMMITS.packed} refs/heads/main\n`) + assert.equal(readSourceCommitId(checkoutRoot), COMMITS.packed) + } + + // Git worktrees use a .git file and a shared commondir for refs. + { + const checkoutRoot = checkout('worktree') + const gitMetadata = join(root, 'worktree-metadata') + const commonMetadata = join(root, 'common-metadata') + mkdirSync(gitMetadata, { recursive: true }) + mkdirSync(commonMetadata, { recursive: true }) + writeFileSync(join(checkoutRoot, '.git'), `gitdir: ${relative(checkoutRoot, gitMetadata)}\n`) + writeFileSync(join(gitMetadata, 'commondir'), `${relative(gitMetadata, commonMetadata)}\n`) + writeFileSync(join(gitMetadata, 'HEAD'), 'ref: refs/heads/work\n') + writeFileSync(join(commonMetadata, 'packed-refs'), `${COMMITS.worktree} refs/heads/work\n`) + assert.equal(readSourceCommitId(checkoutRoot), COMMITS.worktree) + } + + // Invalid provenance must fail closed and never turn an arbitrary Git repo + // into an OpenSquilla source identity. + { + const arbitrary = join(root, 'arbitrary') + mkdirSync(join(arbitrary, '.git'), { recursive: true }) + writeFileSync(join(arbitrary, '.git', 'HEAD'), `${COMMITS.detached}\n`) + assert.equal(readSourceCommitId(arbitrary), null) + + const malformed = checkout('malformed') + mkdirSync(join(malformed, '.git'), { recursive: true }) + writeFileSync(join(malformed, '.git', 'HEAD'), 'not-a-commit\n') + assert.equal(readSourceCommitId(malformed), null) + + const oversized = checkout('oversized') + mkdirSync(join(oversized, '.git'), { recursive: true }) + writeFileSync(join(oversized, '.git', 'HEAD'), 'x'.repeat(4 * 1024 + 1)) + assert.equal(readSourceCommitId(oversized), null) + } + + assert.equal( + sourceTelemetryVersion('0.5.4', COMMITS.detached), + `0.5.4+source.${COMMITS.detached}`, + ) + assert.equal(sourceTelemetryVersion('0.5.4', null), '0.5.4') + assert.equal(sourceTelemetryVersion('0.5.4', COMMITS.detached.toUpperCase()), '0.5.4') + assert.equal(sourceTelemetryVersion('a'.repeat(17), COMMITS.detached), 'a'.repeat(17)) + assert.equal(sourceTelemetryVersion('bad version', COMMITS.detached), 'bad version') + + // Keep the test honest about the intended read-only implementation: the + // module must not gain a child-process dependency that invokes Git at runtime. + const source = readFileSync(new URL('../src/telemetry/build-identity.ts', import.meta.url), 'utf8') + assert.doesNotMatch(source, /(?:spawn|exec)(?:Sync)?\s*\(/) + assert.equal(existsSync(join(root, 'detached', '.git', 'HEAD')), true) +} finally { + rmSync(root, { recursive: true, force: true }) +} + +console.log('desktop telemetry build identity tests passed') diff --git a/desktop/electron/scripts/test-telemetry-consent.mjs b/desktop/electron/scripts/test-telemetry-consent.mjs new file mode 100644 index 0000000000..2e85add3d7 --- /dev/null +++ b/desktop/electron/scripts/test-telemetry-consent.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict' +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + applyDesktopTelemetryConsentPayload, + desktopPrivacyTomlLines, + parseDesktopTelemetryConsent, + parseLegacyNetworkObservabilityDisabled, + replaceDesktopTelemetryConsentInPrivacy, + requireExplicitOnboardingConsent, +} from '../dist/telemetry/onboarding-consent.js' +import { + clearGrowthAnalyticsIdentity, + CONSENT_MIRROR_SCHEMA_VERSION, + readConsentMirror, + writeConsentMirror, +} from '../dist/telemetry/consent-mirror.js' + +const NOW = '2026-09-02T01:02:03.004Z' +const crlfConfig = [ + 'state_dir = "C:\\\\OpenSquilla\\\\state"', + '', + '[privacy]', + 'disable_network_observability = false # legacy switch remains authoritative', + 'reliability_diagnostics_enabled = true', + 'reliability_notice_version = "reliability-v1"', + `reliability_consented_at_utc = "${NOW}"`, + 'product_analytics_enabled = false', + '', + '[control_ui]', + 'enabled = true', + '', +].join('\r\n') + +const parsed = parseDesktopTelemetryConsent(crlfConfig) +assert.deepEqual(parsed, { + reliability: { + enabled: true, + noticeVersion: 'reliability-v1', + consentedAtUtc: NOW, + }, + growth: { enabled: false, noticeVersion: null, consentedAtUtc: null }, +}) +assert.equal(parseLegacyNetworkObservabilityDisabled(crlfConfig), false) +assert.deepEqual(desktopPrivacyTomlLines(false, parsed, true), [ + '', + '[privacy]', + 'disable_network_observability = false', + 'reliability_diagnostics_enabled = true', + 'reliability_notice_version = "reliability-v1"', + `reliability_consented_at_utc = "${NOW}"`, + 'product_analytics_enabled = false', +]) + +assert.throws( + () => requireExplicitOnboardingConsent({ reliabilityDiagnosticsEnabled: true }), + /both telemetry categories/, +) +requireExplicitOnboardingConsent({ + reliabilityDiagnosticsEnabled: false, + productAnalyticsEnabled: false, +}) +const changed = applyDesktopTelemetryConsentPayload(parsed, { + reliabilityDiagnosticsEnabled: false, + productAnalyticsEnabled: true, +}, NOW) +assert.deepEqual(changed, { + reliability: { enabled: false, noticeVersion: null, consentedAtUtc: null }, + growth: { + enabled: true, + noticeVersion: 'growth-v1', + consentedAtUtc: NOW, + }, +}) + +const patched = replaceDesktopTelemetryConsentInPrivacy(crlfConfig, changed) +assert.ok(patched.includes('\r\n'), 'an imported CRLF config must keep its newline convention') +assert.match(patched, /disable_network_observability = false # legacy switch remains authoritative/) +assert.match(patched, /reliability_diagnostics_enabled = false/) +assert.doesNotMatch(patched, /reliability_notice_version/) +assert.doesNotMatch(patched, /reliability_consented_at_utc/) +assert.match(patched, /product_analytics_enabled = true/) +assert.match(patched, /product_analytics_notice_version = "growth-v1"/) +assert.match(patched, new RegExp(`product_analytics_consented_at_utc = "${NOW.replaceAll('.', '\\.')}`)) +assert.equal((patched.match(/\[privacy\]/g) || []).length, 1) +assert.match(patched, /\[control_ui\]\r\nenabled = true/) + +const root = mkdtempSync(join(tmpdir(), 'opensquilla-consent-mirror-')) +try { + const mirrorPath = join(root, 'nested', 'desktop-consent-mirror.json') + const mirror = { + schema_version: CONSENT_MIRROR_SCHEMA_VERSION, + reliability: { + enabled: false, + notice_version: null, + consented_at_utc: null, + forced_off: false, + }, + growth: { + enabled: true, + notice_version: 'growth-v1', + consented_at_utc: NOW, + forced_off: false, + }, + } + await writeConsentMirror(mirrorPath, mirror) + assert.deepEqual(readConsentMirror(mirrorPath), mirror) + assert.equal(readdirSync(join(root, 'nested')).some((name) => name.endsWith('.tmp')), false) + assert.equal(readFileSync(mirrorPath, 'utf8').endsWith('\n'), true) + if (process.platform !== 'win32') { + assert.equal(statSync(mirrorPath).mode & 0o777, 0o600) + } + + const replacement = { + ...mirror, + growth: { + enabled: false, + notice_version: null, + consented_at_utc: null, + forced_off: true, + }, + } + await writeConsentMirror(mirrorPath, replacement) + assert.deepEqual(readConsentMirror(mirrorPath), replacement) + assert.equal(existsSync(mirrorPath), true) + + const identityPath = join(root, 'nested', 'growth_identity.json') + writeFileSync(identityPath, '{"synthetic":"identity"}\n', { mode: 0o600 }) + assert.equal(await clearGrowthAnalyticsIdentity(identityPath), true) + assert.equal(existsSync(identityPath), false) + assert.equal(await clearGrowthAnalyticsIdentity(identityPath), false) + + if (process.platform !== 'win32') { + const identityTarget = join(root, 'identity-target.json') + const identityLink = join(root, 'nested', 'growth-identity-link.json') + writeFileSync(identityTarget, '{}') + symlinkSync(identityTarget, identityLink) + await assert.rejects( + clearGrowthAnalyticsIdentity(identityLink), + /not a regular file/, + ) + assert.equal(existsSync(identityTarget), true) + } +} finally { + rmSync(root, { recursive: true, force: true }) +} + +console.log('telemetry consent round-trip tests passed') diff --git a/desktop/electron/scripts/test-telemetry-early-spool.mjs b/desktop/electron/scripts/test-telemetry-early-spool.mjs new file mode 100644 index 0000000000..5362cb0c75 --- /dev/null +++ b/desktop/electron/scripts/test-telemetry-early-spool.mjs @@ -0,0 +1,330 @@ +import assert from 'node:assert/strict' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + canWriteDurableTelemetryMarker, + clearEarlyTelemetryScope, + DESKTOP_RELIABILITY_RECOVERY_MARKER_PREFIX, + DESKTOP_RELIABILITY_SESSION_MARKER_NAME, + DESKTOP_UPDATE_TRANSITION_MARKER_NAME, + DesktopTelemetryRuntimeGate, + EARLY_SPOOL_MAX_BYTES, + EARLY_SPOOL_MAX_FILES, + spoolEarlyTelemetryEvent, +} from '../dist/telemetry/early-spool.js' +import { CURRENT_NOTICE_VERSION_BY_SCOPE } from '../dist/telemetry/contracts.js' + +const NOW = new Date('2026-09-01T08:00:00.000Z') +const EVENT_ID = '00000000-0000-4000-8000-000000000001' +const APP_SESSION_ID = '00000000-0000-4000-8000-000000000002' +const ANALYTICS_USER_ID = '00000000-0000-4000-8000-000000000003' +const runtimeGate = new DesktopTelemetryRuntimeGate() +runtimeGate.openAfterConsentSync() + +function grantedScope(scope) { + return { + enabled: true, + notice_version: CURRENT_NOTICE_VERSION_BY_SCOPE[scope], + consented_at_utc: NOW.toISOString(), + forced_off: false, + } +} + +function writeMirror(path, overrides = {}) { + const mirror = { + schema_version: 1, + reliability: grantedScope('reliability'), + growth: grantedScope('growth'), + ...overrides, + } + writeFileSync(path, JSON.stringify(mirror), { mode: 0o600 }) +} + +function reliabilityEvent(overrides = {}) { + return { + event_name: 'app_start_result', + event_version: 1, + event_id: EVENT_ID, + occurred_at_utc: NOW.toISOString(), + source: 'desktop', + app_version: '0.5.3', + platform: 'macos', + outcome: 'success', + error_code: null, + duration_ms: 120, + consent_scope: 'reliability', + notice_version: CURRENT_NOTICE_VERSION_BY_SCOPE.reliability, + sample_rate: 1, + app_session_id: APP_SESSION_ID, + failure_stage: null, + ...overrides, + } +} + +function growthEvent(overrides = {}) { + return { + event_name: 'first_app_ready', + event_version: 1, + event_id: '00000000-0000-4000-8000-000000000004', + occurred_at_utc: NOW.toISOString(), + source: 'desktop', + app_version: '0.5.3', + platform: 'linux', + outcome: null, + error_code: null, + duration_ms: null, + consent_scope: 'growth', + notice_version: CURRENT_NOTICE_VERSION_BY_SCOPE.growth, + sample_rate: 1, + analytics_user_id: ANALYTICS_USER_ID, + ...overrides, + } +} + +function spool(root, mirrorPath, event, extra = {}) { + return spoolEarlyTelemetryEvent({ + spoolRoot: join(root, 'spool'), + consentMirrorPath: mirrorPath, + event, + runtimeGate, + env: {}, + now: NOW, + ...extra, + }) +} + +const root = mkdtempSync(join(tmpdir(), 'opensquilla-telemetry-spool-')) +try { + const mirrorPath = join(root, 'consent-mirror.json') + writeMirror(mirrorPath) + + const closedGate = new DesktopTelemetryRuntimeGate() + assert.deepEqual( + spool(root, mirrorPath, reliabilityEvent(), { runtimeGate: closedGate }), + { status: 'dropped', reason: 'consent_blocked' }, + ) + closedGate.openAfterConsentSync() + closedGate.close() + assert.deepEqual( + spool(root, mirrorPath, reliabilityEvent(), { runtimeGate: closedGate }), + { status: 'dropped', reason: 'consent_blocked' }, + ) + + const reliability = spool(root, mirrorPath, reliabilityEvent()) + const growth = spool(root, mirrorPath, growthEvent()) + assert.equal(reliability.status, 'written') + assert.equal(growth.status, 'written') + assert.equal(lstatSync(reliability.path).isSymbolicLink(), false) + assert.equal(lstatSync(growth.path).isSymbolicLink(), false) + assert.deepEqual(readdirSync(join(root, 'spool', 'reliability')), [`${EVENT_ID}.ready`]) + assert.deepEqual(readdirSync(join(root, 'spool', 'growth')), [ + '00000000-0000-4000-8000-000000000004.ready', + ]) + assert.deepEqual(JSON.parse(readFileSync(reliability.path, 'utf8')), reliabilityEvent()) + assert.equal( + readdirSync(join(root, 'spool', 'reliability')).some((name) => name.includes('.tmp')), + false, + ) + if (process.platform !== 'win32') { + assert.equal(statSync(join(root, 'spool')).mode & 0o777, 0o700) + assert.equal(statSync(join(root, 'spool', 'reliability')).mode & 0o777, 0o700) + assert.equal(statSync(reliability.path).mode & 0o777, 0o600) + } + + const duplicate = spool(root, mirrorPath, reliabilityEvent()) + assert.equal(duplicate.status, 'duplicate') + const conflict = spool(root, mirrorPath, reliabilityEvent({ duration_ms: 121 })) + assert.deepEqual(conflict, { status: 'dropped', reason: 'unsafe_path' }) + + assert.deepEqual( + spool(root, mirrorPath, reliabilityEvent({ prompt: 'synthetic-private-value' })), + { status: 'dropped', reason: 'invalid_event' }, + ) + assert.deepEqual( + spool(root, join(root, 'missing-mirror.json'), reliabilityEvent()), + { status: 'dropped', reason: 'consent_blocked' }, + ) + const invalidMirrorPath = join(root, 'invalid-mirror.json') + writeFileSync(invalidMirrorPath, JSON.stringify({ schema_version: 1, reliability: {} })) + assert.deepEqual(spool(root, invalidMirrorPath, reliabilityEvent()), { + status: 'dropped', + reason: 'consent_blocked', + }) + + const growthOffPath = join(root, 'growth-off.json') + writeMirror(growthOffPath, { + growth: { + enabled: false, + notice_version: null, + consented_at_utc: null, + forced_off: false, + }, + }) + assert.deepEqual(spool(join(root, 'off-case'), growthOffPath, growthEvent()), { + status: 'dropped', + reason: 'consent_blocked', + }) + assert.equal( + spool(join(root, 'off-case'), growthOffPath, reliabilityEvent()).status, + 'written', + ) + assert.deepEqual( + spool(join(root, 'dnt-case'), mirrorPath, reliabilityEvent(), { env: { DO_NOT_TRACK: '1' } }), + { status: 'dropped', reason: 'consent_blocked' }, + ) + + const countRoot = join(root, 'count-quota') + const countScope = join(countRoot, 'spool', 'reliability') + mkdirSync(countScope, { recursive: true }) + for (let index = 0; index < EARLY_SPOOL_MAX_FILES; index += 1) { + writeFileSync(join(countScope, `existing-${index}`), '') + } + assert.deepEqual(spool(countRoot, mirrorPath, reliabilityEvent()), { + status: 'dropped', + reason: 'quota_exceeded', + }) + + const durableQuotaRoot = join(root, 'durable-quota') + const durableQuotaScope = join(durableQuotaRoot, 'spool', 'reliability') + mkdirSync(durableQuotaScope, { recursive: true }) + const durableMarker = join(durableQuotaScope, DESKTOP_RELIABILITY_SESSION_MARKER_NAME) + writeFileSync(durableMarker, '{}') + assert.equal(spool(durableQuotaRoot, mirrorPath, reliabilityEvent()).status, 'written') + for (let index = 0; index < EARLY_SPOOL_MAX_FILES - 2; index += 1) { + writeFileSync(join(durableQuotaScope, `existing-${index}`), '') + } + // A duplicate is an acknowledgement, not queue growth, and must stay usable + // when the existing marker plus ready files exactly fill the quota. + assert.equal(spool(durableQuotaRoot, mirrorPath, reliabilityEvent()).status, 'duplicate') + assert.equal(canWriteDurableTelemetryMarker({ + spoolRoot: join(durableQuotaRoot, 'spool'), + scope: 'reliability', + markerName: DESKTOP_RELIABILITY_SESSION_MARKER_NAME, + payloadBytes: 2, + now: NOW, + }), true) + assert.equal(canWriteDurableTelemetryMarker({ + spoolRoot: join(durableQuotaRoot, 'spool'), + scope: 'reliability', + markerName: `${DESKTOP_RELIABILITY_RECOVERY_MARKER_PREFIX}00000000-0000-4000-8000-000000000099.tmp`, + payloadBytes: 2, + now: NOW, + }), false) + assert.equal(canWriteDurableTelemetryMarker({ + spoolRoot: join(durableQuotaRoot, 'spool'), + scope: 'reliability', + markerName: DESKTOP_UPDATE_TRANSITION_MARKER_NAME, + payloadBytes: 2, + now: NOW, + }), false) + + const byteRoot = join(root, 'byte-quota') + const byteScope = join(byteRoot, 'spool', 'reliability') + mkdirSync(byteScope, { recursive: true }) + writeFileSync(join(byteScope, 'existing'), Buffer.alloc(EARLY_SPOOL_MAX_BYTES)) + assert.deepEqual(spool(byteRoot, mirrorPath, reliabilityEvent()), { + status: 'dropped', + reason: 'quota_exceeded', + }) + + const ageRoot = join(root, 'age-prune') + const ageScope = join(ageRoot, 'spool', 'reliability') + mkdirSync(ageScope, { recursive: true }) + const expired = join(ageScope, 'expired.ready') + writeFileSync(expired, '{}') + const eightDaysAgo = new Date(NOW.valueOf() - 8 * 24 * 60 * 60 * 1000) + utimesSync(expired, eightDaysAgo, eightDaysAgo) + assert.equal(spool(ageRoot, mirrorPath, reliabilityEvent()).status, 'written') + assert.equal(existsSync(expired), false) + + const cleanupRoot = join(root, 'cleanup', 'spool') + const cleanupReliability = join(cleanupRoot, 'reliability') + const cleanupGrowth = join(cleanupRoot, 'growth') + mkdirSync(cleanupReliability, { recursive: true }) + mkdirSync(cleanupGrowth, { recursive: true }) + for (const name of ['one.ready', 'two.processing.123', '.three.123.tmp']) { + writeFileSync(join(cleanupReliability, name), '{}') + } + writeFileSync(join(cleanupGrowth, 'growth.ready'), '{}') + assert.deepEqual(clearEarlyTelemetryScope(cleanupRoot, 'reliability'), { + removed: 3, + failed: 0, + unsafe: false, + }) + assert.equal(existsSync(cleanupReliability), false) + assert.deepEqual(readdirSync(cleanupGrowth), ['growth.ready']) + + const fencedRoot = join(root, 'cleanup-fence', 'spool') + const fencedScope = join(fencedRoot, 'reliability') + mkdirSync(fencedScope, { recursive: true }) + const fencedTemp = join(fencedScope, `.${EVENT_ID}.999.synthetic.tmp`) + writeFileSync(fencedTemp, '{}') + assert.deepEqual(clearEarlyTelemetryScope(fencedRoot, 'reliability'), { + removed: 1, + failed: 0, + unsafe: false, + }) + assert.throws( + () => renameSync(fencedTemp, join(fencedScope, `${EVENT_ID}.ready`)), + /ENOENT/, + ) + + const unexpectedRoot = join(root, 'cleanup-unexpected', 'spool') + const unexpectedScope = join(unexpectedRoot, 'reliability') + mkdirSync(unexpectedScope, { recursive: true }) + writeFileSync(join(unexpectedScope, 'keep.local'), 'keep') + const unexpected = clearEarlyTelemetryScope(unexpectedRoot, 'reliability') + assert.deepEqual(unexpected, { removed: 0, failed: 0, unsafe: true }) + const quarantinedUnexpected = readdirSync(unexpectedRoot) + .find((name) => name.startsWith('.revoked-reliability-')) + assert.ok(quarantinedUnexpected) + assert.equal( + readFileSync(join(unexpectedRoot, quarantinedUnexpected, 'keep.local'), 'utf8'), + 'keep', + ) + + if (process.platform !== 'win32') { + const symlinkCase = join(root, 'symlink-case') + mkdirSync(symlinkCase, { recursive: true }) + const mirrorLink = join(symlinkCase, 'mirror-link.json') + symlinkSync(mirrorPath, mirrorLink) + assert.deepEqual(spool(symlinkCase, mirrorLink, reliabilityEvent()), { + status: 'dropped', + reason: 'consent_blocked', + }) + + const unsafeRoot = join(root, 'unsafe-scope') + const unsafeSpool = join(unsafeRoot, 'spool') + const redirect = join(unsafeRoot, 'redirect') + mkdirSync(unsafeSpool, { recursive: true }) + mkdirSync(redirect) + symlinkSync(redirect, join(unsafeSpool, 'reliability')) + assert.deepEqual(spool(unsafeRoot, mirrorPath, reliabilityEvent()), { + status: 'dropped', + reason: 'unsafe_path', + }) + } + + // Permission helpers are best-effort by contract and must not affect payload durability. + if (process.platform !== 'win32') chmodSync(mirrorPath, 0o600) +} finally { + rmSync(root, { recursive: true, force: true }) +} + +console.log('telemetry early spool tests passed') diff --git a/desktop/electron/scripts/test-telemetry-growth.mjs b/desktop/electron/scripts/test-telemetry-growth.mjs new file mode 100644 index 0000000000..5d46ab7a98 --- /dev/null +++ b/desktop/electron/scripts/test-telemetry-growth.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { writeConsentMirror } from '../dist/telemetry/consent-mirror.js' +import { CURRENT_NOTICE_VERSION_BY_SCOPE } from '../dist/telemetry/contracts.js' +import { DesktopTelemetryRuntimeGate } from '../dist/telemetry/early-spool.js' +import { + clearDesktopGrowthTelemetryState, + DesktopGrowthTelemetry, +} from '../dist/telemetry/growth.js' + +const NOW = new Date('2026-09-02T01:02:03.004Z') + +function uuid(counter) { + return `00000000-0000-4000-8000-${counter.toString(16).padStart(12, '0')}` +} + +function ids(start = 1) { + let counter = start + return () => uuid(counter++) +} + +function paths(root) { + return { + profileKey: join(root, 'profile'), + telemetryDirectory: join(root, 'telemetry'), + spoolRoot: join(root, 'telemetry', 'desktop-early-spool'), + consentMirrorPath: join(root, 'telemetry', 'desktop-consent-mirror.json'), + } +} + +async function mirror(path, enabled) { + await writeConsentMirror(path, { + schema_version: 1, + reliability: { + enabled: false, + notice_version: null, + consented_at_utc: null, + forced_off: false, + }, + growth: enabled === true + ? { + enabled: true, + notice_version: CURRENT_NOTICE_VERSION_BY_SCOPE.growth, + consented_at_utc: NOW.toISOString(), + forced_off: false, + } + : { + enabled: enabled === false ? false : null, + notice_version: null, + consented_at_utc: null, + forced_off: false, + }, + }) +} + +function openGate() { + const gate = new DesktopTelemetryRuntimeGate() + gate.openAfterConsentSync() + return gate +} + +function runtime(fakePaths, randomId = ids()) { + const telemetry = new DesktopGrowthTelemetry({ + runtimeGate: openGate(), + appVersion: () => '0.5.3', + platform: 'macos', + env: {}, + nowDate: () => NOW, + randomId, + }) + telemetry.observeProfileInspection({ + profileKey: fakePaths.profileKey, + stableCode: 'fresh_profile', + }) + return telemetry +} + +function readyEvents(fakePaths) { + const scope = join(fakePaths.spoolRoot, 'growth') + if (!existsSync(scope)) return [] + return readdirSync(scope) + .filter((name) => name.endsWith('.ready')) + .map((name) => JSON.parse(readFileSync(join(scope, name), 'utf8'))) +} + +const root = mkdtempSync(join(tmpdir(), 'opensquilla-growth-')) +try { + // Freshness alone is not consent and must not create an identifier or state. + { + const fakePaths = paths(join(root, 'unset')) + await mirror(fakePaths.consentMirrorPath, null) + const telemetry = runtime(fakePaths) + telemetry.synchronize(fakePaths) + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_identity.json')), false) + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_cohort.json')), false) + + await mirror(fakePaths.consentMirrorPath, true) + telemetry.synchronize(fakePaths) + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_identity.json')), true) + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_cohort.json')), true) + } + + // An upgrade/unknown profile with consent is never backfilled into a cohort. + { + const fakePaths = paths(join(root, 'upgrade')) + await mirror(fakePaths.consentMirrorPath, true) + const telemetry = runtime(fakePaths) + telemetry.observeProfileInspection({ + profileKey: fakePaths.profileKey, + stableCode: 'ready', + }) + telemetry.synchronize(fakePaths) + telemetry.recordFirstAppReady() + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_identity.json')), false) + assert.deepEqual(readyEvents(fakePaths), []) + } + + // Imported recovery profiles are explicitly excluded even if a caller + // accidentally forwards a fresh-looking stable code. + { + const fakePaths = paths(join(root, 'imported')) + await mirror(fakePaths.consentMirrorPath, true) + const telemetry = runtime(fakePaths) + telemetry.observeProfileInspection({ + profileKey: fakePaths.profileKey, + stableCode: 'fresh_profile', + importedOrMigrated: true, + }) + telemetry.synchronize(fakePaths) + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_cohort.json')), false) + } + + // The two Desktop milestones are strict, ordered, and exactly once. + { + const fakePaths = paths(join(root, 'milestones')) + await mirror(fakePaths.consentMirrorPath, true) + const telemetry = runtime(fakePaths, ids(100)) + telemetry.synchronize(fakePaths) + telemetry.recordOnboardingCompleted() + telemetry.recordFirstAppReady() + telemetry.recordOnboardingCompleted() + telemetry.recordFirstAppReady() + + const events = readyEvents(fakePaths) + assert.deepEqual(events.map((event) => event.event_name).sort(), [ + 'first_app_ready', + 'onboarding_result', + ]) + assert.equal(new Set(events.map((event) => event.analytics_user_id)).size, 1) + assert.equal(events.every((event) => event.sample_rate === 1), true) + const onboarding = events.find((event) => event.event_name === 'onboarding_result') + assert.equal(onboarding.flow_version, 1) + const marker = JSON.parse(readFileSync( + join(fakePaths.telemetryDirectory, 'growth_desktop_milestones.json'), + 'utf8', + )) + assert.equal(marker.onboarding_result.status, 'enqueued') + assert.equal(marker.first_app_ready.status, 'enqueued') + } + + // A durable cohort receipt can recover identity creation after an OS crash. + { + const fakePaths = paths(join(root, 'activation-recovery')) + await mirror(fakePaths.consentMirrorPath, true) + mkdirSync(fakePaths.telemetryDirectory, { recursive: true }) + writeFileSync(join(fakePaths.telemetryDirectory, 'growth_cohort.json'), JSON.stringify({ + schema_version: 1, + state: 'active', + activated_at_utc: NOW.toISOString(), + })) + const telemetry = runtime(fakePaths, ids(200)) + telemetry.observeProfileInspection({ + profileKey: fakePaths.profileKey, + stableCode: 'ready', + }) + telemetry.synchronize(fakePaths) + telemetry.recordFirstAppReady() + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_identity.json')), true) + assert.equal(readyEvents(fakePaths).length, 1) + } + + // A blocked spool leaves the same pending event for a later launch retry. + { + const fakePaths = paths(join(root, 'pending')) + await mirror(fakePaths.consentMirrorPath, true) + const first = runtime(fakePaths, ids(300)) + first.synchronize(fakePaths) + const growthSpool = join(fakePaths.spoolRoot, 'growth') + mkdirSync(growthSpool, { recursive: true }) + for (let index = 0; index < 512; index += 1) { + writeFileSync(join(growthSpool, `quota-${index}`), '') + } + first.recordOnboardingCompleted() + assert.deepEqual(readyEvents(fakePaths), []) + + const pendingId = JSON.parse(readFileSync( + join(fakePaths.telemetryDirectory, 'growth_desktop_milestones.json'), + 'utf8', + )).onboarding_result.event.event_id + rmSync(growthSpool, { recursive: true, force: true }) + const second = new DesktopGrowthTelemetry({ + runtimeGate: openGate(), + appVersion: () => '0.5.3', + platform: 'linux', + env: {}, + nowDate: () => NOW, + randomId: ids(900), + }) + second.observeProfileInspection({ + profileKey: fakePaths.profileKey, + stableCode: 'ready', + }) + second.synchronize(fakePaths) + assert.equal(readyEvents(fakePaths)[0].event_id, pendingId) + } + + // Corrupt authority fails closed and remains untouched. + { + const fakePaths = paths(join(root, 'corrupt')) + await mirror(fakePaths.consentMirrorPath, true) + writeFileSync(join(fakePaths.telemetryDirectory, 'growth_cohort.json'), '{"bad":true}') + const telemetry = runtime(fakePaths) + telemetry.synchronize(fakePaths) + assert.equal(existsSync(join(fakePaths.telemetryDirectory, 'growth_identity.json')), false) + assert.deepEqual(JSON.parse(readFileSync( + join(fakePaths.telemetryDirectory, 'growth_cohort.json'), + 'utf8', + )), { bad: true }) + } + + // Withdrawal removes only the four Growth files and keeps reliability state. + { + const directory = join(root, 'cleanup', 'telemetry') + mkdirSync(directory, { recursive: true }) + for (const name of [ + 'growth_identity.json', + 'growth_cohort.json', + 'growth_desktop_milestones.json', + 'growth_gateway_milestones.json', + ]) writeFileSync(join(directory, name), '{}') + const keep = join(directory, 'reliability-outbox.sqlite3') + writeFileSync(keep, 'keep') + clearDesktopGrowthTelemetryState(directory) + assert.equal(readFileSync(keep, 'utf8'), 'keep') + assert.deepEqual(readdirSync(directory), ['reliability-outbox.sqlite3']) + } +} finally { + rmSync(root, { recursive: true, force: true }) +} + +console.log('telemetry growth milestone tests passed') + +const mainSource = readFileSync(new URL('../src/main.ts', import.meta.url), 'utf8') +const appSuccess = mainSource.slice( + mainSource.indexOf('function finishAppStartSuccess'), + mainSource.indexOf('function finishAppStartFailure'), +) +assert.ok(appSuccess.includes('desktopGrowthTelemetry.recordFirstAppReady()')) +const onboardingSave = mainSource.slice( + mainSource.indexOf('async function performOnboardingSave'), + mainSource.indexOf('async function withRecoveryOperation'), +) +assert.ok( + onboardingSave.indexOf('completeOnboardingFlow(flow, credential)') + < onboardingSave.indexOf('desktopGrowthTelemetry.recordOnboardingCompleted()'), +) +const profileInspection = mainSource.slice( + mainSource.indexOf('async function inspectActiveProfileBeforeStartup'), + mainSource.indexOf('async function openOrResumeDesktopApp'), +) +assert.ok(profileInspection.includes('stableCode: inspection.stable_code')) +assert.ok(profileInspection.includes('desktopGrowthTelemetry.observeProfileInspection(growthInspection)')) diff --git a/desktop/electron/scripts/test-telemetry-protocol.mjs b/desktop/electron/scripts/test-telemetry-protocol.mjs new file mode 100644 index 0000000000..307f06de97 --- /dev/null +++ b/desktop/electron/scripts/test-telemetry-protocol.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { + CURRENT_NOTICE_VERSION_BY_SCOPE, + DESKTOP_EARLY_EVENT_SCOPES, + TELEMETRY_PROTOCOL_FINGERPRINT_SHA256, +} from '../dist/telemetry/contracts.js' + +const manifestPath = fileURLToPath(new URL( + '../../../src/opensquilla/telemetry/contracts/protocol-manifest.v1.json', + import.meta.url, +)) +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), + ) + } + return value +} + +const fingerprint = createHash('sha256') + .update(JSON.stringify(canonicalize(manifest)), 'utf8') + .digest('hex') +assert.equal(fingerprint, TELEMETRY_PROTOCOL_FINGERPRINT_SHA256) +assert.deepEqual(CURRENT_NOTICE_VERSION_BY_SCOPE, manifest.notice_versions) + +const manifestEvents = new Map( + manifest.events.map((event) => [event.event_name, event]), +) +for (const [eventName, scope] of Object.entries(DESKTOP_EARLY_EVENT_SCOPES)) { + assert.deepEqual(manifestEvents.get(eventName), { + event_name: eventName, + event_version: 1, + scope, + }) +} + +console.log('telemetry protocol manifest parity passed') diff --git a/desktop/electron/scripts/test-telemetry-reliability.mjs b/desktop/electron/scripts/test-telemetry-reliability.mjs new file mode 100644 index 0000000000..edad338dd0 --- /dev/null +++ b/desktop/electron/scripts/test-telemetry-reliability.mjs @@ -0,0 +1,1032 @@ +import assert from 'node:assert/strict' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + unlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { writeConsentMirror } from '../dist/telemetry/consent-mirror.js' +import { CURRENT_NOTICE_VERSION_BY_SCOPE } from '../dist/telemetry/contracts.js' +import { + clearEarlyTelemetryScope, + DesktopTelemetryRuntimeGate, + EARLY_SPOOL_MAX_FILES, +} from '../dist/telemetry/early-spool.js' +import { runTelemetrySideEffectFailOpen } from '../dist/telemetry/fail-open.js' +import { + DesktopReliabilityTelemetry, +} from '../dist/telemetry/reliability.js' + +function uuid(counter) { + return `00000000-0000-4000-8000-${counter.toString(16).padStart(12, '0')}` +} + +function deterministicIds(start = 1) { + let counter = start + return () => uuid(counter++) +} + +const SOURCE_COMMIT = '0123456789abcdef0123456789abcdef01234567' +const SOURCE_VERSION_053 = `0.5.3+source.${SOURCE_COMMIT}` +const SOURCE_VERSION_054 = `0.5.4+source.${SOURCE_COMMIT}` + +function clock(start = Date.parse('2026-09-02T00:00:00.000Z')) { + let now = start + return { + nowMs: () => now, + nowDate: () => new Date(now), + advance: (durationMs) => { now += durationMs }, + } +} + +async function writeReliabilityConsent( + path, + enabled, + consentedAtUtc = '2026-09-02T00:00:00.000Z', +) { + mkdirSync(join(path, '..'), { recursive: true }) + await writeConsentMirror(path, { + schema_version: 1, + reliability: enabled + ? { + enabled: true, + notice_version: CURRENT_NOTICE_VERSION_BY_SCOPE.reliability, + consented_at_utc: consentedAtUtc, + forced_off: false, + } + : { + enabled: false, + notice_version: null, + consented_at_utc: null, + forced_off: false, + }, + growth: { + enabled: false, + notice_version: null, + consented_at_utc: null, + forced_off: false, + }, + }) +} + +function paths(root) { + return { + spoolRoot: join(root, 'desktop-early-spool'), + consentMirrorPath: join(root, 'desktop-consent-mirror.json'), + } +} + +function openGate() { + const gate = new DesktopTelemetryRuntimeGate() + gate.openAfterConsentSync() + return gate +} + +function readyEvents(root) { + const directory = join(paths(root).spoolRoot, 'reliability') + if (!existsSync(directory)) return [] + return readdirSync(directory) + .filter((name) => name.endsWith('.ready')) + .map((name) => JSON.parse(readFileSync(join(directory, name), 'utf8'))) +} + +function telemetry(options) { + const telemetryOptions = { + runtimeGate: options.runtimeGate ?? openGate(), + appVersion: () => options.appVersion ?? '0.5.3', + platform: 'macos', + processStartedAtMs: options.processStartedAtMs ?? options.clock.nowMs(), + appSessionId: options.appSessionId, + nowMs: options.clock.nowMs, + nowDate: options.clock.nowDate, + randomId: options.randomId, + env: {}, + } + if (options.telemetryAppVersion !== undefined) { + telemetryOptions.telemetryAppVersion = () => options.telemetryAppVersion + } + return new DesktopReliabilityTelemetry(telemetryOptions) +} + +const root = mkdtempSync(join(tmpdir(), 'opensquilla-reliability-')) +try { + // No decision, decline, and a closed process gate are all strict no-write states. + { + const disabledRoot = join(root, 'disabled') + const disabledPaths = paths(disabledRoot) + await writeReliabilityConsent(disabledPaths.consentMirrorPath, false) + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + appSessionId: uuid(1), + randomId: deterministicIds(10), + }) + runtime.synchronize(disabledPaths) + runtime.recordAppStartResult({ + outcome: 'success', + durationMs: 10, + failureStage: null, + errorCode: null, + }) + assert.equal(existsSync(disabledPaths.spoolRoot), false) + + await writeReliabilityConsent(disabledPaths.consentMirrorPath, true) + const closedGate = new DesktopTelemetryRuntimeGate() + const closedRuntime = telemetry({ + clock: fakeClock, + runtimeGate: closedGate, + appSessionId: uuid(2), + randomId: deterministicIds(20), + }) + closedRuntime.synchronize(disabledPaths) + closedRuntime.recordCrash({ + component: 'desktop_main', + errorCode: 'uncaught_exception', + reason: 'uncaught_exception', + }) + assert.equal(existsSync(disabledPaths.spoolRoot), false) + } + + // Telemetry-only filesystem failure closes the local gate but never replaces + // the surrounding settings/onboarding operation's outcome. + { + const gate = openGate() + let failureObserved = false + const completed = await runTelemetrySideEffectFailOpen( + async () => { throw new Error('synthetic local telemetry I/O failure') }, + () => { + failureObserved = true + gate.close() + }, + ) + assert.equal(completed, false) + assert.equal(failureObserved, true) + assert.equal(gate.isOpen(), false) + let productOperationContinued = false + productOperationContinued = true + assert.equal(productOperationContinued, true) + assert.equal( + await runTelemetrySideEffectFailOpen(async () => undefined, () => assert.fail()), + true, + ) + } + + // Closing the process gate for a consent resync only pauses persistence. It + // must not reinterpret the temporary veto as a durable withdrawal. + { + const pausedRoot = join(root, 'temporarily-paused') + const pausedPaths = paths(pausedRoot) + await writeReliabilityConsent(pausedPaths.consentMirrorPath, true) + const fakeClock = clock() + const gate = openGate() + const runtime = telemetry({ + clock: fakeClock, + runtimeGate: gate, + appSessionId: uuid(40), + randomId: deterministicIds(41), + }) + runtime.synchronize(pausedPaths) + const sessionPath = join( + pausedPaths.spoolRoot, + 'reliability', + '.desktop-reliability-session.tmp', + ) + const before = JSON.parse(readFileSync(sessionPath, 'utf8')) + gate.close() + runtime.setForeground(true) + runtime.recordCrash({ + component: 'desktop_main', + errorCode: 'uncaught_exception', + reason: 'uncaught_exception', + signature: 'type_error', + }) + assert.equal(existsSync(sessionPath), true) + assert.equal(JSON.parse(readFileSync(sessionPath, 'utf8')).app_session_id, before.app_session_id) + gate.openAfterConsentSync() + runtime.synchronize(pausedPaths) + assert.equal(JSON.parse(readFileSync(sessionPath, 'utf8')).crash?.component, 'desktop_main') + runtime.finishSession() + } + + // A startup that dies before reaching a terminal seam is recovered exactly + // once with its last closed stage, fixed id, original version, and process duration. + { + const interruptedRoot = join(root, 'interrupted-start') + const interruptedPaths = paths(interruptedRoot) + await writeReliabilityConsent(interruptedPaths.consentMirrorPath, true) + const fakeClock = clock() + const first = telemetry({ + clock: fakeClock, + processStartedAtMs: fakeClock.nowMs(), + appSessionId: uuid(50), + randomId: deterministicIds(51), + appVersion: '0.5.3', + }) + first.synchronize(interruptedPaths) + first.observeAppStartStage('gateway_health') + const sessionPath = join( + interruptedPaths.spoolRoot, + 'reliability', + '.desktop-reliability-session.tmp', + ) + const pending = JSON.parse(readFileSync(sessionPath, 'utf8')) + fakeClock.advance(2_500) + first.setForeground(true) + + const second = telemetry({ + clock: fakeClock, + processStartedAtMs: fakeClock.nowMs(), + appSessionId: uuid(60), + randomId: deterministicIds(61), + appVersion: '0.5.4', + }) + second.synchronize(interruptedPaths) + const recoveredStarts = readyEvents(interruptedRoot).filter( + (event) => event.event_name === 'app_start_result' && event.app_session_id === uuid(50), + ) + assert.equal(recoveredStarts.length, 1) + assert.equal(recoveredStarts[0].event_id, pending.app_start_event_id) + assert.equal(recoveredStarts[0].outcome, 'fail') + assert.equal(recoveredStarts[0].error_code, 'internal_error') + assert.equal(recoveredStarts[0].failure_stage, 'gateway_health') + assert.equal(recoveredStarts[0].duration_ms, 2_500) + assert.equal(recoveredStarts[0].app_version, '0.5.3') + second.synchronize(interruptedPaths) + assert.equal( + readyEvents(interruptedRoot).filter( + (event) => event.event_name === 'app_start_result' && event.app_session_id === uuid(50), + ).length, + 1, + ) + second.finishSession() + } + + // A committed clean exit before readiness is a cancellation, never a crash. + { + const cancelledRoot = join(root, 'cancelled-start') + const cancelledPaths = paths(cancelledRoot) + await writeReliabilityConsent(cancelledPaths.consentMirrorPath, true) + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + processStartedAtMs: fakeClock.nowMs(), + appSessionId: uuid(70), + randomId: deterministicIds(71), + }) + runtime.synchronize(cancelledPaths) + runtime.observeAppStartStage('control_ui') + fakeClock.advance(400) + runtime.setForeground(true) + runtime.finishSession() + const events = readyEvents(cancelledRoot) + const start = events.find((event) => event.event_name === 'app_start_result') + assert.equal(start?.outcome, 'cancel') + assert.equal(start?.error_code, 'startup_cancelled') + assert.equal(start?.failure_stage, 'control_ui') + assert.equal(start?.duration_ms, 400) + assert.equal(events.some((event) => event.event_name === 'app_crash_detected'), false) + } + + // Terminal facts are strict, content-free events and a clean exit emits one summary. + { + const factRoot = join(root, 'facts') + const factPaths = paths(factRoot) + await writeReliabilityConsent(factPaths.consentMirrorPath, true) + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + appSessionId: uuid(100), + randomId: deterministicIds(101), + appVersion: '0.5.4', + telemetryAppVersion: SOURCE_VERSION_054, + }) + runtime.setForeground(true) + runtime.synchronize(factPaths) + fakeClock.advance(500) + runtime.recordMonitoredRequest(30_001) + runtime.recordAppStartResult({ + outcome: 'success', + durationMs: 500, + failureStage: null, + errorCode: null, + }) + runtime.recordGatewayStartResult({ + outcome: 'success', + durationMs: 400, + failureStage: null, + errorCode: null, + startupMode: 'spawned', + }) + runtime.recordUpdateResult({ + outcome: 'success', + durationMs: 200, + updateStage: 'check', + errorCode: null, + oldVersion: '0.5.3', + newVersion: '0.5.4', + result: 'available', + }) + fakeClock.advance(500) + runtime.finishSession() + + const events = readyEvents(factRoot) + assert.deepEqual( + events.map((event) => event.event_name).sort(), + ['app_start_result', 'gateway_start_result', 'performance_summary', 'update_result'], + ) + const summary = events.find((event) => event.event_name === 'performance_summary') + assert.ok(events.every((event) => event.app_version === SOURCE_VERSION_054)) + assert.equal(summary.summary_kind, 'session_end') + assert.equal(summary.coverage, 'complete') + assert.equal(summary.monitored_request_count, 1) + assert.equal(summary.slow_request_count, 1) + assert.equal(summary.foreground_duration_ms, 1_000) + assert.equal(summary.background_duration_ms, 0) + assert.equal( + existsSync(join(factPaths.spoolRoot, 'reliability', '.desktop-reliability-session.tmp')), + false, + ) + const serialized = JSON.stringify(events) + for (const forbidden of [ + 'prompt', 'response', 'message', 'stack', 'path', 'payload_json', 'user_id', 'analytics_user_id', + ]) { + assert.equal(serialized.includes(forbidden), false, `forbidden telemetry field: ${forbidden}`) + } + } + + // A renderer crash is persisted as closed facts and reported once on the next launch. + { + const crashRoot = join(root, 'crash') + const crashPaths = paths(crashRoot) + await writeReliabilityConsent(crashPaths.consentMirrorPath, true) + const fakeClock = clock() + const first = telemetry({ + clock: fakeClock, + appSessionId: uuid(200), + randomId: deterministicIds(201), + appVersion: '0.5.3', + }) + first.synchronize(crashPaths) + first.beginStall() + fakeClock.advance(16_000) + first.recordCrash({ + component: 'desktop_renderer', + errorCode: 'renderer_crashed', + reason: 'crashed', + }) + + fakeClock.advance(1_000) + const second = telemetry({ + clock: fakeClock, + appSessionId: uuid(300), + randomId: deterministicIds(301), + appVersion: '0.5.4', + }) + second.synchronize(crashPaths) + const events = readyEvents(crashRoot) + const crash = events.find((event) => event.event_name === 'app_crash_detected') + assert.ok(crash) + assert.equal(crash.component, 'desktop_renderer') + assert.equal(crash.error_code, 'renderer_crashed') + assert.equal(crash.app_version, '0.5.3') + assert.match(crash.error_fingerprint, /^[a-f0-9]{64}$/) + assert.equal(crash.app_session_id, uuid(200)) + const recovered = events.find( + (event) => event.event_name === 'performance_summary' + && event.summary_kind === 'recovered_abnormal', + ) + assert.ok(recovered) + assert.equal(recovered.coverage, 'partial') + assert.equal(recovered.stall_count, 1) + second.finishSession() + } + + // If the process dies after enqueue but before persisting the ACK bit, the + // next launch reuses the fixed id and treats the existing ready file as success. + { + const duplicateRoot = join(root, 'app-start-duplicate') + const duplicatePaths = paths(duplicateRoot) + await writeReliabilityConsent(duplicatePaths.consentMirrorPath, true) + const fakeClock = clock() + const first = telemetry({ + clock: fakeClock, + appSessionId: uuid(310), + randomId: deterministicIds(311), + }) + first.synchronize(duplicatePaths) + first.recordAppStartResult({ + outcome: 'success', + durationMs: 100, + failureStage: null, + errorCode: null, + }) + const sessionPath = join( + duplicatePaths.spoolRoot, + 'reliability', + '.desktop-reliability-session.tmp', + ) + const marker = JSON.parse(readFileSync(sessionPath, 'utf8')) + marker.app_start_result_emitted = false + writeFileSync(sessionPath, `${JSON.stringify(marker)}\n`) + fakeClock.advance(100) + const second = telemetry({ + clock: fakeClock, + appSessionId: uuid(315), + randomId: deterministicIds(316), + }) + second.synchronize(duplicatePaths) + const starts = readyEvents(duplicateRoot).filter( + (event) => event.event_name === 'app_start_result' && event.app_session_id === uuid(310), + ) + assert.equal(starts.length, 1) + assert.equal(starts[0].event_id, marker.app_start_event_id) + second.finishSession() + } + + // A v1 marker may already have emitted app_start_result outside its durable + // state. Upgrade recovery keeps crash/performance but never invents a duplicate. + { + const legacyRoot = join(root, 'legacy-session-marker') + const legacyPaths = paths(legacyRoot) + await writeReliabilityConsent(legacyPaths.consentMirrorPath, true) + const fakeClock = clock() + const first = telemetry({ + clock: fakeClock, + appSessionId: uuid(317), + randomId: deterministicIds(318), + }) + first.synchronize(legacyPaths) + const sessionPath = join( + legacyPaths.spoolRoot, + 'reliability', + '.desktop-reliability-session.tmp', + ) + const marker = JSON.parse(readFileSync(sessionPath, 'utf8')) + for (const field of [ + 'app_start_event_id', + 'app_start_started_at_ms', + 'app_start_stage', + 'app_start_result', + 'app_start_result_emitted', + ]) delete marker[field] + marker.schema_version = 1 + writeFileSync(sessionPath, `${JSON.stringify(marker)}\n`) + fakeClock.advance(100) + const second = telemetry({ + clock: fakeClock, + appSessionId: uuid(319), + randomId: deterministicIds(320), + }) + second.synchronize(legacyPaths) + const events = readyEvents(legacyRoot) + assert.equal( + events.some((event) => event.event_name === 'app_start_result' && event.app_session_id === uuid(317)), + false, + ) + assert.equal( + events.some((event) => event.event_name === 'app_crash_detected' && event.app_session_id === uuid(317)), + true, + ) + second.finishSession() + } + + // Main-process fingerprinting distinguishes closed built-in exception types + // without persisting exception messages, paths, or stacks. + { + const fingerprints = [] + for (const [index, signature] of ['type_error', 'range_error'].entries()) { + const fingerprintRoot = join(root, `fingerprint-${index}`) + const fingerprintPaths = paths(fingerprintRoot) + await writeReliabilityConsent(fingerprintPaths.consentMirrorPath, true) + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + appSessionId: uuid(325 + index), + randomId: deterministicIds(330 + index * 10), + }) + runtime.synchronize(fingerprintPaths) + runtime.recordCrash({ + component: 'desktop_main', + errorCode: 'uncaught_exception', + reason: 'uncaught_exception', + signature, + }) + const marker = readFileSync(join( + fingerprintPaths.spoolRoot, + 'reliability', + '.desktop-reliability-session.tmp', + ), 'utf8') + const parsed = JSON.parse(marker) + fingerprints.push(parsed.crash.error_fingerprint) + for (const forbidden of ['message', 'stack', 'path', signature]) { + assert.equal(marker.includes(forbidden), false) + } + } + assert.notEqual(fingerprints[0], fingerprints[1]) + } + + // A durable clean marker left behind by a failed unlink is never reclassified as a crash. + { + const cleanRoot = join(root, 'clean-marker') + const cleanPaths = paths(cleanRoot) + await writeReliabilityConsent(cleanPaths.consentMirrorPath, true) + const fakeClock = clock() + const first = telemetry({ + clock: fakeClock, + appSessionId: uuid(320), + randomId: deterministicIds(321), + }) + first.synchronize(cleanPaths) + fakeClock.advance(1_000) + first.setForeground(true) + const sessionPath = join( + cleanPaths.spoolRoot, + 'reliability', + '.desktop-reliability-session.tmp', + ) + const cleanMarker = JSON.parse(readFileSync(sessionPath, 'utf8')) + cleanMarker.clean_exit = true + cleanMarker.performance_summary_emitted = true + writeFileSync(sessionPath, `${JSON.stringify(cleanMarker)}\n`) + + const second = telemetry({ + clock: fakeClock, + appSessionId: uuid(330), + randomId: deterministicIds(331), + }) + second.synchronize(cleanPaths) + assert.equal( + readyEvents(cleanRoot).some((event) => event.event_name === 'app_crash_detected'), + false, + ) + second.finishSession() + } + + // A full queue cannot grow a second durable marker. The canonical marker + // remains stable until capacity returns, then recovers with its original facts. + { + const retryRoot = join(root, 'recovery-retry') + const retryPaths = paths(retryRoot) + await writeReliabilityConsent(retryPaths.consentMirrorPath, true) + const fakeClock = clock() + const first = telemetry({ + clock: fakeClock, + appSessionId: uuid(340), + randomId: deterministicIds(341), + }) + first.synchronize(retryPaths) + fakeClock.advance(1_000) + first.setForeground(true) + const scopeDirectory = join(retryPaths.spoolRoot, 'reliability') + const canonicalPath = join(scopeDirectory, '.desktop-reliability-session.tmp') + const canonical = JSON.parse(readFileSync(canonicalPath, 'utf8')) + for (let index = 0; index < EARLY_SPOOL_MAX_FILES - 1; index += 1) { + writeFileSync(join(scopeDirectory, `.quota-${index}`), '') + } + fakeClock.advance(5 * 24 * 60 * 60 * 1_000) + const second = telemetry({ + clock: fakeClock, + appSessionId: uuid(350), + randomId: deterministicIds(351), + }) + second.synchronize(retryPaths) + assert.equal( + readdirSync(scopeDirectory).some((name) => name.startsWith('.desktop-reliability-recovery-')), + false, + ) + assert.equal(JSON.parse(readFileSync(canonicalPath, 'utf8')).app_session_id, canonical.app_session_id) + const fullCount = readdirSync(scopeDirectory).length + second.synchronize(retryPaths) + assert.equal(readdirSync(scopeDirectory).length, fullCount) + for (let index = 0; index < EARLY_SPOOL_MAX_FILES - 1; index += 1) { + unlinkSync(join(scopeDirectory, `.quota-${index}`)) + } + second.synchronize(retryPaths) + const stale = readyEvents(retryRoot).find( + (event) => event.event_name === 'app_crash_detected' + && event.app_session_id === uuid(340), + ) + assert.ok(stale) + assert.equal(stale.runtime_ms, 1_000) + assert.equal(stale.occurred_at_utc, '2026-09-02T00:00:01.000Z') + const recoveredStart = readyEvents(retryRoot).find( + (event) => event.event_name === 'app_start_result' + && event.app_session_id === uuid(340), + ) + assert.equal(recoveredStart?.event_id, canonical.app_start_event_id) + assert.equal(recoveredStart?.duration_ms, 1_000) + assert.equal( + readdirSync(scopeDirectory).some((name) => name.startsWith('.desktop-reliability-recovery-')), + false, + ) + second.finishSession() + } + + // A pre-cap pathological backlog is deterministically bounded to the newest + // markers, releasing enough quota for recovery and a new canonical session. + { + const backlogRoot = join(root, 'legacy-recovery-backlog') + const backlogPaths = paths(backlogRoot) + await writeReliabilityConsent(backlogPaths.consentMirrorPath, true) + const fakeClock = clock() + const seed = telemetry({ + clock: fakeClock, + appSessionId: uuid(900), + randomId: deterministicIds(901), + }) + seed.synchronize(backlogPaths) + const scopeDirectory = join(backlogPaths.spoolRoot, 'reliability') + const canonicalPath = join(scopeDirectory, '.desktop-reliability-session.tmp') + const template = JSON.parse(readFileSync(canonicalPath, 'utf8')) + unlinkSync(canonicalPath) + for (const field of [ + 'app_start_event_id', + 'app_start_started_at_ms', + 'app_start_stage', + 'app_start_result', + 'app_start_result_emitted', + ]) delete template[field] + template.schema_version = 1 + template.clean_exit = false + template.performance_summary_emitted = false + const legacyMarkerCount = EARLY_SPOOL_MAX_FILES + for (let index = 0; index < legacyMarkerCount; index += 1) { + const marker = { + ...template, + app_session_id: uuid(2_000 + index), + crash_event_id: uuid(3_000 + index), + recovered_performance_event_id: uuid(4_000 + index), + } + writeFileSync( + join(scopeDirectory, `.desktop-reliability-recovery-${marker.app_session_id}.tmp`), + `${JSON.stringify(marker)}\n`, + ) + } + const recovery = telemetry({ + clock: fakeClock, + appSessionId: uuid(5_000), + randomId: deterministicIds(5_001), + }) + recovery.synchronize(backlogPaths) + assert.equal( + readdirSync(scopeDirectory).filter( + (name) => name.startsWith('.desktop-reliability-recovery-'), + ).length, + 0, + ) + assert.equal( + readyEvents(backlogRoot).filter((event) => event.event_name === 'performance_summary').length, + 32, + ) + assert.equal( + readyEvents(backlogRoot).filter((event) => event.event_name === 'app_crash_detected').length, + 32, + ) + assert.equal(existsSync(canonicalPath), true) + recovery.finishSession() + } + + // Native updater handoff is resolved only after the next binary version starts and is ready. + { + const updateRoot = join(root, 'update') + const updatePaths = paths(updateRoot) + await writeReliabilityConsent(updatePaths.consentMirrorPath, true) + const fakeClock = clock() + const oldRuntime = telemetry({ + clock: fakeClock, + appSessionId: uuid(400), + randomId: deterministicIds(401), + appVersion: '0.5.3', + telemetryAppVersion: SOURCE_VERSION_053, + }) + oldRuntime.synchronize(updatePaths) + assert.equal(oldRuntime.markUpdateHandoff('0.5.4'), true) + fakeClock.advance(2_000) + oldRuntime.finishSession() + + const newProcessStartedAt = fakeClock.nowMs() + const newRuntime = telemetry({ + clock: fakeClock, + processStartedAtMs: newProcessStartedAt, + appSessionId: uuid(500), + randomId: deterministicIds(501), + appVersion: '0.5.4', + telemetryAppVersion: SOURCE_VERSION_054, + }) + newRuntime.synchronize(updatePaths) + fakeClock.advance(800) + newRuntime.recordAppStartResult({ + outcome: 'success', + durationMs: 800, + failureStage: null, + errorCode: null, + }) + const updates = readyEvents(updateRoot).filter((event) => event.event_name === 'update_result') + const install = updates.find((event) => event.update_stage === 'install') + const restart = updates.find((event) => event.update_stage === 'restart') + assert.equal(install?.outcome, 'success') + assert.equal(install?.old_version, '0.5.3') + assert.equal(install?.new_version, '0.5.4') + assert.equal(install?.app_version, SOURCE_VERSION_054) + assert.equal(restart?.outcome, 'success') + assert.equal(restart?.app_session_id, uuid(500)) + assert.equal(restart?.app_version, SOURCE_VERSION_054) + assert.equal( + existsSync(join(updatePaths.spoolRoot, 'reliability', '.desktop-update-transition.tmp')), + false, + ) + newRuntime.finishSession() + } + + // Install/restart terminal facts survive a full queue and retry with their + // original event ids and completion timestamps after capacity returns. + { + const updateRetryRoot = join(root, 'update-retry') + const updateRetryPaths = paths(updateRetryRoot) + await writeReliabilityConsent(updateRetryPaths.consentMirrorPath, true) + const fakeClock = clock() + const oldRuntime = telemetry({ + clock: fakeClock, + appSessionId: uuid(520), + randomId: deterministicIds(521), + appVersion: '0.5.3', + }) + oldRuntime.synchronize(updateRetryPaths) + assert.equal(oldRuntime.markUpdateHandoff('0.5.4'), true) + oldRuntime.finishSession() + const scopeDirectory = join(updateRetryPaths.spoolRoot, 'reliability') + for (let index = 0; index < 512; index += 1) { + writeFileSync(join(scopeDirectory, `.quota-${index}`), '') + } + fakeClock.advance(2_000) + const newRuntime = telemetry({ + clock: fakeClock, + processStartedAtMs: fakeClock.nowMs(), + appSessionId: uuid(530), + randomId: deterministicIds(531), + appVersion: '0.5.4', + }) + newRuntime.synchronize(updateRetryPaths) + assert.equal( + existsSync(join(scopeDirectory, '.desktop-update-transition.tmp')), + true, + ) + for (let index = 0; index < 512; index += 1) { + unlinkSync(join(scopeDirectory, `.quota-${index}`)) + } + fakeClock.advance(800) + newRuntime.recordAppStartResult({ + outcome: 'success', + durationMs: 800, + failureStage: null, + errorCode: null, + }) + const updates = readyEvents(updateRetryRoot).filter( + (event) => event.event_name === 'update_result', + ) + assert.equal(updates.filter((event) => event.update_stage === 'install').length, 1) + assert.equal(updates.filter((event) => event.update_stage === 'restart').length, 1) + assert.equal( + existsSync(join(scopeDirectory, '.desktop-update-transition.tmp')), + false, + ) + newRuntime.finishSession() + } + + // A sink/path failure is invisible to the observed lifecycle. + { + const failureRoot = join(root, 'failure') + const failurePaths = paths(failureRoot) + await writeReliabilityConsent(failurePaths.consentMirrorPath, true) + writeFileSync(failurePaths.spoolRoot, 'not a directory') + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + appSessionId: uuid(600), + randomId: deterministicIds(601), + }) + assert.doesNotThrow(() => runtime.synchronize(failurePaths)) + assert.doesNotThrow(() => runtime.recordAppStartResult({ + outcome: 'fail', + durationMs: 1, + failureStage: 'profile', + errorCode: 'internal_error', + })) + assert.doesNotThrow(() => runtime.finishSession()) + } + + // Durable transition files remain eligible for withdrawal cleanup but are + // never expired by the ordinary seven-day temporary-file pruning pass. + { + const durableRoot = join(root, 'durable-marker') + const durablePaths = paths(durableRoot) + await writeReliabilityConsent(durablePaths.consentMirrorPath, true) + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + appSessionId: uuid(650), + randomId: deterministicIds(651), + }) + runtime.synchronize(durablePaths) + assert.equal(runtime.markUpdateHandoff('0.5.4'), true) + const scopeDirectory = join(durablePaths.spoolRoot, 'reliability') + const sessionPath = join(scopeDirectory, '.desktop-reliability-session.tmp') + const updatePath = join(scopeDirectory, '.desktop-update-transition.tmp') + const oldSeconds = (fakeClock.nowMs() - 10 * 24 * 60 * 60 * 1_000) / 1_000 + utimesSync(sessionPath, oldSeconds, oldSeconds) + utimesSync(updatePath, oldSeconds, oldSeconds) + runtime.recordUpdateResult({ + outcome: 'success', + durationMs: 1, + updateStage: 'check', + errorCode: null, + oldVersion: '0.5.3', + newVersion: null, + result: 'not_available', + }) + assert.equal(existsSync(sessionPath), true) + assert.equal(existsSync(updatePath), true) + runtime.abandonSession() + } + + // A profile/state-dir switch first preserves the old crash/summary in that + // profile, even while its queue is full, and never checkpoints A into B. + { + const profileARoot = join(root, 'profile-a') + const profileBRoot = join(root, 'profile-b') + const profileAPaths = paths(profileARoot) + const profileBPaths = paths(profileBRoot) + await writeReliabilityConsent(profileAPaths.consentMirrorPath, true) + await writeReliabilityConsent(profileBPaths.consentMirrorPath, true) + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + appSessionId: uuid(675), + randomId: deterministicIds(676), + }) + runtime.synchronize(profileAPaths) + runtime.recordCrash({ + component: 'desktop_renderer', + errorCode: 'renderer_crashed', + reason: 'crashed', + }) + const profileAScope = join(profileAPaths.spoolRoot, 'reliability') + for (let index = 0; index < EARLY_SPOOL_MAX_FILES - 1; index += 1) { + writeFileSync(join(profileAScope, `.quota-${index}`), '') + } + runtime.synchronize(profileBPaths) + assert.equal( + existsSync(join(profileAPaths.spoolRoot, 'reliability', '.desktop-reliability-session.tmp')), + true, + ) + assert.equal( + readdirSync(profileAScope).some((name) => name.startsWith('.desktop-reliability-recovery-')), + false, + ) + assert.equal( + existsSync(join(profileBPaths.spoolRoot, 'reliability', '.desktop-reliability-session.tmp')), + true, + ) + for (let index = 0; index < EARLY_SPOOL_MAX_FILES - 1; index += 1) { + unlinkSync(join(profileAScope, `.quota-${index}`)) + } + runtime.synchronize(profileAPaths) + assert.equal( + readyEvents(profileARoot).some((event) => ( + event.event_name === 'app_crash_detected' + && event.app_session_id === uuid(675) + )), + true, + ) + runtime.finishSession() + } + + // Gateway withdrawal and re-grant happen in another process, so Electron may + // never observe the disabled mirror. A new grant generation must still reset + // the in-memory session before its next checkpoint can recreate any marker. + { + const revokedRoot = join(root, 'revoked') + const revokedPaths = paths(revokedRoot) + await writeReliabilityConsent(revokedPaths.consentMirrorPath, true) + const fakeClock = clock() + const runtime = telemetry({ + clock: fakeClock, + appSessionId: uuid(700), + randomId: deterministicIds(701), + }) + runtime.synchronize(revokedPaths) + runtime.recordAppStartResult({ + outcome: 'success', + durationMs: 10, + failureStage: null, + errorCode: null, + }) + assert.equal(runtime.markUpdateHandoff('0.5.4'), true) + await writeReliabilityConsent(revokedPaths.consentMirrorPath, false) + assert.deepEqual(clearEarlyTelemetryScope(revokedPaths.spoolRoot, 'reliability'), { + removed: 3, + failed: 0, + unsafe: false, + }) + const scopeDirectory = join(revokedPaths.spoolRoot, 'reliability') + assert.deepEqual( + existsSync(scopeDirectory) + ? readdirSync(scopeDirectory).filter((name) => name.endsWith('.ready') || name.endsWith('.tmp')) + : [], + [], + ) + await writeReliabilityConsent( + revokedPaths.consentMirrorPath, + true, + '2026-09-02T00:00:01.000Z', + ) + runtime.setForeground(true) + const renewedMarker = JSON.parse(readFileSync( + join(scopeDirectory, '.desktop-reliability-session.tmp'), + 'utf8', + )) + assert.notEqual(renewedMarker.app_session_id, uuid(700)) + assert.equal( + readdirSync(scopeDirectory).some((name) => name === '.desktop-update-transition.tmp'), + false, + ) + runtime.finishSession() + assert.equal( + readyEvents(revokedRoot).some((event) => event.app_session_id === uuid(700)), + false, + ) + } + + // Keep the production seams explicit: lifecycle facts stay in the existing + // local spool path and never gain a separate network uploader. + { + const mainSource = readFileSync(new URL('../src/main.ts', import.meta.url), 'utf8') + for (const pattern of [ + /readSourceCommitId\(repoRoot\)/, + /telemetryAppVersion: \(\) => sourceTelemetryVersion\(app\.getVersion\(\), desktopSourceCommitId\)/, + /desktopReliabilityTelemetry\.synchronize\(/, + /process\.on\('uncaughtExceptionMonitor'/, + /app\.on\('child-process-gone'/, + /webContents\.on\('render-process-gone'/, + /desktopReliabilityTelemetry\.markUpdateHandoff\(/, + /desktopReliabilityTelemetry\.finishSession\(/, + /desktopReliabilityTelemetry\.recordMonitoredRequest\(/, + /desktopReliabilityTelemetry\.observeAppStartStage\(/, + /signature: normalizedCrashFingerprintSignature\(error\)/, + /runDesktopTelemetryConsentSideEffect\(/, + ]) { + assert.match(mainSource, pattern) + } + const openFlow = mainSource.slice( + mainSource.indexOf('async function openOrResumeDesktopApp'), + mainSource.indexOf('// SIGKILL deadline for the owned gateway child'), + ) + assert.ok( + openFlow.indexOf('await inspectActiveProfileBeforeStartup()') + < openFlow.indexOf('await syncDesktopConsentMirror()') + && openFlow.indexOf('await syncDesktopConsentMirror()') + < openFlow.indexOf('finishAppStartFailure(new Error(\'profile recovery required\')'), + 'consent sync must follow safe profile inspection but precede its terminal result', + ) + assert.doesNotMatch(mainSource, /desktopReliabilityTelemetry\.(?:upload|send|post)\(/) + const saveCredential = mainSource.slice( + mainSource.indexOf('async function saveDesktopCredential'), + mainSource.indexOf('function buildImportedDesktopCredential'), + ) + assert.ok( + saveCredential.indexOf("'pre_commit'") + < saveCredential.indexOf('await applyDesktopSettingsPair(') + && saveCredential.indexOf('await applyDesktopSettingsPair(') + < saveCredential.indexOf("'post_commit'"), + 'telemetry I/O must remain fail-open on both sides of the settings transaction', + ) + const saveImported = mainSource.slice( + mainSource.indexOf('async function saveImportedDesktopCredential'), + mainSource.indexOf('// Sections the desktop config template owns'), + ) + assert.ok( + saveImported.indexOf("'pre_commit'") + < saveImported.indexOf('const inspection = await preflightDesktopConfigWrite') + && saveImported.indexOf('const inspection = await preflightDesktopConfigWrite') + < saveImported.indexOf("'post_commit'"), + 'import adoption must keep telemetry I/O outside its authoritative outcome', + ) + const crashSignatureNormalizer = mainSource.slice( + mainSource.indexOf('function normalizedCrashFingerprintSignature'), + mainSource.indexOf('function recordRendererCrash'), + ) + assert.doesNotMatch(crashSignatureNormalizer, /\.message|\.stack|String\(error\)/) + } + + console.log('Desktop reliability telemetry tests passed.') +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/desktop/electron/src/main.ts b/desktop/electron/src/main.ts index 7825c8b6a7..053303140b 100644 --- a/desktop/electron/src/main.ts +++ b/desktop/electron/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, Menu, ipcMain, nativeTheme, net as electronNet, protocol, safeStorage, shell, Tray } from 'electron' +import { app, BrowserWindow, clipboard, dialog, Menu, ipcMain, nativeTheme, net as electronNet, powerMonitor, protocol, safeStorage, shell, Tray } from 'electron' import electronUpdater from 'electron-updater' import { spawn, spawnSync, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process' import { createHash, randomUUID } from 'node:crypto' @@ -47,6 +47,43 @@ import { type CoordinatedOnboardingFlow, } from './onboarding-flow-coordinator.js' import { OnboardingSaveTelemetry } from './onboarding-save-telemetry.js' +import { + applyDesktopTelemetryConsentPayload, + desktopPrivacyTomlLines, + parseDesktopTelemetryConsent, + parseLegacyNetworkObservabilityDisabled, + replaceDesktopTelemetryConsentInPrivacy, + requireExplicitOnboardingConsent, + type DesktopTelemetryConsent, +} from './telemetry/onboarding-consent.js' +import { + CONSENT_MIRROR_SCHEMA_VERSION, + writeConsentMirror, + type ConsentMirror, +} from './telemetry/consent-mirror.js' +import { + clearEarlyTelemetryScope, + DesktopTelemetryRuntimeGate, +} from './telemetry/early-spool.js' +import { + DesktopReliabilityTelemetry, + type AppStartErrorCode, + type AppStartFailureStage, + type CrashFingerprintReason, + type CrashFingerprintSignature, + type GatewayStartErrorCode, + type GatewayStartFailureStage, + type UpdateErrorCode, +} from './telemetry/reliability.js' +import { + readSourceCommitId, + sourceTelemetryVersion, +} from './telemetry/build-identity.js' +import { runTelemetrySideEffectFailOpen } from './telemetry/fail-open.js' +import { + clearDesktopGrowthTelemetryState, + DesktopGrowthTelemetry, +} from './telemetry/growth.js' import { buildCliInvocation } from './cli-invocation.js' import { cleanupSelectorArgs, @@ -261,9 +298,13 @@ interface OnboardingPayload { searchProvider?: unknown searchApiKey?: unknown disableNetworkObservability?: unknown + reliabilityDiagnosticsEnabled?: unknown + productAnalyticsEnabled?: unknown locale?: unknown } +const desktopTelemetryRuntimeGate = new DesktopTelemetryRuntimeGate() + interface OnboardingProbePayload { provider?: unknown model?: unknown @@ -443,6 +484,11 @@ const defaultRepoRoot = resolve(packageRoot, '..', '..') const repoRoot = process.env.OPENSQUILLA_DESKTOP_REPO_ROOT ? resolve(process.env.OPENSQUILLA_DESKTOP_REPO_ROOT) : defaultRepoRoot +// Official packaged Desktop builds have no checkout metadata and must keep +// their ordinary semver. An unpackaged shell launched from this repository +// may safely identify the exact source commit for reliability diagnostics; +// the helper is bounded, read-only, and never invokes Git. +const desktopSourceCommitId = app.isPackaged ? null : readSourceCommitId(repoRoot) const shouldUseNativeApplicationMenu = process.platform === 'darwin' let mainWindow: BrowserWindow | null = null @@ -514,6 +560,30 @@ let allowGracefulShutdownWhileQuitting = false // rate-limited, and every record must survive an imminent app.exit(). The file // sink caps individual records and rotates a bounded backup set. const desktopProcessStartedAt = Date.now() +const desktopReliabilityTelemetry = new DesktopReliabilityTelemetry({ + runtimeGate: desktopTelemetryRuntimeGate, + appVersion: () => app.getVersion(), + ...(desktopSourceCommitId === null + ? {} + : { + telemetryAppVersion: () => sourceTelemetryVersion(app.getVersion(), desktopSourceCommitId), + }), + platform: process.platform === 'darwin' + ? 'macos' + : process.platform === 'win32' + ? 'windows' + : 'linux', + processStartedAtMs: desktopProcessStartedAt, +}) +const desktopGrowthTelemetry = new DesktopGrowthTelemetry({ + runtimeGate: desktopTelemetryRuntimeGate, + appVersion: () => app.getVersion(), + platform: process.platform === 'darwin' + ? 'macos' + : process.platform === 'win32' + ? 'windows' + : 'linux', +}) function desktopLog(event: string, detail?: Record): void { try { @@ -544,6 +614,37 @@ function nativeWorkbenchFailureReason(event: NativeWorkbenchSurfaceEvent): strin return 'unknown' } +function normalizedCrashFingerprintReason(value: unknown): CrashFingerprintReason { + if (value === 'crashed') return 'crashed' + if (value === 'oom') return 'oom' + if (value === 'killed') return 'killed' + if (value === 'launch-failed') return 'launch_failed' + if (value === 'integrity-failure') return 'integrity_failure' + if (value === 'abnormal-exit') return 'abnormal_exit' + return 'unknown' +} + +function normalizedCrashFingerprintSignature(error: unknown): CrashFingerprintSignature { + if (error instanceof AggregateError) return 'aggregate_error' + if (error instanceof TypeError) return 'type_error' + if (error instanceof RangeError) return 'range_error' + if (error instanceof ReferenceError) return 'reference_error' + if (error instanceof SyntaxError) return 'syntax_error' + if (error instanceof URIError) return 'uri_error' + if (error instanceof EvalError) return 'eval_error' + if (error instanceof Error) return 'error' + return 'unknown' +} + +function recordRendererCrash(reason: unknown): void { + if (reason === 'clean-exit') return + desktopReliabilityTelemetry.recordCrash({ + component: 'desktop_renderer', + errorCode: reason === 'killed' ? 'renderer_killed' : 'renderer_crashed', + reason: normalizedCrashFingerprintReason(reason), + }) +} + let gatewayStartPromise: Promise | null = null const GATEWAY_UNEXPECTED_EXIT_RESTART_DELAYS_MS = [1_000, 2_000, 4_000] as const interface GatewayReadyAuthority { @@ -556,6 +657,103 @@ let gatewayUnexpectedExitRestartAttempt = 0 let gatewayUnexpectedExitRestartTimer: NodeJS.Timeout | null = null let gatewayUnexpectedExitRestartProfileKey: string | null = null let gatewayUnexpectedExitRestartOpenFlowRevision = 0 +interface GatewayStartTelemetryAttempt { + startedAt: number | null + mode: 'spawned' | 'reused' | 'external' | null + stage: GatewayStartFailureStage + errorCodeHint: GatewayStartErrorCode | null + finished: boolean +} +let gatewayStartTelemetryAttempt: GatewayStartTelemetryAttempt | null = null + +function createGatewayStartTelemetryAttempt(): GatewayStartTelemetryAttempt { + return { + startedAt: null, + mode: null, + stage: 'spawn', + errorCodeHint: null, + finished: false, + } +} + +function beginGatewayStartTelemetry( + mode: 'spawned' | 'reused' | 'external', + stage: GatewayStartFailureStage, +): void { + const attempt = gatewayStartTelemetryAttempt + if (!attempt || attempt.finished) return + attempt.startedAt ??= Date.now() + attempt.mode = mode + attempt.stage = stage + attempt.errorCodeHint = null +} + +function advanceGatewayStartTelemetry( + stage: GatewayStartFailureStage, + errorCodeHint: GatewayStartErrorCode | null = null, +): void { + const attempt = gatewayStartTelemetryAttempt + if (!attempt || attempt.finished || attempt.startedAt === null) return + attempt.stage = stage + if (errorCodeHint !== null) attempt.errorCodeHint = errorCodeHint +} + +function classifyGatewayStartFailure( + attempt: GatewayStartTelemetryAttempt, + error: unknown, +): { outcome: 'fail' | 'timeout' | 'cancel'; errorCode: GatewayStartErrorCode } { + const message = error instanceof Error ? error.message : String(error) + if (/superseded|cancelled/i.test(message)) { + return { outcome: 'cancel', errorCode: 'startup_cancelled' } + } + if (attempt.errorCodeHint) { + return { + outcome: attempt.errorCodeHint === 'health_timeout' || attempt.errorCodeHint === 'control_ui_timeout' + ? 'timeout' + : 'fail', + errorCode: attempt.errorCodeHint, + } + } + if (/ownership|unverified listener/i.test(message)) { + return { outcome: 'fail', errorCode: 'ownership_unverified' } + } + if (/did not become healthy|is not healthy/i.test(message)) { + return { outcome: 'timeout', errorCode: 'health_timeout' } + } + if (/Control UI did not become reachable/i.test(message)) { + return { outcome: 'timeout', errorCode: 'control_ui_timeout' } + } + if (/gateway failed to start|gateway exited|port is already in use/i.test(message)) { + return { outcome: 'fail', errorCode: 'spawn_failed' } + } + return { outcome: 'fail', errorCode: 'internal_error' } +} + +function finishGatewayStartTelemetry( + attempt: GatewayStartTelemetryAttempt, + error: unknown | null, +): void { + if (attempt.finished || attempt.startedAt === null || attempt.mode === null) return + attempt.finished = true + if (error === null) { + desktopReliabilityTelemetry.recordGatewayStartResult({ + outcome: 'success', + durationMs: Math.max(0, Date.now() - attempt.startedAt), + failureStage: null, + errorCode: null, + startupMode: attempt.mode, + }) + return + } + const failure = classifyGatewayStartFailure(attempt, error) + desktopReliabilityTelemetry.recordGatewayStartResult({ + outcome: failure.outcome, + durationMs: Math.max(0, Date.now() - attempt.startedAt), + failureStage: attempt.stage, + errorCode: failure.errorCode, + startupMode: attempt.mode, + }) +} let onboardingSaveTelemetryAttempt = 0 const onboardingFlows = new OnboardingFlowCoordinator< OnboardingPayload, @@ -703,6 +901,12 @@ const nativeWorkbenchSurfaces = new NativeWorkbenchSurfaceManager({ reason: nativeWorkbenchFailureReason(event), }) } + // The surface manager maps an owner-window hang to a synthetic `crashed` + // surface event so its UI can recover. Count that as a stall only; the + // owner's real render-process-gone seam below remains the crash authority. + if (event.type === 'crashed' && event.detail?.reason !== 'owner-unresponsive') { + recordRendererCrash(event.detail?.reason) + } const window = currentMainWindow() if ( !window @@ -739,7 +943,7 @@ async function resolveCandidatePreviewFromGateway( if (!gatewayOrigin || !token) { throw new Error('The Desktop candidate preview service is unavailable.') } - const response = await fetch( + const response = await desktopMonitoredFetch( new URL('/api/v1/desktop-artifact-candidate-preview/resolve', gatewayOrigin), { method: 'POST', @@ -827,7 +1031,7 @@ async function releaseCandidatePreviewFromGateway( if (!gatewayOrigin || !token) { throw new Error('The Desktop candidate preview cleanup service is unavailable.') } - const response = await fetch( + const response = await desktopMonitoredFetch( new URL( `/api/v1/desktop-artifact-candidate-preview/${encodeURIComponent(candidateHandle)}`, gatewayOrigin, @@ -1484,6 +1688,7 @@ async function openMacKeychainAccess(): Promise { function sendBootStatus(phaseId: BootPhaseId): void { bootStatus = { phaseId, label: desktopT('boot.' + phaseId), at: new Date().toISOString() } + desktopReliabilityTelemetry.observeAppStartStage(appStartFailureStage(phaseId)) bootError = null desktopStartupLog('boot_phase', { phaseId }) mainWindow?.webContents.send('desktop:boot:status', bootStatus) @@ -1498,6 +1703,79 @@ function sendBootError(error: unknown): void { mainWindow?.webContents.send('desktop:boot:error', bootError) } +let appStartResultRecorded = false + +function appStartFailureStage(phase: BootPhaseId): AppStartFailureStage { + if (phase === 'gateway-start') return 'gateway_start' + if (phase === 'gateway-health') return 'gateway_health' + if (phase === 'control') return 'control_ui' + return phase +} + +function classifyAppStartFailure(error: unknown): { + outcome: 'fail' | 'timeout' | 'cancel' + errorCode: AppStartErrorCode +} { + if (error instanceof DesktopStartupError && error.code === 'keychain_unavailable') { + return { outcome: 'fail', errorCode: 'keychain_unavailable' } + } + const message = error instanceof Error ? error.message : String(error) + if (/superseded|cancelled/i.test(message)) { + return { outcome: 'cancel', errorCode: 'startup_cancelled' } + } + if (gatewayExitLooksLikeProfileInUse(message)) { + return { outcome: 'fail', errorCode: 'profile_in_use' } + } + if (/ownership|unverified listener/i.test(message)) { + return { outcome: 'fail', errorCode: 'ownership_unverified' } + } + if (/did not become healthy|is not healthy/i.test(message)) { + return { outcome: 'timeout', errorCode: 'health_timeout' } + } + if (/Control UI did not become reachable/i.test(message)) { + return { outcome: 'timeout', errorCode: 'control_ui_timeout' } + } + if (/gateway failed to start|gateway exited|port is already in use/i.test(message)) { + return { outcome: 'fail', errorCode: 'spawn_failed' } + } + if (bootStatus.phaseId === 'gateway-start') { + return { outcome: 'fail', errorCode: 'runtime_unavailable' } + } + if (bootStatus.phaseId === 'control') { + return { outcome: 'fail', errorCode: 'renderer_load_failed' } + } + return { outcome: 'fail', errorCode: 'internal_error' } +} + +function finishAppStartSuccess(): void { + if (appStartResultRecorded) return + appStartResultRecorded = true + desktopReliabilityTelemetry.recordAppStartResult({ + outcome: 'success', + durationMs: Math.max(0, Date.now() - desktopProcessStartedAt), + failureStage: null, + errorCode: null, + }) + desktopGrowthTelemetry.recordFirstAppReady() +} + +function finishAppStartFailure( + error: unknown, + override?: { stage: AppStartFailureStage; errorCode: AppStartErrorCode }, +): void { + if (appStartResultRecorded) return + appStartResultRecorded = true + const classified = override + ? { outcome: 'fail' as const, errorCode: override.errorCode } + : classifyAppStartFailure(error) + desktopReliabilityTelemetry.recordAppStartResult({ + outcome: classified.outcome, + durationMs: Math.max(0, Date.now() - desktopProcessStartedAt), + failureStage: override?.stage ?? appStartFailureStage(bootStatus.phaseId), + errorCode: classified.errorCode, + }) +} + const TEXT_ROUTER_TIERS: TextRouterTier[] = ['c0', 'c1', 'c2', 'c3'] // Legacy desktop builds (and any credential.json written before the c0-c3 // rename) used t0-t3. Canonicalize those on read so upgrading users don't end @@ -2208,11 +2486,9 @@ function ensembleConfigTomlLines(credential: DesktopConnection): string[] { ] } -function desktopConfigShouldWritePrivacySection(credential: DesktopConnection): boolean { - return credential.disableNetworkObservability || readDesktopConfigNetworkObservabilitySetting() !== null -} - -function privacyConfigTomlLines(credential: DesktopConnection): string[] { +function privacyConfigTomlLines( + credential: DesktopConnection, +): string[] { if (!desktopConfigShouldWritePrivacySection(credential)) return [] return [ '', @@ -2221,6 +2497,156 @@ function privacyConfigTomlLines(credential: DesktopConnection): string[] { ] } +function desktopConfigShouldWritePrivacySection(credential: DesktopConnection): boolean { + return credential.disableNetworkObservability || readDesktopConfigNetworkObservabilitySetting() !== null +} + +function scopedPrivacyConfigTomlLines( + credential: DesktopConnection, + existingRaw: string | null, + consentOverride: DesktopTelemetryConsent | null, +): string[] { + const persistedConsent = parseDesktopTelemetryConsent(existingRaw) + const consent = consentOverride ?? persistedConsent + const persistedLegacy = parseLegacyNetworkObservabilityDisabled(existingRaw) + const includeLegacy = credential.disableNetworkObservability + || persistedLegacy !== null + || consent.reliability.enabled !== null + || consent.growth.enabled !== null + if ( + consentOverride === null + && consent.reliability.enabled === null + && consent.growth.enabled === null + ) return privacyConfigTomlLines(credential) + return desktopPrivacyTomlLines( + credential.disableNetworkObservability, + consent, + includeLegacy, + ) +} + +function configuredDesktopStateDirectory( + profile: DesktopProfilePaths, + configRaw: string | null, +): string { + if (configRaw !== null) { + for (const rawLine of configRaw.split(/\r?\n/)) { + if (/^\s*\[/.test(rawLine)) break + const match = rawLine.match(/^\s*state_dir\s*=\s*(["'])(.*?)\1\s*(?:#.*)?$/) + if (!match) continue + let configured = match[2] ?? '' + if (match[1] === '"') { + try { + const parsed: unknown = JSON.parse(`${match[1]}${configured}${match[1]}`) + if (typeof parsed === 'string') configured = parsed + } catch { + return join(profile.home, 'state') + } + } + if (configured === '~') configured = homedir() + else if (configured.startsWith('~/') || configured.startsWith('~\\')) { + configured = join(homedir(), configured.slice(2)) + } + return resolve(profile.home, configured) + } + } + return join(profile.home, 'state') +} + +function desktopTelemetryDirectory(profile: DesktopProfilePaths, configRaw: string | null): string { + return join(configuredDesktopStateDirectory(profile, configRaw), 'telemetry') +} + +function desktopConsentMirrorPath(profile: DesktopProfilePaths, configRaw: string | null): string { + return join(desktopTelemetryDirectory(profile, configRaw), 'desktop-consent-mirror.json') +} + +function desktopEarlyTelemetrySpoolPath(profile: DesktopProfilePaths, configRaw: string | null): string { + return join(desktopTelemetryDirectory(profile, configRaw), 'desktop-early-spool') +} + +function mirroredScopeConsent( + scope: DesktopTelemetryConsent['reliability'], + forcedOff: boolean, +): ConsentMirror['reliability'] { + const timestampIsUtc = typeof scope.consentedAtUtc === 'string' + && scope.consentedAtUtc.endsWith('Z') + && Number.isFinite(Date.parse(scope.consentedAtUtc)) + if (scope.enabled !== true || scope.noticeVersion === null || !timestampIsUtc) { + return { + enabled: scope.enabled === false ? false : null, + notice_version: null, + consented_at_utc: null, + forced_off: forcedOff, + } + } + return { + enabled: true, + notice_version: scope.noticeVersion, + consented_at_utc: scope.consentedAtUtc, + forced_off: forcedOff, + } +} + +async function writeDesktopConsentMirror( + profile: DesktopProfilePaths, + configRaw: string | null, + failClosed = false, +): Promise { + if (failClosed) desktopTelemetryRuntimeGate.close() + const consent = failClosed + ? parseDesktopTelemetryConsent(null) + : parseDesktopTelemetryConsent(configRaw) + const forcedOff = failClosed || parseLegacyNetworkObservabilityDisabled(configRaw) === true + await writeConsentMirror(desktopConsentMirrorPath(profile, configRaw), { + schema_version: CONSENT_MIRROR_SCHEMA_VERSION, + reliability: mirroredScopeConsent(consent.reliability, forcedOff), + growth: mirroredScopeConsent(consent.growth, forcedOff), + }) +} + +async function syncDesktopConsentMirror(profile = activeDesktopProfile()): Promise { + desktopTelemetryRuntimeGate.close() + const configRaw = await readOptionalDesktopText(join(profile.home, 'config.toml')) + await writeDesktopConsentMirror(profile, configRaw) + const consent = parseDesktopTelemetryConsent(configRaw) + for (const scope of ['reliability', 'growth'] as const) { + if (consent[scope].enabled !== false) continue + const cleanup = clearEarlyTelemetryScope( + desktopEarlyTelemetrySpoolPath(profile, configRaw), + scope, + ) + if (cleanup.unsafe || cleanup.failed > 0) { + throw new Error(`Could not clear declined ${scope} telemetry from the local early spool.`) + } + } + if (consent.growth.enabled === false) { + clearDesktopGrowthTelemetryState(desktopTelemetryDirectory(profile, configRaw)) + } + desktopTelemetryRuntimeGate.openAfterConsentSync() + desktopReliabilityTelemetry.synchronize({ + spoolRoot: desktopEarlyTelemetrySpoolPath(profile, configRaw), + consentMirrorPath: desktopConsentMirrorPath(profile, configRaw), + }) + desktopGrowthTelemetry.synchronize({ + profileKey: profile.home, + telemetryDirectory: desktopTelemetryDirectory(profile, configRaw), + spoolRoot: desktopEarlyTelemetrySpoolPath(profile, configRaw), + consentMirrorPath: desktopConsentMirrorPath(profile, configRaw), + }) + refreshDesktopReliabilityForegroundState() +} + +async function runDesktopTelemetryConsentSideEffect( + phase: 'pre_commit' | 'post_commit', + sideEffect: () => Promise, +): Promise { + await runTelemetrySideEffectFailOpen(sideEffect, () => { + desktopTelemetryRuntimeGate.close() + desktopLog('desktop_telemetry_consent_side_effect_failed', { phase }) + }) +} + function plainSecret(secret: string): { value: string; encryption: SecretEncryption } { return { value: Buffer.from(secret, 'utf8').toString('base64'), @@ -2618,6 +3044,7 @@ async function saveDesktopCredential( ): Promise { const targetProfile = activeDesktopProfile() const expectedCredential = await readOptionalDesktopText(targetProfile.credentialPath) + const existingConfigRaw = await readOptionalDesktopText(join(targetProfile.home, 'config.toml')) const existing = await loadDesktopCredential() if (existing?.configAuthority === 'profile') { throw new Error( @@ -2666,6 +3093,15 @@ async function saveDesktopCredential( ? normalizeBooleanSetting(payload.disableNetworkObservability, existing?.disableNetworkObservability ?? false) : configDisableNetworkObservability ?? existing?.disableNetworkObservability ?? false const configLocale = desktopLocaleChoice(payload.locale) ?? desktopLocale + const hasConsentPayload = Object.prototype.hasOwnProperty.call(payload, 'reliabilityDiagnosticsEnabled') + || Object.prototype.hasOwnProperty.call(payload, 'productAnalyticsEnabled') + const consentOverride = hasConsentPayload + ? applyDesktopTelemetryConsentPayload( + parseDesktopTelemetryConsent(existingConfigRaw), + payload, + new Date().toISOString(), + ) + : null if (defaults.requiresApiKey && !encryptedApiKey) throw new Error('API key is required.') if (modelRoutingMode === 'llm_ensemble' && !modelRoutingModeAllowed(modelRoutingMode, provider)) { @@ -2703,6 +3139,15 @@ async function saveDesktopCredential( ? () => {} : beginDesktopWriterOperation('save desktop settings') try { + // Invalidate a previously granted early-start snapshot before changing the + // authoritative config. If the settings transaction fails, collection stays + // off until the next successful startup reconciliation. + if (consentOverride !== null) { + await runDesktopTelemetryConsentSideEffect( + 'pre_commit', + () => writeDesktopConsentMirror(targetProfile, existingConfigRaw, true), + ) + } await applyDesktopSettingsPair( targetProfile, credential, @@ -2710,6 +3155,11 @@ async function saveDesktopCredential( expectedCredential, writerReserved, configLocale, + consentOverride, + ) + await runDesktopTelemetryConsentSideEffect( + 'post_commit', + () => syncDesktopConsentMirror(targetProfile), ) rememberDecryptedCredentialSecrets( credential, @@ -2757,6 +3207,7 @@ async function saveImportedDesktopCredential( importTransactionId: string, apiKeyOverride = '', writerReserved = false, + consentPayload: OnboardingPayload | null = null, ): Promise { const profile = primaryDesktopProfile() const expectedCredential = await readOptionalDesktopText(profile.credentialPath) @@ -2764,6 +3215,16 @@ async function saveImportedDesktopCredential( if (importedConfig === null) { throw new Error('The imported profile config.toml is missing; recover the profile before adoption.') } + const importedConsent = consentPayload === null + ? null + : applyDesktopTelemetryConsentPayload( + parseDesktopTelemetryConsent(importedConfig), + consentPayload, + new Date().toISOString(), + ) + const candidateConfig = importedConsent === null + ? importedConfig + : replaceDesktopTelemetryConsentInPrivacy(importedConfig, importedConsent) if (gatewayState.url && await healthCheck(gatewayState.url)) { throw new Error('A gateway is still serving this profile; stop it before adopting credentials.') } @@ -2773,6 +3234,12 @@ async function saveImportedDesktopCredential( ? () => {} : beginDesktopWriterOperation('adopt imported desktop credential') try { + if (importedConsent !== null) { + await runDesktopTelemetryConsentSideEffect( + 'pre_commit', + () => writeDesktopConsentMirror(profile, importedConfig, true), + ) + } const inspection = await preflightDesktopConfigWrite(profile) const result = await runRecoveryCli( profile, @@ -2785,7 +3252,7 @@ async function saveImportedDesktopCredential( ], JSON.stringify({ expected_config: importedConfig, - config: importedConfig, + config: candidateConfig, expected_credential: expectedCredential, credential: candidateCredential, }), @@ -2809,6 +3276,10 @@ async function saveImportedDesktopCredential( ) { throw new Error('Imported credential readback did not match the verified transaction.') } + await runDesktopTelemetryConsentSideEffect( + 'post_commit', + () => syncDesktopConsentMirror(profile), + ) return readback } finally { finishWriter() @@ -2914,7 +3385,11 @@ function renderDesktopConfigAfterPreflight( inspection: RecoveryProtocolResult, existingRaw: string | null, defaultLocale: DesktopLocale, + consentOverride: DesktopTelemetryConsent | null = null, ): string { + // Retain the legacy privacy writer as the no-scoped-consent path. The + // scoped writer extends it only when an explicit v2 decision exists. + const basePrivacyLines = privacyConfigTomlLines(credential) let preservedForeignSections: string[] = [] let preservedForeignPreamble: string[] = [] const preservedControlUiLocale = persistedControlUiDefaultLocale(existingRaw) @@ -2942,7 +3417,11 @@ function renderDesktopConfigAfterPreflight( '', ...routerConfigTomlLines(credential), ...ensembleConfigTomlLines(credential), - ...privacyConfigTomlLines(credential), + ...(consentOverride === null + && parseDesktopTelemetryConsent(existingRaw).reliability.enabled === null + && parseDesktopTelemetryConsent(existingRaw).growth.enabled === null + ? basePrivacyLines + : scopedPrivacyConfigTomlLines(credential, existingRaw, consentOverride)), '', ...freshDesktopSandboxConfigLines(existingRaw, process.platform), '[control_ui]', @@ -2961,6 +3440,7 @@ async function applyDesktopSettingsPair( expectedCredential: string | null, writerReserved = false, defaultLocale = desktopLocale, + consentOverride: DesktopTelemetryConsent | null = null, ): Promise { const targetProfileKey = desktopProfileKey(profile) if (desktopProfileKey() !== targetProfileKey) { @@ -2988,6 +3468,7 @@ async function applyDesktopSettingsPair( inspection, expectedConfig, defaultLocale, + consentOverride, ) const result = await runRecoveryCli( profile, @@ -3551,6 +4032,15 @@ const DESKTOP_MESSAGES: Record> = { 'onboarding.step5.subtitle': 'Search is optional. Start without another key, or connect a runtime-supported search provider.', 'onboarding.step5.searchKey': 'Search API key', 'onboarding.step5.searchHintDefault': 'DuckDuckGo is enough to start.', + 'onboarding.telemetry.heading': 'Data choices', + 'onboarding.telemetry.subtitle': 'Choose each category before starting. Both stay off until you decide.', + 'onboarding.telemetry.reliabilityTitle': 'Stability diagnostics', + 'onboarding.telemetry.reliabilityDesc': 'Share operation outcomes, timings, error codes, and crash fingerprints. Prompts, replies, file names, paths, contents, tool inputs, and full stacks are excluded.', + 'onboarding.telemetry.growthTitle': 'Product and growth analytics', + 'onboarding.telemetry.growthDesc': 'Share activation milestones using a random, purpose-specific analytics ID—not your raw account ID. Prompts, replies, files, and payment details are excluded.', + 'onboarding.telemetry.enable': 'Enable', + 'onboarding.telemetry.decline': 'Do not enable', + 'onboarding.telemetry.required': 'Choose an option for both data categories.', 'onboarding.step5.back': 'Back', 'onboarding.step5.finish': 'Start OpenSquilla', }, @@ -3683,6 +4173,15 @@ const DESKTOP_MESSAGES: Record> = { 'onboarding.step5.subtitle': '搜索为可选项。可以不添加其他密钥直接开始,或连接运行时支持的搜索提供商。', 'onboarding.step5.searchKey': '搜索 API 密钥', 'onboarding.step5.searchHintDefault': 'DuckDuckGo 足以开始使用。', + 'onboarding.telemetry.heading': '数据选项', + 'onboarding.telemetry.subtitle': '启动前请分别选择;在你做出选择之前,两类数据都保持关闭。', + 'onboarding.telemetry.reliabilityTitle': '稳定性诊断', + 'onboarding.telemetry.reliabilityDesc': '上传操作结果、耗时、错误码和崩溃指纹;不包含提示词、回复、文件名、路径、文件内容、工具入参或完整堆栈。', + 'onboarding.telemetry.growthTitle': '产品与增长分析', + 'onboarding.telemetry.growthDesc': '使用随机生成、仅用于分析的专用 ID 上传激活里程碑,不使用原始账号 ID;不包含提示词、回复、文件或支付信息。', + 'onboarding.telemetry.enable': '启用', + 'onboarding.telemetry.decline': '不启用', + 'onboarding.telemetry.required': '请为两类数据分别选择一个选项。', 'onboarding.step5.back': '返回', 'onboarding.step5.finish': '启动 OpenSquilla', }, @@ -4596,6 +5095,30 @@ function currentOnboardingWindow(): BrowserWindow | null { return onboardingWindow && !onboardingWindow.isDestroyed() ? onboardingWindow : null } +function desktopReliabilityIsForeground(): boolean { + return [mainWindow, onboardingWindow].some((window) => ( + window !== null + && !window.isDestroyed() + && window.isVisible() + && !window.isMinimized() + && window.isFocused() + )) +} + +function refreshDesktopReliabilityForegroundState(): void { + desktopReliabilityTelemetry.setForeground(desktopReliabilityIsForeground()) +} + +function trackDesktopReliabilityWindow(window: BrowserWindow): void { + window.on('show', refreshDesktopReliabilityForegroundState) + window.on('hide', refreshDesktopReliabilityForegroundState) + window.on('focus', refreshDesktopReliabilityForegroundState) + window.on('blur', refreshDesktopReliabilityForegroundState) + window.on('minimize', refreshDesktopReliabilityForegroundState) + window.on('restore', refreshDesktopReliabilityForegroundState) + window.once('closed', refreshDesktopReliabilityForegroundState) +} + function focusOnboardingWindow(): boolean { const window = currentOnboardingWindow() if (!window) return false @@ -5987,6 +6510,20 @@ function onboardingHtml( padding: 4px 4px 4px 20px; } .field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; } + .telemetry-consent { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--line); + } + .telemetry-consent > h3 { margin: 0; font-size: 14px; } + .telemetry-consent > p { margin: 5px 0 12px; color: var(--muted); font-size: 12px; line-height: 1.45; } + .telemetry-consent-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + .telemetry-consent-card { padding: 12px; border: 1px solid var(--line); border-radius: 9px; } + .telemetry-consent-card strong { display: block; font-size: 13px; } + .telemetry-consent-card p { min-height: 48px; margin: 5px 0 10px; color: var(--muted); font-size: 11px; line-height: 1.45; } + .telemetry-consent-options { display: flex; flex-wrap: wrap; gap: 12px; } + .telemetry-consent-options label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; } + .telemetry-consent-options input { width: auto; min-height: 0; } .actions { display: flex; align-items: center; @@ -6065,6 +6602,7 @@ function onboardingHtml( .deck { width: 100%; } .setup-card { position: relative; min-height: 620px; height: auto; padding: 24px 20px; } .provider, .field-pair { grid-template-columns: 1fr; gap: 4px; } + .telemetry-consent-grid { grid-template-columns: 1fr; } .provider-feature { grid-template-columns: 1fr; } .provider-feature-cta { width: 100%; } .provider-promo-copy { @@ -6181,6 +6719,31 @@ function onboardingHtml( +