From 5b4e7edb50ed86e237fffa871d3fed22251d7185 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:28:43 -0700 Subject: [PATCH] refactor(main): split backend services and startup (cherry picked from commit a33573328bfeb551cf0f23284e8f18d893fd46c9) --- config/max-lines-baseline.txt | 10 - src/cli/serve-electron-flag-parity.test.ts | 7 +- src/main/agent-hooks/server.ts | 3544 +-------------- .../server/server-authority-aliases.ts | 222 + .../server/server-authority-evidence.ts | 94 + .../server/server-authority-fences.ts | 193 + .../server/server-claude-status-rules.ts | 191 + src/main/agent-hooks/server/server-cleanup.ts | 167 + .../agent-hooks/server/server-constants.ts | 29 + .../agent-hooks/server/server-hydration.ts | 162 + .../server/server-ingest-normalization.ts | 81 + .../server/server-ingest-remote.ts | 282 ++ .../server/server-ingest-terminal.ts | 104 + .../agent-hooks/server/server-lifecycle.ts | 197 + .../agent-hooks/server/server-listeners.ts | 218 + .../server/server-persistence-validation.ts | 183 + .../agent-hooks/server/server-persistence.ts | 141 + src/main/agent-hooks/server/server-reaping.ts | 124 + .../agent-hooks/server/server-runtime-env.ts | 63 + src/main/agent-hooks/server/server-state.ts | 217 + .../server/server-status-application.ts | 141 + .../server/server-status-disposition.ts | 161 + .../server/server-status-identity.ts | 94 + .../server/server-status-inference.ts | 175 + .../server/server-status-retries.ts | 155 + .../server/server-status-update.ts | 219 + .../agent-hooks/server/server-tab-cleanup.ts | 122 + .../server/server-transport-rules.ts | 13 + src/main/agent-hooks/server/server-types.ts | 113 + .../agent-browser-bridge-capture-commands.ts | 185 + .../agent-browser-bridge-core-commands.ts | 186 + .../browser/agent-browser-bridge-execution.ts | 263 ++ .../agent-browser-bridge-input-commands.ts | 124 + .../browser/agent-browser-bridge-input.ts | 67 + ...ent-browser-bridge-interaction-commands.ts | 192 + .../browser/agent-browser-bridge-lifecycle.ts | 266 ++ .../agent-browser-bridge-mouse-commands.ts | 206 + .../browser/agent-browser-bridge-mouse.ts | 195 + .../browser/agent-browser-bridge-process.ts | 183 + .../browser/agent-browser-bridge-queue.ts | 174 + .../agent-browser-bridge-raw-process.ts | 108 + .../browser/agent-browser-bridge-result.ts | 29 + .../browser/agent-browser-bridge-shutdown.ts | 40 + .../agent-browser-bridge-state-commands.ts | 266 ++ .../browser/agent-browser-bridge-state.ts | 64 + src/main/browser/agent-browser-bridge-tabs.ts | 232 + .../browser/agent-browser-bridge-types.ts | 65 + .../agent-browser-bridge-utility-commands.ts | 175 + src/main/browser/agent-browser-bridge.ts | 2904 +------------ .../browser-cookie-chromium-finalize.ts | 132 + .../browser/browser-cookie-chromium-import.ts | 74 + .../browser-cookie-chromium-prepare.ts | 289 ++ .../browser/browser-cookie-chromium-scan.ts | 167 + .../browser/browser-cookie-chromium-types.ts | 83 + src/main/browser/browser-cookie-decryption.ts | 126 + .../browser/browser-cookie-detection-types.ts | 224 + src/main/browser/browser-cookie-detection.ts | 127 + .../browser/browser-cookie-firefox-import.ts | 138 + .../browser-cookie-import-diagnostics.ts | 55 + .../browser/browser-cookie-import-pipeline.ts | 300 ++ src/main/browser/browser-cookie-import.ts | 2198 +--------- src/main/browser/browser-cookie-key.ts | 158 + .../browser/browser-cookie-safari-import.ts | 61 + .../browser/browser-cookie-safari-parser.ts | 148 + src/main/browser/browser-cookie-sqlite.ts | 147 + src/main/browser/browser-cookie-validation.ts | 127 + src/main/browser/browser-manager-bindings.ts | 89 + .../browser-manager-download-creation.ts | 188 + .../browser-manager-download-lifecycle.ts | 241 ++ .../browser-manager-event-forwarding.ts | 123 + src/main/browser/browser-manager-final.ts | 22 + src/main/browser/browser-manager-grab.ts | 126 + .../browser/browser-manager-guest-cleanup.ts | 44 + ...browser-manager-guest-navigation-policy.ts | 152 + .../browser/browser-manager-guest-policy.ts | 76 + .../browser-manager-guest-popup-policy.ts | 210 + .../browser/browser-manager-navigation.ts | 263 ++ src/main/browser/browser-manager-queries.ts | 136 + .../browser/browser-manager-registration.ts | 230 + src/main/browser/browser-manager-state.ts | 289 ++ src/main/browser/browser-manager-types.ts | 238 + .../browser-manager-viewport-scroll-state.ts | 81 + src/main/browser/browser-manager-viewport.ts | 219 + .../browser/browser-manager-visibility.ts | 256 ++ src/main/browser/browser-manager.ts | 2705 +----------- .../runtime-home-service-auth-core.ts | 174 + .../runtime-home-service-auth-provenance.ts | 224 + ...runtime-home-service-auth-sync-identity.ts | 37 + .../runtime-home-service-auth-sync.ts | 292 ++ .../runtime-home-service-home-routing.ts | 297 ++ .../runtime-home-service-launch.ts | 154 + .../runtime-home-service-legacy-migration.ts | 204 + .../runtime-home-service-managed-home.ts | 276 ++ .../runtime-home-service-paths.ts | 187 + .../runtime-home-service-state.ts | 264 ++ .../runtime-home-service-sync.ts | 154 + .../runtime-home-service-types.ts | 62 + .../runtime-home-service-wsl-core.ts | 119 + .../runtime-home-service-wsl.ts | 167 + .../codex-accounts/runtime-home-service.ts | 2274 +--------- .../gpu-crash-diagnostics.test.ts | 20 +- .../gpu-crash-fallback-field-sessions.test.ts | 11 +- .../gpu-fallback-recovered-launch.test.ts | 13 +- ...omation-dispatcher-source-boundary.test.ts | 2 +- src/main/index.ts | 3836 +---------------- src/main/ipc/filesystem.ts | 2463 +---------- .../filesystem-download-handlers.ts | 210 + .../ipc/filesystem/filesystem-file-helpers.ts | 173 + ...lesystem-git-commit-generation-handlers.ts | 157 + .../filesystem-git-commit-handlers.ts | 42 + .../filesystem-git-diff-handlers.ts | 126 + .../filesystem-git-index-handlers.ts | 173 + ...filesystem-git-model-discovery-handlers.ts | 82 + ...em-git-pull-request-generation-handlers.ts | 226 + .../filesystem-git-remote-handlers.ts | 322 ++ .../filesystem-git-status-handlers.ts | 307 ++ .../filesystem/filesystem-git-url-handlers.ts | 54 + .../filesystem/filesystem-handler-context.ts | 75 + .../filesystem/filesystem-read-handlers.ts | 170 + .../filesystem/filesystem-search-handlers.ts | 234 + .../filesystem/filesystem-worktree-helpers.ts | 185 + .../filesystem/filesystem-write-handlers.ts | 91 + src/main/macos-press-and-hold-default.test.ts | 12 +- ...uit-teardown-agent-browser-daemons.test.ts | 4 +- src/main/rate-limits/service.ts | 2188 +--------- .../service/service-account-refresh.ts | 182 + .../service/service-configuration.ts | 139 + .../service/service-fetch-control.ts | 80 + .../service/service-fetch-policy.ts | 139 + .../service/service-fetch-queue.ts | 245 ++ .../service/service-fetch-targets.ts | 171 + .../service/service-full-cycle-application.ts | 215 + .../service/service-full-cycle-preparation.ts | 204 + .../service/service-inactive-accounts.ts | 246 ++ .../rate-limits/service/service-polling.ts | 183 + .../service/service-provider-cycles.ts | 183 + .../service/service-result-policy.ts | 112 + src/main/rate-limits/service/service-state.ts | 165 + src/main/rate-limits/service/service-types.ts | 163 + .../rpc/methods/orchestration-ask-methods.ts | 168 + .../rpc/methods/orchestration-ask-remote.ts | 129 + .../rpc/methods/orchestration-check-direct.ts | 79 + .../methods/orchestration-check-methods.ts | 79 + .../rpc/methods/orchestration-check-run.ts | 251 ++ .../rpc/methods/orchestration-check-worker.ts | 192 + .../methods/orchestration-dispatch-methods.ts | 178 + .../methods/orchestration-message-methods.ts | 219 + .../methods/orchestration-reset-methods.ts | 27 + .../rpc/methods/orchestration-routing.ts | 135 + .../rpc/methods/orchestration-schemas.ts | 272 ++ .../orchestration-send-control-mail.ts | 83 + .../rpc/methods/orchestration-send-group.ts | 131 + .../rpc/methods/orchestration-send-methods.ts | 188 + .../orchestration-send-point-to-point.ts | 187 + .../rpc/methods/orchestration-send-remote.ts | 114 + src/main/runtime/rpc/methods/orchestration.ts | 2059 +-------- src/main/startup/branch-rename-hook.ts | 128 + src/main/startup/codex-launch-preparation.ts | 95 + .../startup/codex-session-resume-launch.ts | 98 + src/main/startup/configure-process.test.ts | 20 +- .../startup/desktop-startup-ordering.test.ts | 371 +- src/main/startup/gpu-lifecycle.ts | 177 + .../headless-pty-hydration-ordering.test.ts | 9 +- .../host-port-bootstrap-wiring.test.ts | 47 +- .../startup/main-process-account-services.ts | 151 + src/main/startup/main-process-automations.ts | 99 + src/main/startup/main-process-i18n-menu.ts | 94 + .../startup/main-process-ipc-bootstrap.ts | 47 + src/main/startup/main-process-observers.ts | 132 + src/main/startup/main-process-plugins.ts | 158 + src/main/startup/main-process-preflight.ts | 323 ++ src/main/startup/main-process-pty-startup.ts | 195 + src/main/startup/main-process-quit.ts | 217 + .../startup/main-process-ready-foundation.ts | 235 + .../startup/main-process-ready-runtime.ts | 134 + src/main/startup/main-process-ready.ts | 17 + .../startup/main-process-runtime-launch.ts | 283 ++ .../startup/main-process-runtime-service.ts | 136 + src/main/startup/main-process-serve.ts | 131 + src/main/startup/main-process-state.ts | 127 + src/main/startup/main-window-actions.ts | 169 + src/main/startup/main-window-agent-status.ts | 138 + src/main/startup/main-window-controller.ts | 197 + src/main/startup/main-window-core-services.ts | 130 + .../startup/main-window-lifecycle-flags.ts | 53 + .../startup/main-window-service-readiness.ts | 42 + ...-protection-report-deferral-wiring.test.ts | 25 +- .../serve-desktop-activation-wiring.test.ts | 75 +- ...serve-mode-argv-cli-redirect-order.test.ts | 11 +- ...single-instance-lock-exit.electron.test.ts | 9 +- ...single-instance-lock-headless-exit.test.ts | 18 +- src/main/startup/synthetic-title-runtime.ts | 187 + src/main/startup/web-contents-timed-flag.ts | 31 + src/main/updater.ts | 2357 +--------- src/main/updater/updater-build-selection.ts | 152 + src/main/updater/updater-check-failure.ts | 118 + src/main/updater/updater-check-state.ts | 298 ++ src/main/updater/updater-download-install.ts | 93 + src/main/updater/updater-install-execution.ts | 228 + src/main/updater/updater-install-support.ts | 174 + src/main/updater/updater-menu-checks.ts | 100 + src/main/updater/updater-nudge.ts | 86 + src/main/updater/updater-package-recovery.ts | 274 ++ src/main/updater/updater-release-feed.ts | 270 ++ src/main/updater/updater-remote-status.ts | 102 + src/main/updater/updater-scheduling.ts | 121 + src/main/updater/updater-setup.ts | 251 ++ src/main/updater/updater-state.ts | 127 + src/main/updater/updater-status.ts | 184 + src/main/updater/updater-types.ts | 2 + 210 files changed, 29470 insertions(+), 26543 deletions(-) create mode 100644 src/main/agent-hooks/server/server-authority-aliases.ts create mode 100644 src/main/agent-hooks/server/server-authority-evidence.ts create mode 100644 src/main/agent-hooks/server/server-authority-fences.ts create mode 100644 src/main/agent-hooks/server/server-claude-status-rules.ts create mode 100644 src/main/agent-hooks/server/server-cleanup.ts create mode 100644 src/main/agent-hooks/server/server-constants.ts create mode 100644 src/main/agent-hooks/server/server-hydration.ts create mode 100644 src/main/agent-hooks/server/server-ingest-normalization.ts create mode 100644 src/main/agent-hooks/server/server-ingest-remote.ts create mode 100644 src/main/agent-hooks/server/server-ingest-terminal.ts create mode 100644 src/main/agent-hooks/server/server-lifecycle.ts create mode 100644 src/main/agent-hooks/server/server-listeners.ts create mode 100644 src/main/agent-hooks/server/server-persistence-validation.ts create mode 100644 src/main/agent-hooks/server/server-persistence.ts create mode 100644 src/main/agent-hooks/server/server-reaping.ts create mode 100644 src/main/agent-hooks/server/server-runtime-env.ts create mode 100644 src/main/agent-hooks/server/server-state.ts create mode 100644 src/main/agent-hooks/server/server-status-application.ts create mode 100644 src/main/agent-hooks/server/server-status-disposition.ts create mode 100644 src/main/agent-hooks/server/server-status-identity.ts create mode 100644 src/main/agent-hooks/server/server-status-inference.ts create mode 100644 src/main/agent-hooks/server/server-status-retries.ts create mode 100644 src/main/agent-hooks/server/server-status-update.ts create mode 100644 src/main/agent-hooks/server/server-tab-cleanup.ts create mode 100644 src/main/agent-hooks/server/server-transport-rules.ts create mode 100644 src/main/agent-hooks/server/server-types.ts create mode 100644 src/main/browser/agent-browser-bridge-capture-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-core-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-execution.ts create mode 100644 src/main/browser/agent-browser-bridge-input-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-input.ts create mode 100644 src/main/browser/agent-browser-bridge-interaction-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-lifecycle.ts create mode 100644 src/main/browser/agent-browser-bridge-mouse-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-mouse.ts create mode 100644 src/main/browser/agent-browser-bridge-process.ts create mode 100644 src/main/browser/agent-browser-bridge-queue.ts create mode 100644 src/main/browser/agent-browser-bridge-raw-process.ts create mode 100644 src/main/browser/agent-browser-bridge-result.ts create mode 100644 src/main/browser/agent-browser-bridge-shutdown.ts create mode 100644 src/main/browser/agent-browser-bridge-state-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-state.ts create mode 100644 src/main/browser/agent-browser-bridge-tabs.ts create mode 100644 src/main/browser/agent-browser-bridge-types.ts create mode 100644 src/main/browser/agent-browser-bridge-utility-commands.ts create mode 100644 src/main/browser/browser-cookie-chromium-finalize.ts create mode 100644 src/main/browser/browser-cookie-chromium-import.ts create mode 100644 src/main/browser/browser-cookie-chromium-prepare.ts create mode 100644 src/main/browser/browser-cookie-chromium-scan.ts create mode 100644 src/main/browser/browser-cookie-chromium-types.ts create mode 100644 src/main/browser/browser-cookie-decryption.ts create mode 100644 src/main/browser/browser-cookie-detection-types.ts create mode 100644 src/main/browser/browser-cookie-detection.ts create mode 100644 src/main/browser/browser-cookie-firefox-import.ts create mode 100644 src/main/browser/browser-cookie-import-diagnostics.ts create mode 100644 src/main/browser/browser-cookie-import-pipeline.ts create mode 100644 src/main/browser/browser-cookie-key.ts create mode 100644 src/main/browser/browser-cookie-safari-import.ts create mode 100644 src/main/browser/browser-cookie-safari-parser.ts create mode 100644 src/main/browser/browser-cookie-sqlite.ts create mode 100644 src/main/browser/browser-cookie-validation.ts create mode 100644 src/main/browser/browser-manager-bindings.ts create mode 100644 src/main/browser/browser-manager-download-creation.ts create mode 100644 src/main/browser/browser-manager-download-lifecycle.ts create mode 100644 src/main/browser/browser-manager-event-forwarding.ts create mode 100644 src/main/browser/browser-manager-final.ts create mode 100644 src/main/browser/browser-manager-grab.ts create mode 100644 src/main/browser/browser-manager-guest-cleanup.ts create mode 100644 src/main/browser/browser-manager-guest-navigation-policy.ts create mode 100644 src/main/browser/browser-manager-guest-policy.ts create mode 100644 src/main/browser/browser-manager-guest-popup-policy.ts create mode 100644 src/main/browser/browser-manager-navigation.ts create mode 100644 src/main/browser/browser-manager-queries.ts create mode 100644 src/main/browser/browser-manager-registration.ts create mode 100644 src/main/browser/browser-manager-state.ts create mode 100644 src/main/browser/browser-manager-types.ts create mode 100644 src/main/browser/browser-manager-viewport-scroll-state.ts create mode 100644 src/main/browser/browser-manager-viewport.ts create mode 100644 src/main/browser/browser-manager-visibility.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-core.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-provenance.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-sync.ts create mode 100644 src/main/codex-accounts/runtime-home-service-home-routing.ts create mode 100644 src/main/codex-accounts/runtime-home-service-launch.ts create mode 100644 src/main/codex-accounts/runtime-home-service-legacy-migration.ts create mode 100644 src/main/codex-accounts/runtime-home-service-managed-home.ts create mode 100644 src/main/codex-accounts/runtime-home-service-paths.ts create mode 100644 src/main/codex-accounts/runtime-home-service-state.ts create mode 100644 src/main/codex-accounts/runtime-home-service-sync.ts create mode 100644 src/main/codex-accounts/runtime-home-service-types.ts create mode 100644 src/main/codex-accounts/runtime-home-service-wsl-core.ts create mode 100644 src/main/codex-accounts/runtime-home-service-wsl.ts create mode 100644 src/main/ipc/filesystem/filesystem-download-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-file-helpers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-commit-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-diff-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-index-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-remote-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-status-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-url-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-handler-context.ts create mode 100644 src/main/ipc/filesystem/filesystem-read-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-search-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-worktree-helpers.ts create mode 100644 src/main/ipc/filesystem/filesystem-write-handlers.ts create mode 100644 src/main/rate-limits/service/service-account-refresh.ts create mode 100644 src/main/rate-limits/service/service-configuration.ts create mode 100644 src/main/rate-limits/service/service-fetch-control.ts create mode 100644 src/main/rate-limits/service/service-fetch-policy.ts create mode 100644 src/main/rate-limits/service/service-fetch-queue.ts create mode 100644 src/main/rate-limits/service/service-fetch-targets.ts create mode 100644 src/main/rate-limits/service/service-full-cycle-application.ts create mode 100644 src/main/rate-limits/service/service-full-cycle-preparation.ts create mode 100644 src/main/rate-limits/service/service-inactive-accounts.ts create mode 100644 src/main/rate-limits/service/service-polling.ts create mode 100644 src/main/rate-limits/service/service-provider-cycles.ts create mode 100644 src/main/rate-limits/service/service-result-policy.ts create mode 100644 src/main/rate-limits/service/service-state.ts create mode 100644 src/main/rate-limits/service/service-types.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-ask-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-ask-remote.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-direct.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-run.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-worker.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-message-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-reset-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-routing.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-schemas.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-control-mail.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-group.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-remote.ts create mode 100644 src/main/startup/branch-rename-hook.ts create mode 100644 src/main/startup/codex-launch-preparation.ts create mode 100644 src/main/startup/codex-session-resume-launch.ts create mode 100644 src/main/startup/gpu-lifecycle.ts create mode 100644 src/main/startup/main-process-account-services.ts create mode 100644 src/main/startup/main-process-automations.ts create mode 100644 src/main/startup/main-process-i18n-menu.ts create mode 100644 src/main/startup/main-process-ipc-bootstrap.ts create mode 100644 src/main/startup/main-process-observers.ts create mode 100644 src/main/startup/main-process-plugins.ts create mode 100644 src/main/startup/main-process-preflight.ts create mode 100644 src/main/startup/main-process-pty-startup.ts create mode 100644 src/main/startup/main-process-quit.ts create mode 100644 src/main/startup/main-process-ready-foundation.ts create mode 100644 src/main/startup/main-process-ready-runtime.ts create mode 100644 src/main/startup/main-process-ready.ts create mode 100644 src/main/startup/main-process-runtime-launch.ts create mode 100644 src/main/startup/main-process-runtime-service.ts create mode 100644 src/main/startup/main-process-serve.ts create mode 100644 src/main/startup/main-process-state.ts create mode 100644 src/main/startup/main-window-actions.ts create mode 100644 src/main/startup/main-window-agent-status.ts create mode 100644 src/main/startup/main-window-controller.ts create mode 100644 src/main/startup/main-window-core-services.ts create mode 100644 src/main/startup/main-window-lifecycle-flags.ts create mode 100644 src/main/startup/main-window-service-readiness.ts create mode 100644 src/main/startup/synthetic-title-runtime.ts create mode 100644 src/main/startup/web-contents-timed-flag.ts create mode 100644 src/main/updater/updater-build-selection.ts create mode 100644 src/main/updater/updater-check-failure.ts create mode 100644 src/main/updater/updater-check-state.ts create mode 100644 src/main/updater/updater-download-install.ts create mode 100644 src/main/updater/updater-install-execution.ts create mode 100644 src/main/updater/updater-install-support.ts create mode 100644 src/main/updater/updater-menu-checks.ts create mode 100644 src/main/updater/updater-nudge.ts create mode 100644 src/main/updater/updater-package-recovery.ts create mode 100644 src/main/updater/updater-release-feed.ts create mode 100644 src/main/updater/updater-remote-status.ts create mode 100644 src/main/updater/updater-scheduling.ts create mode 100644 src/main/updater/updater-setup.ts create mode 100644 src/main/updater/updater-state.ts create mode 100644 src/main/updater/updater-status.ts create mode 100644 src/main/updater/updater-types.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 49ce9707ecf..4bc75a5a132 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,21 +2,11 @@ # This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) -inline src/main/agent-hooks/server.ts -inline src/main/browser/agent-browser-bridge.ts -inline src/main/browser/browser-cookie-import.ts -inline src/main/browser/browser-manager.ts -inline src/main/codex-accounts/runtime-home-service.ts -inline src/main/index.ts -inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts -inline src/main/rate-limits/service.ts -inline src/main/runtime/rpc/methods/orchestration.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts inline src/main/ssh/ssh-relay-deploy.ts inline src/main/ssh/ssh-relay-session.ts -inline src/main/updater.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts mobile-config app/h/*/files/*.tsx diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index 1abcf84ef64..4213d360a41 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -57,8 +57,11 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', // both ends of the contract are only readable statically. Without this leg the rewrite could // emit a name nothing reads and every behavioural assertion above would still pass. const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = mainSource.indexOf('function getServeOptions(') + const mainSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const start = mainSource.indexOf('export function getServeOptions(') // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. expect(start).toBeGreaterThanOrEqual(0) const end = mainSource.indexOf('\n}', start) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 11c1088315f..f03484f14f9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,3526 +1,30 @@ -/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ -// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron; parsing that drifts back into this file stops applying to SSH panes. -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { createHash, randomBytes, randomUUID } from 'node:crypto' -import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' -import { - ORCA_HOOK_PROTOCOL_VERSION, - ORCA_HOOK_RAW_JSON_TRANSPORT -} from '../../shared/agent-hook-types' -import { - clearAllListenerCaches, - clearPaneCacheState, - paneHasStateClaims, - createHookListenerState, - movePaneCacheState, - type HookListenerState -} from '../../shared/agent-hook-listener/listener-state' -import { - clearClaudeAnsweredQuestionWait, - markClaudeLeadTurnInterrupted, - reapRestoredClaudeSubagentsForDeadPane, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots -} from '../../shared/agent-hook-listener/providers/claude-roster-state' -import { - getEndpointFileName, - writeEndpointFile -} from '../../shared/agent-hook-listener/endpoint-publication' -import { - hasCodexTranscriptSubagents, - markCodexLeadTurnInterrupted, - reconcileRemoteCodexState, - seedCodexStateFromSnapshot -} from '../../shared/agent-hook-listener/providers/codex-state' -import { - hasPendingAgentResultText, - preparePendingGrokResultDiscovery -} from '../../shared/agent-hook-listener/grok-result-discovery' -import { - HOOK_REQUEST_SLOWLORIS_MS, - MAX_PANE_KEY_LEN, - normalizeClaudePromptId, - warnOnHookEnvOrVersionMismatch -} from '../../shared/agent-hook-listener/listener-limits' -import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +// This main-process adapter keeps listener internals in shared/ so the relay can host the same pipeline without Electron. +import { clearAllListenerCaches } from '../../shared/agent-hook-listener/listener-state' import { normalizeHookPayload } from '../../shared/agent-hook-listener' -import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope' -import { - parseFormEncodedBody, - readRequestBody -} from '../../shared/agent-hook-listener/request-body' -import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import { parseFormEncodedBody } from '../../shared/agent-hook-listener/request-body' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import { - canAcceptClaudeCompactCompletion, - isClaudeCompactCompletionConsumed, - markClaudeCompactCompletionConsumed, - resolveLegacyCompactTrigger -} from '../../shared/claude-compact-completion' -import { - createHookTransportInterferenceTracker, - describeHookTransportInterference, - isHookRequestTruncatedError, - type HookTransportInterferenceReport -} from '../../shared/agent-hook-transport-interference' -import { - claudeTeammateIdMatchesName, - claudeRosterHasRestoredSnapshotSubagent, - claudeRosterHasWorkingSubagent, - claudeRosterToSnapshots -} from '../../shared/claude-subagent-roster' -import { - isAgentHookSource, - restoreShedStatusFields, - type AgentHookSource -} from '../../shared/agent-hook-relay' -import { - CLAUDE_STATUSLINE_PATHNAME, - parseClaudeStatusLineBody, - type ClaudeStatusLineRateLimits -} from '../../shared/claude-statusline-rate-limits' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusClearIpcPayload, - type AgentStatusIpcPayload, - type AgentType, - type AgentStatusState, - type ParsedAgentStatusPayload, - normalizeAgentStatusPayload -} from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' -import { - AgentStatusObservationSequencer, - createAgentStatusAuthorityId, - type AgentStatusObservation, - type AgentStatusObservationOrigin -} from '../../shared/agent-status-observation' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../shared/agent-status-identity' -import { - isAgentInterruptInputIntent, - type AgentInterruptInferenceRequest -} from '../../shared/agent-interrupt-intent' -import { - isAskUserQuestionTool, - type AgentQuestionAnsweredInferenceRequest -} from '../../shared/agent-question-answered-intent' -import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../shared/pane-key-alias' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import type { LegacyPaneKeyAliasEntry } from '../../shared/persisted-state-types' -import { - getAgentResumeArgv, - normalizeAgentProviderSession, - type AgentProviderSessionMetadata -} from '../../shared/agent-session-resume' -import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' -import { - buildSpoolHookBody, - drainAgentHookSpool, - launchTokenHash, - type SpoolRecord -} from '../../shared/agent-hook-spool' -import { CodexSubagentPollScheduler } from '../../shared/codex-subagent-poll-scheduler' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { AgentHookServerLifecycle } from './server/server-lifecycle' +import { isValidPaneKey } from './server/server-status-identity' +export type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload +} from './server/server-types' export type { AgentHookSource } - -// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). -type EnrichedAgentHookEventPayload = AgentHookEventPayload & { - receivedAt: number - stateStartedAt: number - /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ - observation?: AgentStatusObservation - /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ - restoredUnconfirmed?: true - /** User-hidden resume identity retained solely for destructive liveness checks. */ - retainedForLiveness?: true - /** Persisted proof that a lead boundary was held working only by child agents. */ - claudeLeadBoundaryChildOnly?: true -} - -type NormalizedLocalHook = { - event: AgentHookEventPayload | null - onAccepted?: () => void -} - -type PersistedAgentHookEventPayload = Omit< - EnrichedAgentHookEventPayload, - | 'claudeRunningNonAgentTask' - | 'launchToken' - | 'promptInteractionKey' - | 'restoredUnconfirmed' - // Why: revision counters are in-memory and the authority id is regenerated per process, so - // a stored observation could only rehydrate as a stale ordering claim from a dead authority. - | 'observation' -> & { - launchTokenHash?: string -} - -type PersistedAgentHookAuthorityCommitment = { - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -} - -export type AgentHookStatusChangeEntry = { - state: AgentStatusState - receivedAt: number - observedInCurrentRuntime: boolean -} - -export type AgentHookProviderSessionIdentity = { - paneKey: string - sessionId: string - transcriptPath?: string - worktreeId?: string -} - -export type AgentHookAuthorityEvidence = Readonly<{ - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -}> - -export type AgentHookAuthorityAttestation = Readonly<{ - paneKey: string - source: 'current_hook' | 'hydrated_commitment' -}> - -type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void -type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void -type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void -type StatusDropListener = (paneKey: string) => void -type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void -type PaneKeyAliasEntry = { - stablePaneKey: string - ptyId: string | null - updatedAt: number - authorityVerified: boolean -} -type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } -/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ -type RetiredPaneFence = { - paneKeys: readonly string[] - aliases: readonly RetiredPaneAlias[] -} - -// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. -const LAST_STATUS_FILE_NAME = 'last-status.json' -const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 -const ASSISTANT_MESSAGE_RETRY_MS = 50 -const CODEX_SUBAGENT_POLL_MS = 1_000 -const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 - -type CodexSubagentPoll = { - source: AgentHookSource - body: unknown - original: EnrichedAgentHookEventPayload -} - -// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). -const LAST_STATUS_FILE_VERSION = 2 - -// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. -const STATUS_PERSIST_DEBOUNCE_MS = 250 -const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) -const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) - -// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. -const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 - -// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. -export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 -export const PANE_KEY_ALIASES_MAX = 1024 -export const RETIRED_PANE_FENCES_MAX = 1024 - -type LastStatusFile = { - version: number - entries: Record - authorityCommitments?: Record -} - -type AgentPromptSentDedupeEntry = { - agentKind: AgentKind - promptHash: string - promptInteractionKey?: string -} - -function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { - const normalized = agentType?.trim().toLowerCase() - if (!normalized || normalized === 'unknown') { - return 'other' - } - if (normalized === 'claude') { - return 'claude-code' - } - return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) - ? (normalized as AgentKind) - : 'other' -} - -function equivalentInterruptAgentType( - actual: AgentType | undefined, - baseline: AgentType | undefined -): boolean { - const normalizedActual = actual === 'unknown' ? undefined : actual - const normalizedBaseline = baseline === 'unknown' ? undefined : baseline - return normalizedActual === normalizedBaseline -} - -// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. -export function isValidPaneKey(value: unknown): value is string { - return ( - typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null - ) -} - -function dropHydratedIdleClaudeSubagents( - payload: ParsedAgentStatusPayload -): ParsedAgentStatusPayload { - if ( - payload.agentType !== 'claude' || - !payload.subagents?.some((subagent) => subagent.state === 'idle') - ) { - return payload - } - const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') - // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. - return { - ...payload, - subagents: activeSubagents.length > 0 ? activeSubagents : undefined - } -} - -// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. -function isValidPiProviderSessionOnly( - providerSession: AgentProviderSessionMetadata | undefined, - agentType: AgentType | undefined -): boolean { - return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) -} - -function sanitizeHydratedEntry( - paneKey: string, - rawEntry: unknown -): EnrichedAgentHookEventPayload | null { - const parsedPaneKey = parsePaneKey(paneKey) - if (!parsedPaneKey) { - return null - } - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - if (record.paneKey !== paneKey) { - return null - } - const tabId = record.tabId - if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { - return null - } - // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. - if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { - return null - } - const worktreeId = record.worktreeId - if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { - return null - } - const receivedAt = record.receivedAt - if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { - return null - } - const stateStartedAt = record.stateStartedAt - if ( - typeof stateStartedAt !== 'number' || - !Number.isFinite(stateStartedAt) || - stateStartedAt <= 0 - ) { - return null - } - // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. - const connectionIdRaw = record.connectionId - let connectionId: string | null - if (connectionIdRaw === null || connectionIdRaw === undefined) { - connectionId = null - } else if (typeof connectionIdRaw === 'string') { - connectionId = connectionIdRaw - } else { - return null - } - const payload = normalizeAgentStatusPayload(record.payload) - if (!payload) { - return null - } - const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined - const providerSessionOnly = record.providerSessionOnly === true - const retainedForLiveness = record.retainedForLiveness === true - const validRetainedIdentity = Boolean( - retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' - ) - if ( - providerSessionOnly && - !isValidPiProviderSessionOnly(providerSession, payload.agentType) && - !validRetainedIdentity - ) { - return null - } - const source = isAgentHookSource(record.source) ? record.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') - ? record.compactTrigger - : undefined - return { - paneKey, - source, - tabId: typeof tabId === 'string' ? tabId : undefined, - worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, - connectionId, - hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, - hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, - providerPromptId, - compactTrigger, - toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, - toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, - teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, - toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, - claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, - providerSession, - providerSessionOnly: providerSessionOnly ? true : undefined, - retainedForLiveness: retainedForLiveness ? true : undefined, - payload, - receivedAt, - stateStartedAt - } -} - -function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { - return launchTokenHash - } - const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' - return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null -} - -function sanitizePersistedAuthorityCommitment( - paneKey: string, - value: unknown -): AgentHookAuthorityEvidence | null { - if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { - return null - } - const record = value as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - const connectionId = record.connectionId - const observedAt = record.observedAt - if ( - !/^[a-f0-9]{64}$/.test(launchTokenHash) || - (connectionId !== null && typeof connectionId !== 'string') || - typeof observedAt !== 'number' || - !Number.isFinite(observedAt) - ) { - return null - } - return Object.freeze({ - paneKey, - launchTokenHash, - connectionId, - ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), - ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), - observedAt - }) -} - -function authorityCommitmentsMatch( - left: AgentHookAuthorityEvidence, - right: AgentHookAuthorityEvidence -): boolean { - return ( - left.paneKey === right.paneKey && - left.launchTokenHash === right.launchTokenHash && - left.connectionId === right.connectionId && - left.tabId === right.tabId && - left.worktreeId === right.worktreeId - ) -} - -function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { - return { - paneKey: entry.paneKey, - ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), - tabId: entry.tabId, - worktreeId: entry.worktreeId, - connectionId: entry.connectionId, - receivedAt: entry.receivedAt, - stateStartedAt: entry.stateStartedAt, - ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), - ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), - ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), - ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(entry.observation ? { observation: entry.observation } : {}), - ...entry.payload - } -} - -function trackEmptyPaneKeyHook(body: unknown): void { - if (typeof body !== 'object' || body === null) { - return - } - const paneKey = (body as Record).paneKey - if (typeof paneKey === 'string' && paneKey.trim().length > 0) { - return - } - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) -} - -function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { - if (next.payload.state !== 'working') { - return false - } - if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { - return false - } - // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. - return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) -} - -function paneCacheKeyTabId(key: string): string | null { - const paneKey = key.split('\0', 1)[0] ?? key - return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null -} - -function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { - return paneCacheKeyTabId(key) === tabId -} - -function attachClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { - const establishesBoundary = - next.payload.agentType === 'claude' && - (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && - !next.toolAgentId && - next.payload.state === 'working' && - next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && - next.claudeRunningNonAgentTask === false - const carriesBoundary = - previous?.claudeLeadBoundaryChildOnly === true && - next.payload.agentType === 'claude' && - next.claudeRunningNonAgentTask === false && - (next.toolAgentId !== undefined || - next.hookEventName === 'SubagentStart' || - next.hookEventName === 'SubagentStop' || - next.hookEventName === 'TeammateIdle') - return establishesBoundary || carriesBoundary - ? { ...next, claudeLeadBoundaryChildOnly: true } - : next -} - -function invalidateClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): EnrichedAgentHookEventPayload | undefined { - if ( - previous?.claudeLeadBoundaryChildOnly !== true || - attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true - ) { - return previous - } - const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous - return withoutBoundary -} - -function shouldKeepClaudePermissionVisible( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if (previous?.restoredUnconfirmed) { - return false - } - if ( - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'waiting' || - previous.hookEventName !== 'PermissionRequest' || - next.payload.agentType !== 'claude' || - next.payload.state !== 'working' - ) { - return false - } - if (next.hasExplicitPrompt === true) { - return false - } - if (isClaudePermissionOwningChildEnding(previous, next)) { - return false - } - if (isClaudePermissionResumingApprovedTool(previous, next)) { - return false - } - // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. - if (isAskUserQuestionTool(previous.payload.toolName)) { - return false - } - return true -} - -function isClaudePermissionOwningChildEnding( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const ownerId = previous.toolAgentId?.trim() - if (!ownerId) { - return false - } - if (next.hookEventName === 'SubagentStop') { - return ownerId === next.toolAgentId?.trim() - } - return ( - next.hookEventName === 'TeammateIdle' && - next.teammateName !== undefined && - claudeTeammateIdMatchesName(ownerId, next.teammateName) - ) -} - -function isClaudePermissionResumingApprovedTool( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const previousToolUseId = previous.toolUseId?.trim() || undefined - const nextToolUseId = next.toolUseId?.trim() || undefined - const previousAgentId = previous.toolAgentId?.trim() || undefined - const nextAgentId = next.toolAgentId?.trim() || undefined - const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined - const previousAgentType = previous.toolAgentType?.trim() || undefined - const nextAgentType = next.toolAgentType?.trim() || undefined - const hasMatchingConcreteAgentId = - previousAgentId !== undefined && previousAgentId === nextAgentId - const hasSameExplicitAgentType = - !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType - const sameToolName = - previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownInputFromConcreteAgent = - hasMatchingConcreteAgentId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - const hasMatchingToolUseId = - previousToolUseId !== undefined && previousToolUseId === nextToolUseId - const hasConflictingToolUseId = - previousToolUseId !== undefined && - nextToolUseId !== undefined && - previousToolUseId !== nextToolUseId - const sameUnknownInputFromToolUseId = - hasMatchingToolUseId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - - return ( - (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && - nextToolUseId !== undefined && - !hasConflictingToolUseId && - // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. - (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && - sameToolName && - (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) - ) -} - -function shouldInheritClaudeToolUseIdForPermission( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if ( - previous?.restoredUnconfirmed || - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'working' || - previous.hookEventName !== 'PreToolUse' || - typeof previous.toolUseId !== 'string' || - previous.toolUseId.trim().length === 0 || - next.payload.agentType !== 'claude' || - next.payload.state !== 'waiting' || - next.hookEventName !== 'PermissionRequest' || - next.toolUseId !== undefined - ) { - return false - } - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownToolInput = - previous.payload.toolInput === undefined && next.payload.toolInput === undefined - if ( - previous.toolAgentId !== next.toolAgentId || - previous.toolAgentType !== next.toolAgentType || - previous.payload.toolName === undefined || - previous.payload.toolName !== next.payload.toolName || - (!sameKnownToolInput && !sameUnknownToolInput) - ) { - return false - } - return true -} - -function attachClaudePermissionToolUseId( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload { - const inheritedToolUseId = previous?.toolUseId - if ( - !shouldInheritClaudeToolUseIdForPermission(previous, next) || - typeof inheritedToolUseId !== 'string' - ) { - return next - } - return { - ...next, - // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. - toolUseId: inheritedToolUseId - } -} - -export class AgentHookServer { - private server: ReturnType | null = null - private port = 0 - private token = '' - // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. - private env = 'production' - private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null - private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null - private onPaneStatusCleared: PaneStatusClearListener | null = null - private paneStatusClearListeners = new Set() - private statusDropListeners = new Set() - private statusChangeListeners = new Set() - private providerSessionChangeListeners = new Set() - // Why: setListener is a single slot owned by the main-window fanout; the - // plugin event bus (and future consumers) need an additive subscription - // that also works in headless serve, where no window listener exists. - private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() - // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). - private endpointDir: string | null = null - private endpointFilePathCache: string | null = null - private endpointFileWritten = false - // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. - private state: HookListenerState = createHookListenerState() - private onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = null - private transportInterference = createHookTransportInterferenceTracker((report) => { - console.warn(describeHookTransportInterference(report)) - this.onTransportInterference?.(report) - }) - // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. - private runtimeObservedStatusPaneKeys = new Set() - private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) - private hydratedLaunchTokenHashByPaneKey = new Map() - private persistedAuthorityCommitmentsByPaneKey = new Map() - private revokedHydratedAuthorityCommitments = new WeakSet() - private currentAuthorityObservations = new Map() - private legacyPaneKeyAliases = new Map() - // Why: indexed by every key the retirement fenced, so a re-attach on any of them - // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps - // it mirrors; an evicted record simply degrades to lifting the key it was handed. - private retiredPaneFencesByKey = new Map() - private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null - // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. - private lastStatusFilePath: string | null = null - // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. - private statusPersistTimer: ReturnType | null = null - private assistantMessageRetryTimers = new Map>() - private codexSubagentPollScheduler = new CodexSubagentPollScheduler( - CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) - ) - private promptSentDedupeByPaneKey = new Map() - private activeHookTurnCompletedAtByPaneKey = new Map() - private promptSentHashSalt = randomBytes(16).toString('hex') - private closedAgentStatusTabIds = new Set() - private closedAgentStatusPaneKeys = new Set() - private restartedStatusLaunchTokenHashByPaneKey = new Map() - private connectionTimestampWatermarkById = new Map() - // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. - private lastWrittenJson: string | null = null - // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own - // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. - private readonly observations = new AgentStatusObservationSequencer( - createAgentStatusAuthorityId('main-agent-hooks') - ) - - /** - * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). - * Why: the listener fails open on every request error, so without this the only symptom is - * agent status quietly going stale — for every runtime at once, since they share this transport. - */ - setTransportInterferenceListener( - listener: ((report: HookTransportInterferenceReport) => void) | null - ): void { - this.onTransportInterference = listener - } - - setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { - this.onAgentStatus = listener - if (!listener) { - return - } - // Why: replay is best-effort per pane so one throwing listener can't starve the rest. - for (const payload of this.state.lastStatusByPaneKey.values()) { - try { - // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. - listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) - } catch (err) { - console.error('[agent-hooks] replay listener threw', err) - } - } - } - - // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. - setClaudeStatusLineListener( - listener: ((event: ClaudeStatusLineRateLimits) => void) | null - ): void { - this.onClaudeStatusLine = listener - } - - subscribeStatusChanges(listener: StatusChangeListener): () => void { - this.statusChangeListeners.add(listener) - return () => { - this.statusChangeListeners.delete(listener) - } - } - - subscribeProviderSessionChanges(listener: ProviderSessionChangeListener): () => void { - this.providerSessionChangeListeners.add(listener) - return () => { - this.providerSessionChangeListeners.delete(listener) - } - } - - /** Multi-subscriber tap on every enriched status change (no replay). */ - subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { - this.enrichedStatusListeners.add(listener) - return () => { - this.enrichedStatusListeners.delete(listener) - } - } - - /** Replay is durable evidence from a prior runtime, not a live observation. */ - private withdrawReplayObservation(paneKey: string): void { - if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { - this.notifyStatusChangeListeners() - } - } - - private ingestSpoolRecord(record: SpoolRecord): void { - if (!isAgentHookSource(record.source)) { - return - } - const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) - const normalized = this.normalizeLocalHookPayload(record.source, body) - if (!normalized.event) { - return - } - const replay = { ...normalized.event, isReplay: true as const } - const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { - source: record.source, - hookEventName: replay.hookEventName, - isReplay: true, - hasExplicitPrompt: replay.hasExplicitPrompt, - launchToken: replay.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay - if (statusDisposition === 'restart') { - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus(event, normalized.onAccepted) - if (event.payload.state !== 'done') { - this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) - } - } - - setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { - this.onPaneStatusCleared = listener - } - - /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` - * (a single slot the main window owns and drops on close) this survives window - * teardown and exists at all under headless serve, which never opens one. */ - subscribePaneStatusClear(listener: PaneStatusClearListener): () => void { - this.paneStatusClearListeners.add(listener) - return () => { - this.paneStatusClearListeners.delete(listener) - } - } - - /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user - * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned - * cleanup (synthetic spinners) still has to retire with the row it was driving. */ - subscribeStatusDrop(listener: StatusDropListener): () => void { - this.statusDropListeners.add(listener) - return () => { - this.statusDropListeners.delete(listener) - } - } - - private emitStatusDropped(paneKey: string): void { - for (const listener of this.statusDropListeners) { - // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. - try { - listener(paneKey) - } catch (err) { - console.error('[agent-hooks] status-drop listener threw', err) - } - } - } - - private emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { - this.onPaneStatusCleared?.(clear) - for (const listener of this.paneStatusClearListeners) { - // Why: callers are pane/connection teardown paths; one throwing subscriber must - // not strand the rest, matching every other fan-out here. - try { - listener(clear) - } catch (err) { - console.error('[agent-hooks] pane-status-clear listener threw', err) - } - } - } - - /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the - * dashboard catches up on hook events that fired during startup. */ - getStatusSnapshot(): AgentStatusIpcPayload[] { - return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => - toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) - ) - } - - /** Provider-session identities, including Pi's metadata-only rows. */ - getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { - return this.buildStatusChangeNotification().providerSessions - } - - getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { - const entry = this.state.lastStatusByPaneKey.get(paneKey) - return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] - } - - getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { - return this.hydratedAuthorityCommitments - } - - getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { - return Object.freeze( - Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) - ) - } - - attestCompatibilityAuthority(candidate: { - paneKey: string - launchTokenHash: string - connectionId: string | null - terminalProvenance: 'current_runtime' | 'restored' - }): AgentHookAuthorityAttestation | null { - const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) - const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => - entry.launchTokenHash === candidate.launchTokenHash && - entry.connectionId === candidate.connectionId - const commitments = this.hydratedAuthorityCommitments.filter( - (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) - ) - const current = Array.from(this.currentAuthorityObservations.values()) - const observations = current.filter(matchesCandidate) - const paneObservations = current.filter( - (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey - ) - const hasUniqueCurrentObservation = - observations.length === 1 && - paneObservations.length === 1 && - this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey - if (candidate.terminalProvenance === 'current_runtime') { - return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null - } - if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { - return null - } - if (observations.length === 0 && paneObservations.length === 0) { - return Object.freeze({ paneKey, source: 'hydrated_commitment' }) - } - if (!hasUniqueCurrentObservation) { - return null - } - return Object.freeze({ paneKey, source: 'current_hook' }) - } - - inferInterrupt(request: AgentInterruptInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - if (!isAgentInterruptInputIntent(request.intent)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - if (existing.providerSessionOnly) { - return false - } - // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - const agentType: AgentType | undefined = payload.agentType - // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. - if (agentType === 'droid' && request.intent === 'ctrl-c') { - return false - } - // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. - if ( - (agentType === 'opencode' || agentType === 'copilot') && - request.intent === 'plain-escape' && - request.inputCount !== 2 - ) { - return false - } - const dismissesClaudeQuestion = - agentType === 'claude' && - request.intent === 'plain-escape' && - payload.state === 'waiting' && - isAskUserQuestionTool(payload.toolName) - if (dismissesClaudeQuestion) { - return this.inferQuestionAnswered(request) - } - // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. - if ( - payload.state !== 'working' || - !equivalentInterruptAgentType(agentType, request.baselineAgentType) || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. - if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { - return false - } - // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. - if ( - agentType === 'claude' && - (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) - ) { - return false - } - - // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. - if (agentType === 'claude') { - markClaudeLeadTurnInterrupted(this.state, existing.paneKey) - } - if (agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, existing.paneKey) - } - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: 'done', - prompt: payload.prompt, - agentType, - ...(payload.model ? { model: payload.model } : {}), - interrupted: true, - // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred interrupted agent status', { - paneKey: inferred.paneKey, - agentType, - intent: request.intent - }) - return true - } - - /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ - inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. - if ( - payload.agentType !== 'claude' || - payload.state !== 'waiting' || - !isAskUserQuestionTool(payload.toolName) - ) { - return false - } - if ( - payload.agentType !== request.baselineAgentType || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. - const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: restored.state, - ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), - prompt: payload.prompt, - agentType: payload.agentType, - ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}), - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred resolved question status', { - paneKey: inferred.paneKey, - state: inferred.payload.state - }) - return true - } - - getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { - return this.buildStatusChangeNotification().statuses - } - - private buildStatusChangeNotification(): { - statuses: AgentHookStatusChangeEntry[] - providerSessions: AgentHookProviderSessionIdentity[] - } { - const statuses: AgentHookStatusChangeEntry[] = [] - const providerSessions: AgentHookProviderSessionIdentity[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if (enriched.providerSession) { - providerSessions.push({ - paneKey, - sessionId: enriched.providerSession.id, - ...(enriched.providerSession.transcriptPath - ? { transcriptPath: enriched.providerSession.transcriptPath } - : {}), - ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) - }) - } - if (!enriched.providerSessionOnly) { - statuses.push({ - state: enriched.payload.state, - receivedAt: enriched.receivedAt, - observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) - }) - } - } - return { statuses, providerSessions } - } - - private notifyStatusChangeListeners(): void { - if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { - return - } - const { statuses, providerSessions } = this.buildStatusChangeNotification() - for (const listener of this.statusChangeListeners) { - try { - listener(statuses) - } catch (err) { - console.error('[agent-hooks] status-change listener threw', err) - } - } - for (const listener of this.providerSessionChangeListeners) { - try { - listener(providerSessions) - } catch (err) { - console.error('[agent-hooks] provider-session listener threw', err) - } - } - } - - private markTabClosedForAgentStatus(tabId: string): void { - // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. - this.closedAgentStatusTabIds.delete(tabId) - this.closedAgentStatusTabIds.add(tabId) - while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - const oldest = this.closedAgentStatusTabIds.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusTabIds.delete(oldest) - } - } - - private getAgentStatusDisposition( - paneKey: string, - event?: { - source?: AgentHookSource - /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ - rawSource?: unknown - hookEventName?: string - isReplay?: boolean - hasExplicitPrompt?: boolean - launchToken?: string - } - ): 'accept' | 'restart' | 'suppress' { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneRetired = - this.closedAgentStatusPaneKeys.has(paneKey) || - this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (tabId && this.closedAgentStatusTabIds.has(tabId)) { - return 'suppress' - } - if (!paneRetired) { - const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) - // Why: deferred retirement lets a new process start in a still-authorized pane, so - // its tokened SessionStart re-fences; prompts recur, so a stale process would win. - if ( - event?.hookEventName === 'SessionStart' && - event.isReplay !== true && - tokenFence !== undefined - ) { - const startedLaunchToken = event.launchToken?.trim() - if (startedLaunchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(startedLaunchToken).digest('hex') - ) - return 'accept' - } - } - if (event && tokenFence) { - const launchToken = event.launchToken?.trim() - if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { - return 'suppress' - } - } - return 'accept' - } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live new-turn event proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). - // Why the classifier, not literals: only 5 of 18 sources name their boundary - // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. - // Why four branches: `source` collapses to undefined when an older relay omits the field, - // when a newer host sends an unknown string, and when the wire value is malformed. Only an - // unknown string is valid future-provider evidence. Unreachable from the local path, which - // 404s an unresolvable source. - const isNewTurn = - event?.source !== undefined - ? isNewTurnEvent(event.source, event.hookEventName) - : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 - ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and - // the costs are asymmetric — a stranded pane is invisible and permanent with no user - // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. - true - : event?.rawSource === undefined - ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it - // cannot revive a provider whose boundary event is named anything else. - event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' - : false - // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in - // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no - // SessionStart at all, so without this its retired panes never come back. - const freshOpenCodeFamilyPrompt = - (event?.source === 'opencode' || event?.source === 'mimo-code') && - event.hookEventName === 'MessagePart' && - event.hasExplicitPrompt === true - // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up - // status on that launch token stops a stale process reclaiming the pane's row without - // restoring retired orchestration authority. - if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.delete(ownerPaneKey) - const launchToken = event?.launchToken?.trim() - if (launchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(launchToken).digest('hex') - ) - } else { - this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) - } - return 'restart' - } - return 'suppress' - } - - // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric - // keys never parse as stable ones — resolve both forms so neither slips the tab check. - private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { - const tabId = - parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined - return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) - } - - private recordRetiredPaneFence( - paneKeys: ReadonlySet, - aliases: readonly RetiredPaneAlias[] - ): void { - const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } - for (const key of paneKeys) { - // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. - this.retiredPaneFencesByKey.delete(key) - this.retiredPaneFencesByKey.set(key, fence) - } - while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { - const oldest = this.retiredPaneFencesByKey.keys().next().value - if (oldest === undefined) { - break - } - this.retiredPaneFencesByKey.delete(oldest) - } - } - - private markPaneClosedForAgentStatus(paneKey: string): void { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.add(paneKey) - while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { - const oldest = this.closedAgentStatusPaneKeys.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusPaneKeys.delete(oldest) - } - } - - private attachStatusTiming( - payload: AgentHookEventPayload, - now = Date.now() - ): EnrichedAgentHookEventPayload { - const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const commandCodeNewTurn = - previous !== undefined && - isCommandCodeNewTurnWhileWorking({ - agentType: payload.payload.agentType, - previousState: previous.payload.state, - incomingState: payload.payload.state, - previousPrompt: previous.payload.prompt, - incomingPrompt: payload.payload.prompt, - hasExplicitPrompt: payload.hasExplicitPrompt, - previousPromptInteractionKey: previous.promptInteractionKey, - incomingPromptInteractionKey: payload.promptInteractionKey - }) - const stateStartedAt = - previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn - ? previous.stateStartedAt - : now - return { - ...payload, - receivedAt: now, - stateStartedAt - } - } - - private hashPromptForTelemetryDedupe(prompt: string): string { - return createHash('sha256') - .update(this.promptSentHashSalt) - .update('\0') - .update(prompt) - .digest('hex') - } - - private maybeTrackAgentPromptSent( - payload: AgentHookEventPayload, - previousStatus: EnrichedAgentHookEventPayload | undefined - ): void { - if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { - return - } - const prompt = payload.payload.prompt?.trim() ?? '' - if (prompt.length === 0) { - return - } - const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) - const promptHash = this.hashPromptForTelemetryDedupe(prompt) - const promptInteractionKey = - typeof payload.promptInteractionKey === 'string' && - payload.promptInteractionKey.trim().length > 0 - ? payload.promptInteractionKey.trim() - : undefined - const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) - const isCompletedTurnBoundary = - previousStatus?.payload.state === 'done' && payload.payload.state === 'working' - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey === promptInteractionKey && - (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) - ) { - return - } - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptHash === promptHash && - !( - previousStatus?.payload.state === 'done' && - payload.payload.state === 'done' && - previousDedupe.promptInteractionKey !== undefined && - promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey !== promptInteractionKey - ) && - !isCompletedTurnBoundary - ) { - return - } - this.promptSentDedupeByPaneKey.set(payload.paneKey, { - agentKind, - promptHash, - promptInteractionKey - }) - try { - // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. - track('agent_prompt_sent', { - agent_kind: agentKind, - launch_source: 'unknown', - request_kind: 'followup', - ...getCohortAtEmit() - }) - } catch (err) { - console.error('[agent-hooks] prompt-sent telemetry failed', err) - } - } - - /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet - * (STA-4293) — it is stamped here because every main-side ingress funnels through - * applyNormalizedStatus, so no origin can silently arrive untagged. */ - private stampObservation( - payload: AgentHookEventPayload, - origin: AgentStatusObservationOrigin, - observedAt: number - ): AgentStatusObservation { - return this.observations.observe(payload.paneKey, { - origin, - observedAt, - // Why: reuse the listener's own per-provider classifier; a second list of raw event-name - // literals here would strand the providers whose boundary event is named anything else. - boundary: - payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), - kind: payload.providerSessionOnly - ? 'identity-only' - : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the - // current state rather than announcing a change — neither is a fresh transition. - payload.isReplay === true || origin === 'osc' - ? 'snapshot' - : 'transition' - }) - } - - private applyNormalizedStatus( - payload: AgentHookEventPayload, - onAccepted?: () => void, - origin: AgentStatusObservationOrigin = 'hook' - ): EnrichedAgentHookEventPayload { - if (payload.hookEventName === 'UserPromptSubmit') { - // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. - this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) - } - let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const connectionClearWatermark = payload.connectionId - ? this.connectionTimestampWatermarkById.get(payload.connectionId) - : undefined - // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. - const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined - const now = Math.max( - Date.now(), - (connectionClearWatermark ?? -1) + 1, - (restoredStatusWatermark ?? -1) + 1 - ) - if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) - } - if (payload.providerSessionOnly) { - // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. - onAccepted?.() - const enriched = { - ...this.attachStatusTiming(payload, now), - observation: this.stampObservation(payload, origin, now) - } - this.clearAssistantMessageRetry(enriched.paneKey) - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - const stateReconciledPayload = - payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName - ? { - ...payload, - payload: reconcileRemoteCodexState( - this.state, - payload.paneKey, - payload.hookEventName, - payload.toolAgentId, - payload.payload, - previous?.payload - ) - } - : payload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && - stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' - ? previous - : undefined - const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession - : undefined - const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model - : undefined - // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. - const rootContextPreservingPayload = - preservedProviderSession || preservedRootModel - ? { - ...stateReconciledPayload, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: preservedRootModel - ? { ...stateReconciledPayload.payload, model: preservedRootModel } - : stateReconciledPayload.payload - } - : stateReconciledPayload - const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( - previous, - rootContextPreservingPayload - ) - if (boundaryReconciledPrevious !== previous) { - previous = boundaryReconciledPrevious - if (previous) { - this.state.lastStatusByPaneKey.set(previous.paneKey, previous) - this.scheduleStatusPersist() - } - } - const identity = resolveAgentStatusIdentity({ - existing: previous - ? { - agentType: previous.payload.agentType, - state: previous.payload.state, - updatedAt: previous.receivedAt, - restoredUnconfirmed: previous.restoredUnconfirmed - } - : undefined, - incoming: rootContextPreservingPayload.payload.agentType, - now - }) - if ( - previous && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: rootContextPreservingPayload.payload.state - }) - ) { - return previous - } - const identityResolvedPayload = - identity.agentType === rootContextPreservingPayload.payload.agentType - ? rootContextPreservingPayload - : { - ...rootContextPreservingPayload, - payload: { - ...rootContextPreservingPayload.payload, - agentType: identity.agentType - } - } - const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) - if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { - return previous - } - // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'done' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS - ) { - return previous - } - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'working' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - (effectivePayload.isReplay === true || - isToolProgressWorkingAfterInterrupt(effectivePayload) || - (effectivePayload.hasExplicitPrompt !== true && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) - ) { - if (effectivePayload.payload.agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) - } - return previous - } - if ( - effectivePayload.payload.state !== 'done' || - effectivePayload.payload.lastAssistantMessage - ) { - this.clearAssistantMessageRetry(effectivePayload.paneKey) - } - onAccepted?.() - if (!identity.inheritedFromActivePane) { - this.maybeTrackAgentPromptSent(effectivePayload, previous) - } - const enriched = { - ...this.attachStatusTiming(boundaryAwarePayload, now), - observation: this.stampObservation(boundaryAwarePayload, origin, now) - } - if ( - typeof enriched.payload.turnCompletedAt === 'number' && - Number.isFinite(enriched.payload.turnCompletedAt) - ) { - this.activeHookTurnCompletedAtByPaneKey.set( - enriched.paneKey, - enriched.payload.turnCompletedAt - ) - } - // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. - if (enriched.restoredUnconfirmed) { - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - } else { - this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) - } - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - - // Why: every status emit must reach plugins too, so a new early-return path - // upstream cannot silently leave the plugin tap behind the main-window fanout. - private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { - this.onAgentStatus?.(enriched) - for (const listener of this.enrichedStatusListeners) { - try { - listener(enriched) - } catch (err) { - console.error('[agent-hooks] enriched status listener threw', err) - } - } - } - - private clearAssistantMessageRetry(paneKey: string): void { - const timer = this.assistantMessageRetryTimers.get(paneKey) - if (!timer) { - return - } - clearTimeout(timer) - this.assistantMessageRetryTimers.delete(paneKey) - } - - private clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) - } - - private scheduleCodexSubagentPoll( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload - ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { - return - } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { - return - } - this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) - } - - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { - const { source, body, original } = poll - // Keep the identity check at callback time: a newer event supersedes this - // payload even when its pane still has transcript children. - if ( - paneKey !== original.paneKey || - !this.server || - this.state.lastStatusByPaneKey.get(original.paneKey) !== original - ) { - return - } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized) { - return - } - const subagentsChanged = - JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original - this.scheduleCodexSubagentPoll(source, body, next) - } - - private scheduleAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - attempt = 1, - discoveryReady = false - ): void { - if ( - original.payload.lastAssistantMessage || - !hasPendingAgentResultText(source, body) || - attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS - ) { - return - } - this.clearAssistantMessageRetry(original.paneKey) - if (!discoveryReady) { - const discovery = preparePendingGrokResultDiscovery(source, body) - if (discovery) { - // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. - void discovery - .then(() => { - if (this.server) { - this.applyAssistantMessageRetry(source, body, original, 1, true) - } - }) - .catch((err) => { - console.error('[agent-hooks] Grok result discovery failed:', err) - }) - return - } - } - const timer = setTimeout(() => { - try { - this.assistantMessageRetryTimers.delete(original.paneKey) - this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) - } catch (err) { - console.error('[agent-hooks] assistant message retry failed:', err) - } - }, ASSISTANT_MESSAGE_RETRY_MS) - this.assistantMessageRetryTimers.set(original.paneKey, timer) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - - private applyAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - nextAttempt: number, - requireExactOriginal: boolean - ): void { - const current = this.state.lastStatusByPaneKey.get(original.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - !current || - (requireExactOriginal && current !== original) || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const normalized = this.normalizeLocalHookPayload(source, body) - if (!normalized.event?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) - return - } - // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. - this.applyNormalizedStatus(normalized.event, normalized.onAccepted) - } - - setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { - this.paneKeyAliasPersistenceListener = listener - } - - private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { - return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => - entry.ptyId - ? [ - { - ptyId: entry.ptyId, - legacyPaneKey, - stablePaneKey: entry.stablePaneKey, - updatedAt: entry.updatedAt - } - ] - : [] - ) - } - - private notifyPaneKeyAliasPersistenceListener(): void { - this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) - } - - private boundPaneKeyAliases(): void { - while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { - // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. - const oldestKey = this.legacyPaneKeyAliases.keys().next().value - if (!oldestKey) { - break - } - this.legacyPaneKeyAliases.delete(oldestKey) - } - } - - private getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - let fallbackPaneKey = paneKey - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if ( - entry.stablePaneKey === ownerPaneKey && - (!ptyId || !entry.ptyId || entry.ptyId === ptyId) - ) { - if (entry.authorityVerified) { - return physicalPaneKey - } - fallbackPaneKey = physicalPaneKey - } - } - return fallbackPaneKey - } - - canTransferPaneAuthority( - fromPaneKey: string, - ptyId: string | undefined, - ownsPty: (physicalPaneKey: string, ptyId: string) => boolean - ): boolean { - if (!isValidPaneKey(fromPaneKey)) { - return false - } - const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) - if (ptyId) { - return Boolean( - (alias?.authorityVerified && alias.ptyId === ptyId) || - ownsPty(physicalPaneKey, ptyId) || - (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) - ) - } - // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. - return alias?.authorityVerified === true - } - - registerPaneKeyAlias( - legacyPaneKey: string, - stablePaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { overwriteExisting?: boolean; authorityVerified?: boolean } - ): void { - const fromPaneKey = legacyPaneKey.trim() - const toPaneKey = stablePaneKey.trim() - if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { - return - } - const existing = this.legacyPaneKeyAliases.get(fromPaneKey) - if (existing && options?.overwriteExisting === false) { - return - } - // Why: remint tokens have no embedded tab id; first pane wins so a later spawn - // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. - if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { - return - } - const normalizedPtyId = - typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId - const normalizedUpdatedAt = - Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) - const authorityVerified = options?.authorityVerified ?? false - if ( - existing && - existing.stablePaneKey === toPaneKey && - existing.ptyId === (normalizedPtyId ?? null) && - existing.updatedAt === normalizedUpdatedAt && - existing.authorityVerified === authorityVerified - ) { - return - } - this.legacyPaneKeyAliases.set(fromPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId ?? null, - updatedAt: normalizedUpdatedAt, - authorityVerified - }) - this.boundPaneKeyAliases() - if (normalizedPtyId) { - this.notifyPaneKeyAliasPersistenceListener() - } - } - - transferPaneAuthority( - fromPaneKey: string, - toPaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { authorityVerified?: boolean } - ): void { - if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { - return - } - const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) - const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null - const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) - movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) - const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (movedStatus) { - const owner = parsePaneKey(toPaneKey) - this.state.lastStatusByPaneKey.set(toPaneKey, { - ...movedStatus, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - } - const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - if (hydratedLaunchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) - } - const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) - if (persistedAuthority) { - const owner = parsePaneKey(toPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.set( - toPaneKey, - Object.freeze({ - ...persistedAuthority, - paneKey: toPaneKey, - ...(owner?.tabId ? { tabId: owner.tabId } : {}) - }) - ) - } - if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { - this.runtimeObservedStatusPaneKeys.add(toPaneKey) - } - const restartedTokenHash = - this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) - if (restartedTokenHash) { - this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) - } - const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) - if (activeTurnCompletedAt !== undefined) { - this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) - this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) - } - const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) - if (authorityObservation) { - const owner = parsePaneKey(toPaneKey) - this.currentAuthorityObservations.delete(previousOwnerPaneKey) - this.currentAuthorityObservations.set( - toPaneKey, - Object.freeze({ - ...authorityObservation, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - ) - } - const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) - if (promptDedupe !== undefined) { - this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) - this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) - } - this.clearAssistantMessageRetry(previousOwnerPaneKey) - this.clearCodexSubagentPoll(previousOwnerPaneKey) - // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. - this.legacyPaneKeyAliases.set(physicalPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId, - updatedAt, - authorityVerified: options?.authorityVerified ?? true - }) - this.boundPaneKeyAliases() - this.closedAgentStatusPaneKeys.delete(toPaneKey) - this.notifyPaneKeyAliasPersistenceListener() - if (hadStatus || persistedAuthority) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - retirePaneAuthority(paneKey: string): void { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, ownerPaneKey]) - const retiredAliases: RetiredPaneAlias[] = [] - let aliasChanged = false - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { - this.legacyPaneKeyAliases.delete(physicalPaneKey) - retiredAliases.push({ physicalPaneKey, entry }) - paneKeys.add(physicalPaneKey) - paneKeys.add(entry.stablePaneKey) - aliasChanged = true - } - } - this.recordRetiredPaneFence(paneKeys, retiredAliases) - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) - for (const key of paneKeys) { - this.markPaneClosedForAgentStatus(key) - this.restartedStatusLaunchTokenHashByPaneKey.delete(key) - this.clearAssistantMessageRetry(key) - this.clearCodexSubagentPoll(key) - clearPaneCacheState(this.state, key) - this.activeHookTurnCompletedAtByPaneKey.delete(key) - this.runtimeObservedStatusPaneKeys.delete(key) - this.currentAuthorityObservations.delete(key) - this.promptSentDedupeByPaneKey.delete(key) - this.observations.forget(key) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - // Why: retirement fences a pane and every alias of it, then deletes those aliases. - // Lifting only the key we are handed strands the rest — a detached pane's process - // keeps posting the key it launched under, so it would stay suppressed forever with - // the fence apparently lifted. Replay the recorded fence instead: same key set, same - // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger - // claim survives and a live process is never routed back into a closed tab. - private restoreRetiredPaneFence(fence: RetiredPaneFence): void { - let aliasChanged = false - for (const { physicalPaneKey, entry } of fence.aliases) { - if ( - this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || - this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || - // Why: the pane was rebound in the meantime; the newer alias is the truth. - this.legacyPaneKeyAliases.has(physicalPaneKey) - ) { - continue - } - this.legacyPaneKeyAliases.set(physicalPaneKey, entry) - aliasChanged = true - } - for (const key of fence.paneKeys) { - if (this.retiredPaneFencesByKey.get(key) === fence) { - this.retiredPaneFencesByKey.delete(key) - } - } - if (aliasChanged) { - this.boundPaneKeyAliases() - this.notifyPaneKeyAliasPersistenceListener() - } - } - - // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that - // exact pane disproves the claim at the moment it stops being true, so the fence - // lifts here instead of waiting for the agent to speak again — an agent re-attached - // mid-turn or left idle would otherwise stay suppressed for the rest of its life - // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. - restorePaneAuthority(paneKey: string): boolean { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { - return false - } - const fence = - this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) - let restored = false - for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { - if (this.isClosedAgentStatusTabForPaneKey(key)) { - continue - } - if (this.closedAgentStatusPaneKeys.delete(key)) { - restored = true - } - } - if (fence) { - this.restoreRetiredPaneFence(fence) - } - return restored - } - - clearPaneKeyAliasesForPty( - ptyId: string, - options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } - ): void { - let aliasChanged = false - let statusChanged = false - const clearedStatusPaneKeys = new Set() - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - if (entry.ptyId === ptyId) { - const shouldClearStablePaneKey = - options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true - const revokedPaneKeys = new Set([legacyPaneKey]) - if (shouldClearStablePaneKey) { - revokedPaneKeys.add(entry.stablePaneKey) - } - if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { - statusChanged = true - } - this.legacyPaneKeyAliases.delete(legacyPaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { - statusChanged = true - clearedStatusPaneKeys.add(entry.stablePaneKey) - } - if (shouldClearStablePaneKey) { - // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. - clearPaneCacheState(this.state, entry.stablePaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) - this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) - this.currentAuthorityObservations.delete(entry.stablePaneKey) - this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) - } - aliasChanged = true - } - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - for (const paneKey of clearedStatusPaneKeys) { - this.emitPaneStatusCleared({ paneKey }) - } - } - } - - private resolvePaneKeyAlias(paneKey: string): string { - return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey - } - - private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { - let changed = false - for (const commitment of this.hydratedAuthorityCommitments) { - if ( - paneKeys.has(commitment.paneKey) || - paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) - ) { - this.revokedHydratedAuthorityCommitments.add(commitment) - changed = true - } - } - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed - changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed - } - return changed - } - - private normalizeHookBodyPaneKeyAlias(body: unknown): unknown { - if (typeof body !== 'object' || body === null) { - return body - } - const record = body as Record - const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey - if (!stablePaneKey) { - return body - } - // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. - return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } - } - - private normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { - if (source !== 'claude' || typeof body !== 'object' || body === null) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const rawPaneKey = (body as Record).paneKey - const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' - if (!paneKey) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - const event = normalizeHookPayload(this.state, source, body, this.env) - const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) - if (!event || event.paneKey !== paneKey) { - return { event } - } - // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. - return { - event, - onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) - } - } - - private setClaudeBackgroundEvidence( - paneKey: string, - hasRunningTask: boolean, - hasActiveCron: boolean - ): void { - if (hasRunningTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - if (hasActiveCron) { - this.state.claudeActiveSessionCronPaneKeys.add(paneKey) - } else { - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - } - - ingestTerminalStatus(event: { - paneKey: string - tabId?: string - worktreeId?: string - connectionId?: string | null - payload: ParsedAgentStatusPayload - }): void { - const physicalPaneKey = event.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { - return - } - const reportedTabId = - event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - if (this.getAgentStatusDisposition(paneKey) !== 'accept') { - return - } - const worktreeId = - event.worktreeId !== undefined && event.worktreeId.trim().length > 0 - ? event.worktreeId.trim() - : undefined - const connectionId = - typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 - ? event.connectionId.trim() - : null - const previous = this.state.lastStatusByPaneKey.get(paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - previous?.claudeLeadBoundaryChildOnly === true && - previous.payload.agentType === 'claude' && - event.payload.agentType === 'claude' - ) { - // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. - return - } - const preserveActiveTurnStamp = - previous?.payload.turnCompletedAt !== undefined && - previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) - if ( - !previous?.restoredUnconfirmed && - previous?.connectionId === connectionId && - previous.tabId === tabId && - previous.worktreeId === worktreeId && - terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) - ) { - return - } - // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is - // never evidence that the session ended — yet overwriting the row dropped the cached identity. - // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which - // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). - // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. - // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an - // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so - // it must not be read as a mismatch and strip the session the renderer would have kept. - const claimedAgentType = - event.payload.agentType && event.payload.agentType !== 'unknown' - ? event.payload.agentType - : undefined - const preservedProviderSession = - previous?.providerSession && - (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && - (previous.payload.state !== 'done' || event.payload.state === 'done') - ? previous.providerSession - : undefined - // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. - this.applyNormalizedStatus( - { - paneKey, - tabId, - worktreeId, - connectionId, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: event.payload - }, - undefined, - 'osc' - ) - } - - /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ - ingestRemote( - envelope: { - paneKey: string - tabId?: string - worktreeId?: string - env?: string - version?: string - launchToken?: string - hasExplicitPrompt?: boolean - promptInteractionKey?: string - hookEventName?: string - source?: unknown - providerPromptId?: unknown - compactTrigger?: unknown - toolUseId?: string - toolAgentId?: string - teammateName?: string - toolAgentType?: string - providerSession?: unknown - providerSessionOnly?: unknown - isReplay?: boolean - /** Payload fields the relay dropped to fit an oversized frame; validated below. */ - shedFields?: unknown - claudeRunningNonAgentTask?: unknown - payload: unknown - }, - connectionId: string | null - ): void { - // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (connectionId !== null && typeof connectionId !== 'string') { - return - } - const trimmedConnectionId = connectionId?.trim() ?? null - if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { - return - } - if (!envelope || typeof envelope.paneKey !== 'string') { - return - } - // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. - const physicalPaneKey = envelope.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN) { - return - } - if (!parsedPaneKey) { - return - } - // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. - if (envelope.isReplay === true) { - const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) - if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { - return - } - } - if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { - return - } - if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { - return - } - // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. - const reportedTabId = - envelope.tabId !== undefined && envelope.tabId.trim().length > 0 - ? envelope.tabId.trim() - : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - const hookEventName = - typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 - ? envelope.hookEventName.trim() - : undefined - const source = isAgentHookSource(envelope.source) ? envelope.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && - (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') - ? envelope.compactTrigger - : undefined - const statusDisposition = this.getAgentStatusDisposition(paneKey, { - source, - rawSource: envelope.source, - hookEventName, - isReplay: envelope.isReplay === true, - hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - if (statusDisposition === 'restart') { - // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. - // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the - // stable pane, so the rebind cannot land on a legacy key. - this.observations.rebind(paneKey) - } - const worktreeId = - envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 - ? envelope.worktreeId.trim() - : undefined - const promptInteractionKey = - typeof envelope.promptInteractionKey === 'string' && - envelope.promptInteractionKey.trim().length > 0 - ? envelope.promptInteractionKey.trim() - : undefined - const toolUseId = - typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 - ? envelope.toolUseId.trim() - : undefined - const toolAgentId = - typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 - ? envelope.toolAgentId.trim() - : undefined - const teammateName = - typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 - ? envelope.teammateName.trim() - : undefined - const toolAgentType = - typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 - ? envelope.toolAgentType.trim() - : undefined - const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined - // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). - const validatedPayload = normalizeAgentStatusPayload(envelope.payload) - if (!validatedPayload) { - return - } - // Why: restore a shed roster only when its digest and turn identity still match the cache. - let normalizedPayload = restoreShedStatusFields( - validatedPayload, - envelope.shedFields, - this.state.lastStatusByPaneKey.get(paneKey)?.payload - ) - const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) - let acceptedCompactCompletion = false - if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { - // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); - // reject it here too so a host on any version cannot drive pane state from it. - if (hookEventName === 'PreCompact' || source !== 'claude') { - return - } - // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact - // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is - // fixed and known — manual produced `done`, auto produced `working` — so the payload state - // stands in for the missing trigger. Trigger substitution only; ownership is still checked. - const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) - // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older - // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so - // without this drop, every auto compact on such a host mints exactly the stuck `working` this - // change removes. - if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { - return - } - if ( - isClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) || - !canAcceptClaudeCompactCompletion(previousStatus, { - source, - connectionId: trimmedConnectionId, - providerPromptId, - providerSession - }) - ) { - return - } - markClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) - // Why: an older relay built this payload before the boundary flag existed, so it arrives as a - // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the - // boundary here so a compact stays silent regardless of which relay normalized it. - if (normalizedPayload.sessionBoundary !== true) { - normalizedPayload = { ...normalizedPayload, sessionBoundary: true } - } - acceptedCompactCompletion = true - } - // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the - // trigger-stripped replay is exactly the shape that arrives without one, and it is still the - // compact's own promptless event, so it still needs the summarized turn's label. - if ( - source === 'claude' && - (compactTrigger !== undefined || acceptedCompactCompletion) && - normalizedPayload.prompt.length === 0 && - previousStatus?.payload.prompt - ) { - normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } - } - if ( - envelope.providerSessionOnly === true && - !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) - ) { - return - } - const applyClaudeBackgroundWork = - normalizedPayload.agentType === 'claude' && - typeof envelope.claudeRunningNonAgentTask === 'boolean' && - // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. - (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) - // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. - warnOnHookEnvOrVersionMismatch(this.state, { - version: envelope.version, - env: envelope.env, - expectedEnv: this.env - }) - const event: AgentHookEventPayload = { - paneKey, - source, - launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, - tabId, - worktreeId, - connectionId: trimmedConnectionId, - hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, - promptInteractionKey, - hookEventName, - providerPromptId, - compactTrigger, - toolUseId, - toolAgentId, - teammateName, - toolAgentType, - providerSession, - providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, - isReplay: envelope.isReplay === true ? true : undefined, - claudeRunningNonAgentTask: - typeof envelope.claudeRunningNonAgentTask === 'boolean' - ? envelope.claudeRunningNonAgentTask - : undefined, - payload: normalizedPayload - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus( - event, - applyClaudeBackgroundWork - ? () => { - if (envelope.claudeRunningNonAgentTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - } - : undefined - ) - } - - async start(options?: { - env?: string - userDataPath?: string - endpointNamespace?: string - }): Promise { - if (this.server) { - return - } - - if (options?.env) { - this.env = options.env - } - if (options?.userDataPath) { - // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. - this.endpointDir = options.endpointNamespace - ? join(options.userDataPath, 'agent-hooks', options.endpointNamespace) - : join(options.userDataPath, 'agent-hooks') - this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) - this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) - } - this.token = randomUUID() - this.endpointFileWritten = false - this.lastWrittenJson = null - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) - }) - } - const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { - if (req.method !== 'POST') { - res.writeHead(404) - res.end() - return - } - - if (req.headers['x-orca-agent-hook-token'] !== this.token) { - res.writeHead(403) - res.end() - return - } - - // Why: bound request time so a stalled client can't hold a socket open (slowloris). - // Why: track our own destroy so the slowloris cap can't be misread as outside interference. - let destroyedBySlowlorisCap = false - req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { - destroyedBySlowlorisCap = true - req.destroy() - }) - - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - try { - const body = await readRequestBody(req) - if (pathname === CLAUDE_STATUSLINE_PATHNAME) { - const statusLineEvent = parseClaudeStatusLineBody(body) - if (statusLineEvent) { - this.onClaudeStatusLine?.(statusLineEvent) - } - res.writeHead(204) - res.end() - return - } - const source = resolveHookSource(pathname) - if (!source) { - res.writeHead(404) - res.end() - return - } - - const hookBody = mergeAgentHookRequestHeaders(body, req.headers) - trackEmptyPaneKeyHook(hookBody) - const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) - const normalized = this.normalizeLocalHookPayload(source, aliasedBody) - const statusDisposition = normalized.event - ? this.getAgentStatusDisposition(normalized.event.paneKey, { - source, - hookEventName: normalized.event.hookEventName, - isReplay: normalized.event.isReplay, - hasExplicitPrompt: normalized.event.hasExplicitPrompt, - launchToken: normalized.event.launchToken - }) - : 'suppress' - if (normalized.event && statusDisposition !== 'suppress') { - const event = - statusDisposition === 'restart' - ? { ...normalized.event, launchToken: undefined } - : normalized.event - if (statusDisposition === 'restart') { - // Why: a retired pane accepting a new turn is a different agent session behind the - // same key — later observations must not be ordered against the retired one. - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) - this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) - } - - res.writeHead(204) - res.end() - } catch (error) { - // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut - // by something on the loopback path, not by a bad payload. Fail open as before, but count it — - // this is the one failure mode that silently stops status for every runtime at once. - if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { - this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) - } - // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. - res.writeHead(204) - res.end() - } - } - // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. - this.server = createServer((req, res) => { - void handleRequest(req, res) - }) - - await new Promise((resolve, reject) => { - // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. - const onStartupError = (err: Error): void => { - this.server?.off('listening', onListening) - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - console.error('[agent-hooks] server error', err) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port - } - this.maybeWriteEndpointFile() - resolve() - } - this.server!.once('error', onStartupError) - this.server!.listen(0, '127.0.0.1', onListening) - }) - } - - stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() - this.server?.close() - this.server = null - this.port = 0 - this.token = '' - this.env = 'production' - this.onAgentStatus = null - this.onPaneStatusCleared = null - for (const timer of this.assistantMessageRetryTimers.values()) { - clearTimeout(timer) - } - this.assistantMessageRetryTimers.clear() - this.codexSubagentPollScheduler.clearAll() - // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. - this.endpointDir = null - this.endpointFilePathCache = null - this.endpointFileWritten = false - this.lastStatusFilePath = null - this.lastWrittenJson = null - this.runtimeObservedStatusPaneKeys.clear() - this.hydratedAuthorityCommitments = Object.freeze([]) - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - this.revokedHydratedAuthorityCommitments = new WeakSet() - this.currentAuthorityObservations.clear() - this.promptSentDedupeByPaneKey.clear() - this.closedAgentStatusTabIds.clear() - this.closedAgentStatusPaneKeys.clear() - this.restartedStatusLaunchTokenHashByPaneKey.clear() - this.retiredPaneFencesByKey.clear() - this.connectionTimestampWatermarkById.clear() - this.legacyPaneKeyAliases.clear() - clearAllListenerCaches(this.state) - this.notifyStatusChangeListeners() - } - - /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state - * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live - * claims. Returns null when the row has no resumable session to keep. */ - private toRetainedProviderSessionRow( - entry: EnrichedAgentHookEventPayload | null | undefined - ): EnrichedAgentHookEventPayload | null { - if ( - !entry?.providerSession || - !entry.payload.agentType || - entry.payload.agentType === 'unknown' - ) { - return null - } - const { launchToken: _launchToken, ...resumeIdentity } = entry - return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } - } - - /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ - dropStatusEntry(paneKey: string): void { - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (!deleted) { - return - } - const retained = this.toRetainedProviderSessionRow(deleted) - if (retained) { - this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) - } - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitStatusDropped(deleted.paneKey) - } - - /** Retire panes whose owning process is certifiably dead. - * - * The ordinary teardown already does this: every attributable PTY exit reaches - * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that - * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may - * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left - * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, - * so a dead pane is cleaned up identically however its keys were resolved. */ - reconcileEndedProcessForPaneKeys( - paneKeys: Iterable, - options?: { - /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still - * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` - * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to - * resume into, and dropping it matches what `clearProviderPtyState` already does. */ - preserveResumeIdentity?: boolean - } - ): number { - let cleared = 0 - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { - continue - } - const retained = options?.preserveResumeIdentity - ? this.toRetainedProviderSessionRow( - this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - ) - : null - this.clearPaneState(resolvedPaneKey) - if (retained) { - this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - cleared += 1 - } - return cleared - } - - /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through - * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list - * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a - * different file. */ - private hasLiveClaimsForPaneKey(paneKey: string): boolean { - return paneHasStateClaims(this.state, paneKey) - } - - /** Clear statuses proven to belong to one lost SSH transport. */ - clearStatusEntriesForConnection(connectionId: string): void { - const normalizedConnectionId = connectionId.trim() - if (normalizedConnectionId.length === 0) { - return - } - const clearedAt = Math.max( - Date.now(), - (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 - ) - this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) - let statusChanged = false - for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { - const entry = rawEntry as EnrichedAgentHookEventPayload - // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. - if (entry.connectionId !== normalizedConnectionId) { - continue - } - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (deleted) { - statusChanged = true - if (deleted.payload.agentType === 'codex') { - // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. - this.state.codexSubagentRosterByPaneKey.delete(paneKey) - this.state.codexLeadStateByPaneKey.delete(paneKey) - } else if (deleted.payload.agentType === 'claude') { - this.state.claudeSubagentRosterByPaneKey.delete(paneKey) - this.state.claudeLeadStateByPaneKey.delete(paneKey) - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - this.state.claudeSessionOwnerByPaneKey.delete(paneKey) - } - } - } - for (const [paneKey, evidence] of this.currentAuthorityObservations) { - if (evidence.connectionId === normalizedConnectionId) { - this.currentAuthorityObservations.delete(paneKey) - } - } - if (statusChanged) { - // Why: persist/notify once — one disconnect can own many panes. - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. - this.emitPaneStatusCleared({ - transient: true, - connectionId: normalizedConnectionId, - clearedAt - }) - } - - private deleteStatusEntry( - paneKey: string, - options?: { preserveAuthority?: boolean } - ): EnrichedAgentHookEventPayload | null { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return null - } - this.state.lastStatusByPaneKey.delete(resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - if (!options?.preserveAuthority) { - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - } - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - if (existing.payload.state === 'done') { - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - } - return existing - } - - dropStatusEntriesByTabPrefix(tabId: string): void { - this.markTabClosedForAgentStatus(tabId) - const paneKeysToClear = new Set() - for (const key of this.state.lastStatusByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key) - } - } - for (const key of this.state.lastPromptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.lastToolByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.ampCompletedCacheKeys) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const paneKey of this.runtimeObservedStatusPaneKeys) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const commitment of this.hydratedAuthorityCommitments) { - if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { - paneKeysToClear.add(commitment.paneKey) - } - } - - let aliasChanged = false - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - const ownerMatches = paneCacheKeyMatchesTab(entry.stablePaneKey, tabId) - if (ownerMatches) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeysToClear.add(legacyPaneKey) - paneKeysToClear.add(entry.stablePaneKey) - this.markPaneClosedForAgentStatus(legacyPaneKey) - this.markPaneClosedForAgentStatus(entry.stablePaneKey) - aliasChanged = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) - - let statusChanged = false - for (const paneKey of paneKeysToClear) { - if (this.state.lastStatusByPaneKey.has(paneKey)) { - statusChanged = true - } - this.clearAssistantMessageRetry(paneKey) - this.clearCodexSubagentPoll(paneKey) - clearPaneCacheState(this.state, paneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) - this.runtimeObservedStatusPaneKeys.delete(paneKey) - this.currentAuthorityObservations.delete(paneKey) - this.promptSentDedupeByPaneKey.delete(paneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - clearPaneState(paneKey: string): void { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, resolvedPaneKey]) - // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. - const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - clearPaneCacheState(this.state, resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - let clearedAlias = false - for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { - if (stablePaneKey.stablePaneKey === resolvedPaneKey) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeys.add(legacyPaneKey) - paneKeys.add(stablePaneKey.stablePaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) - clearedAlias = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - if (clearedAlias) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) - } - } - - /** Second reap path for restored Claude subagent rows: drop the ones whose pane - * has no live local agent process behind it any more. A PTY that dies while Orca - * is down never runs the teardown that clears pane state, so hydrate rebuilds a - * roster nothing can ever retire — the inventory reap needs the parent to emit a - * complete `background_tasks` list and an idle parent never does. The row then - * gates the pane 'working' for the rest of its life and hibernation, which - * requires 'done', can never reclaim the agent's heap. - * - * Both the execution host and relay binding must prove local ownership before - * targeted PTY liveness is consulted. Panes that reported in this runtime are - * also skipped. Returns the number of panes changed. */ - async reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalExecutionHost: (worktreeId: string | undefined) => boolean, - isLocalPaneAgentLive: (paneKey: string) => Promise, - isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean - ): Promise { - const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if ( - enriched.payload.agentType === 'claude' && - enriched.connectionId === null && - isLocalExecutionHost(enriched.worktreeId) && - // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, - // or a background-task/cron latch nothing will refresh, strands the pane just as - // permanently — and unlike the roster case there is no child event left to reap it. - (claudeRosterHasRestoredSnapshotSubagent( - this.state.claudeSubagentRosterByPaneKey.get(paneKey) - ) || - enriched.payload.state !== 'done' || - this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && - !this.runtimeObservedStatusPaneKeys.has(paneKey) - ) { - candidates.push({ paneKey, entry: enriched }) - } - } - const liveness = await Promise.all( - candidates.map(async (candidate) => { - try { - return await isLocalPaneAgentLive(candidate.paneKey) - } catch { - return true - } - }) - ) - let changedPanes = 0 - for (const [index, candidate] of candidates.entries()) { - const { paneKey, entry: enriched } = candidate - if ( - liveness[index] || - !isLocalPaneLivenessEvidenceCurrent(paneKey) || - this.state.lastStatusByPaneKey.get(paneKey) !== enriched || - this.runtimeObservedStatusPaneKeys.has(paneKey) || - !isLocalExecutionHost(enriched.worktreeId) - ) { - continue - } - if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { - // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably - // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the - // pane the same way an observed exit would — otherwise the widened candidate set is inert. - // - // Why delete rather than downgrade to `done` like the reap branch below: that branch has a - // real turn to describe — a parent whose children it just reaped — while these panes' only - // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would - // invent a completion that never happened, and leaving it non-terminal keeps the bug. This - // sweep stands in for the exit Orca never observed, so it does what that exit does: - // `clearProviderPtyState` -> `clearPaneState`. - if (this.hasLiveClaimsForPaneKey(paneKey)) { - this.clearPaneState(paneKey) - changedPanes += 1 - } - continue - } - changedPanes += 1 - const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) - const subagents = claudeRosterToSnapshots(roster) - // Why: the pane's persisted 'working' was the child gate holding a finished - // lead open (subagent events never set lead state). With the last working row - // gone and no process left to report, 'done' is the only truthful state — and - // the one hibernation needs once this pane's agent is restored. - const state = - enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) - ? 'done' - : enriched.payload.state - const stateChanged = state !== enriched.payload.state - const reconciledAt = stateChanged - ? Math.max(Date.now(), enriched.receivedAt + 1) - : enriched.receivedAt - // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying - // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. - const { restoredUnconfirmed, ...reconciledBase } = enriched - const reconciled: EnrichedAgentHookEventPayload = { - ...reconciledBase, - ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - receivedAt: reconciledAt, - stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, - payload: { - ...enriched.payload, - state, - workingMode: state === 'working' ? enriched.payload.workingMode : undefined, - subagents - } - } - this.state.lastStatusByPaneKey.set(paneKey, reconciled) - } - if (changedPanes > 0) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - return changedPanes - } - - buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { - return {} - } - - const env: Record = { - ORCA_AGENT_HOOK_PORT: String(this.port), - ORCA_AGENT_HOOK_TOKEN: this.token, - ORCA_AGENT_HOOK_ENV: this.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, - ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT - } - // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. - if (this.endpointFileWritten && this.endpointFilePathCache) { - env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache - } - return env - } - - get endpointFilePath(): string | null { - return this.endpointFilePathCache - } - - /** Test/diagnostic accessor for the on-disk last-status file path. */ - get lastStatusPath(): string | null { - return this.lastStatusFilePath - } - - private maybeWriteEndpointFile(): void { - if (!this.endpointDir || !this.endpointFilePathCache) { - return - } - this.endpointFileWritten = false - const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { - port: this.port, - token: this.token, - env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION, - transport: ORCA_HOOK_RAW_JSON_TRANSPORT - }) - this.endpointFileWritten = ok - } - - private hydrateLastStatusFromDisk(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. - this.state.lastStatusByPaneKey.clear() - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - let raw: string - try { - raw = readFileSync(this.lastStatusFilePath, 'utf8') - } catch (err) { - // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[agent-hooks] failed to read last-status file:', err) - } - return - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') - return - } - if (typeof parsed !== 'object' || parsed === null) { - console.warn('[agent-hooks] last-status file is not an object; ignoring') - return - } - const file = parsed as Partial - if (file.version !== LAST_STATUS_FILE_VERSION) { - console.warn( - `[agent-hooks] last-status file version mismatch (${String( - file.version - )} != ${LAST_STATUS_FILE_VERSION}); ignoring` - ) - return - } - const entries = file.entries - if (typeof entries !== 'object' || entries === null) { - console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') - return - } - let hydrated = 0 - let dropped = 0 - let prunedLegacyClaudeSubagents = 0 - let scrubbedLegacyLaunchTokens = 0 - // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). - const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS - for (const [paneKey, rawEntry] of Object.entries(entries)) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const rawResolvedEntry = - resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null - ? rawEntry - : { ...(rawEntry as Record), paneKey: resolvedPaneKey } - const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) - if (entry && entry.receivedAt >= ttlCutoff) { - const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) - if (launchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) - const evidence = this.toAuthorityEvidence(entry, launchTokenHash) - if (evidence) { - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) - } - } - if ( - typeof rawResolvedEntry === 'object' && - rawResolvedEntry !== null && - typeof (rawResolvedEntry as Record).launchToken === 'string' - ) { - scrubbedLegacyLaunchTokens += 1 - } - const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) - if (hydratedPayload !== entry.payload) { - prunedLegacyClaudeSubagents += - (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) - entry.payload = hydratedPayload - } - if (entry.payload.state !== 'done') { - // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. - entry.restoredUnconfirmed = true - } - this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) - if (entry.connectionId) { - // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. - const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) - this.connectionTimestampWatermarkById.set( - entry.connectionId, - Math.max(previousWatermark ?? -1, entry.receivedAt) - ) - } - // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. - if (entry.payload.agentType === 'codex') { - seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) - } else if (entry.payload.agentType === 'claude') { - seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true - }) - if (entry.payload.subagents) { - seedClaudeSubagentRosterFromSnapshots( - this.state, - resolvedPaneKey, - entry.payload.subagents - ) - } - } - hydrated += 1 - } else { - dropped += 1 - } - } - for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) - if (!commitment || commitment.observedAt < ttlCutoff) { - dropped += 1 - continue - } - const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - dropped += 1 - continue - } - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) - } - if (dropped > 0) { - console.warn( - `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` - ) - } - if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { - // Why: persist load-time pruning and bearer scrubbing once. - this.runStatusPersist() - } else if (hydrated > 0) { - // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. - this.lastWrittenJson = raw - } - } - - private captureHydratedAuthorityCommitments(): void { - this.revokedHydratedAuthorityCommitments = new WeakSet() - for (const entry of this.state.lastStatusByPaneKey.values()) { - const evidence = this.toAuthorityEvidence( - entry as EnrichedAgentHookEventPayload, - this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) - ) - if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { - this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) - } - } - this.hydratedAuthorityCommitments = Object.freeze( - Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) - ) - } - - private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { - const evidence = this.toAuthorityEvidence(payload) - if (evidence) { - this.currentAuthorityObservations.set(evidence.paneKey, evidence) - this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) - this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) - } - } - - private toAuthorityEvidence( - payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, - launchTokenHashOverride?: string - ): AgentHookAuthorityEvidence | null { - const launchToken = payload.launchToken?.trim() - const launchTokenHash = - launchTokenHashOverride ?? - (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) - if (!launchTokenHash) { - return null - } - return Object.freeze({ - paneKey: payload.paneKey, - launchTokenHash, - connectionId: payload.connectionId, - ...(payload.tabId ? { tabId: payload.tabId } : {}), - ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), - observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() - }) - } - - private serializeStatusFile(): string { - const entries: Record = {} - const authorityCommitments: Record = {} - const conflictedCommitments = new Set() - for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { - authorityCommitments[paneKey] = { ...commitment } - } - for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { - // Why: never persist invalid keys (matches the hydrate-path invariant). - if (!isValidPaneKey(paneKey)) { - continue - } - const enrichedPayload = payload as EnrichedAgentHookEventPayload - const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true - const { - claudeRunningNonAgentTask: _claudeRunningNonAgentTask, - promptInteractionKey: _promptInteractionKey, - // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. - restoredUnconfirmed: _restoredUnconfirmed, - // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). - observation: _observation, - // Replay provenance is runtime-only and must not survive another restart. - isReplay: _isReplay, - launchToken, - ...persistedPayload - } = enrichedPayload - const launchTokenHash = launchToken?.trim() - ? createHash('sha256').update(launchToken.trim()).digest('hex') - : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - entries[paneKey] = { - ...persistedPayload, - ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), - ...(launchTokenHash ? { launchTokenHash } : {}) - } - const commitment = this.toAuthorityEvidence(payload, launchTokenHash) - if (commitment && !conflictedCommitments.has(paneKey)) { - const existing = authorityCommitments[paneKey] - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - delete authorityCommitments[paneKey] - conflictedCommitments.add(paneKey) - } else { - authorityCommitments[paneKey] = { ...commitment } - } - } - } - const file: LastStatusFile = { - version: LAST_STATUS_FILE_VERSION, - entries, - authorityCommitments - } - return JSON.stringify(file) - } - - private scheduleStatusPersist(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: reset the timer each call so the write fires only after the last event in a burst. - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - } - this.statusPersistTimer = setTimeout(() => { - this.statusPersistTimer = null - this.runStatusPersist() - }, STATUS_PERSIST_DEBOUNCE_MS) - // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. - if (typeof this.statusPersistTimer.unref === 'function') { - this.statusPersistTimer.unref() - } - } - - flushStatusPersistSync(): void { - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - this.statusPersistTimer = null - } - if (!this.lastStatusFilePath) { - return - } - this.runStatusPersist() - } - - private runStatusPersist(): void { - if (!this.lastStatusFilePath || !this.endpointDir) { - return - } - const json = this.serializeStatusFile() - if (json === this.lastWrittenJson) { - return - } - const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) - let tmpWritten = false - try { - mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - try { - chmodSync(this.endpointDir, 0o700) - } catch { - // best-effort - } - } - writeFileSync(tmpPath, json, { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, this.lastStatusFilePath) - this.lastWrittenJson = json - } catch (err) { - console.warn('[agent-hooks] failed to write last-status file:', err) - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // tmp already gone - } - } - } - } - - /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ - _getStateForTests(): HookListenerState { - return this.state - } - - _resetPromptSentDedupeForTests(): void { - this.promptSentDedupeByPaneKey.clear() - } - - _resetConnectionTimestampWatermarksForTests(): void { - this.connectionTimestampWatermarkById.clear() - } -} +export { + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + PANE_KEY_ALIASES_MAX, + RETIRED_PANE_FENCES_MAX +} from './server/server-constants' +export { isValidPaneKey } + +/** Public composition seam for the loopback hook listener and relay status adapter. */ +export class AgentHookServer extends AgentHookServerLifecycle {} export const agentHookServer = new AgentHookServer() @@ -3540,3 +44,5 @@ export const _internals = { agentHookServer._resetConnectionTimestampWatermarksForTests() } } + +export type { HookListenerState } from '../../shared/agent-hook-listener/listener-state' diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts new file mode 100644 index 00000000000..18756cb459c --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -0,0 +1,222 @@ +import { movePaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../../shared/pane-key-alias' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { PANE_KEY_ALIASES_MAX } from './server-constants' +import type { EnrichedAgentHookEventPayload, PaneKeyAliasPersistenceListener } from './server-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import { isValidPaneKey } from './server-status-identity' +import { AgentHookServerAuthorityEvidence } from './server-authority-evidence' + +export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAuthorityEvidence { + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { + this.paneKeyAliasPersistenceListener = listener + } + + protected getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { + return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => + entry.ptyId + ? [ + { + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.stablePaneKey, + updatedAt: entry.updatedAt + } + ] + : [] + ) + } + + protected notifyPaneKeyAliasPersistenceListener(): void { + this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) + } + + protected boundPaneKeyAliases(): void { + while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { + // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. + const oldestKey = this.legacyPaneKeyAliases.keys().next().value + if (!oldestKey) { + break + } + this.legacyPaneKeyAliases.delete(oldestKey) + } + } + + protected getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + let fallbackPaneKey = paneKey + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if ( + entry.stablePaneKey === ownerPaneKey && + (!ptyId || !entry.ptyId || entry.ptyId === ptyId) + ) { + if (entry.authorityVerified) { + return physicalPaneKey + } + fallbackPaneKey = physicalPaneKey + } + } + return fallbackPaneKey + } + + canTransferPaneAuthority( + fromPaneKey: string, + ptyId: string | undefined, + ownsPty: (physicalPaneKey: string, ptyId: string) => boolean + ): boolean { + if (!isValidPaneKey(fromPaneKey)) { + return false + } + const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) + if (ptyId) { + return Boolean( + (alias?.authorityVerified && alias.ptyId === ptyId) || + ownsPty(physicalPaneKey, ptyId) || + (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) + ) + } + // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. + return alias?.authorityVerified === true + } + + registerPaneKeyAlias( + legacyPaneKey: string, + stablePaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { overwriteExisting?: boolean; authorityVerified?: boolean } + ): void { + const fromPaneKey = legacyPaneKey.trim() + const toPaneKey = stablePaneKey.trim() + if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { + return + } + const existing = this.legacyPaneKeyAliases.get(fromPaneKey) + if (existing && options?.overwriteExisting === false) { + return + } + // Why: remint tokens have no embedded tab id; first pane wins so a later spawn + // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. + if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { + return + } + const normalizedPtyId = + typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId + const normalizedUpdatedAt = + Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) + const authorityVerified = options?.authorityVerified ?? false + if ( + existing && + existing.stablePaneKey === toPaneKey && + existing.ptyId === (normalizedPtyId ?? null) && + existing.updatedAt === normalizedUpdatedAt && + existing.authorityVerified === authorityVerified + ) { + return + } + this.legacyPaneKeyAliases.set(fromPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId ?? null, + updatedAt: normalizedUpdatedAt, + authorityVerified + }) + this.boundPaneKeyAliases() + if (normalizedPtyId) { + this.notifyPaneKeyAliasPersistenceListener() + } + } + + transferPaneAuthority( + fromPaneKey: string, + toPaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { authorityVerified?: boolean } + ): void { + if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { + return + } + const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) + const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null + const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) + const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (movedStatus) { + const owner = parsePaneKey(toPaneKey) + this.state.lastStatusByPaneKey.set(toPaneKey, { + ...movedStatus, + paneKey: toPaneKey, + tabId: owner?.tabId + }) + } + const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + if (hydratedLaunchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) + } + const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) + if (persistedAuthority) { + const owner = parsePaneKey(toPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.set( + toPaneKey, + Object.freeze({ + ...persistedAuthority, + paneKey: toPaneKey, + ...(owner?.tabId ? { tabId: owner.tabId } : {}) + }) + ) + } + if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { + this.runtimeObservedStatusPaneKeys.add(toPaneKey) + } + const restartedTokenHash = + this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) + if (restartedTokenHash) { + this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) + } + const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) + if (activeTurnCompletedAt !== undefined) { + this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) + this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) + } + const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) + if (authorityObservation) { + const owner = parsePaneKey(toPaneKey) + this.currentAuthorityObservations.delete(previousOwnerPaneKey) + this.currentAuthorityObservations.set( + toPaneKey, + Object.freeze({ ...authorityObservation, paneKey: toPaneKey, tabId: owner?.tabId }) + ) + } + const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) + if (promptDedupe !== undefined) { + this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) + this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) + } + this.clearAssistantMessageRetry(previousOwnerPaneKey) + this.clearCodexSubagentPoll(previousOwnerPaneKey) + // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. + this.legacyPaneKeyAliases.set(physicalPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId, + updatedAt, + authorityVerified: options?.authorityVerified ?? true + }) + this.boundPaneKeyAliases() + this.closedAgentStatusPaneKeys.delete(toPaneKey) + this.notifyPaneKeyAliasPersistenceListener() + if (hadStatus || persistedAuthority) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } +} diff --git a/src/main/agent-hooks/server/server-authority-evidence.ts b/src/main/agent-hooks/server/server-authority-evidence.ts new file mode 100644 index 00000000000..8cda3426629 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + EnrichedAgentHookEventPayload +} from './server-types' +import { AgentHookServerStatusRetries } from './server-status-retries' + +export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerStatusRetries { + attestCompatibilityAuthority(candidate: { + paneKey: string + launchTokenHash: string + connectionId: string | null + terminalProvenance: 'current_runtime' | 'restored' + }): AgentHookAuthorityAttestation | null { + const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) + const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => + entry.launchTokenHash === candidate.launchTokenHash && + entry.connectionId === candidate.connectionId + const commitments = this.hydratedAuthorityCommitments.filter( + (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) + ) + const current = Array.from(this.currentAuthorityObservations.values()) + const observations = current.filter(matchesCandidate) + const paneObservations = current.filter( + (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey + ) + const hasUniqueCurrentObservation = + observations.length === 1 && + paneObservations.length === 1 && + this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey + if (candidate.terminalProvenance === 'current_runtime') { + return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null + } + if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { + return null + } + if (observations.length === 0 && paneObservations.length === 0) { + return Object.freeze({ paneKey, source: 'hydrated_commitment' }) + } + if (!hasUniqueCurrentObservation) { + return null + } + return Object.freeze({ paneKey, source: 'current_hook' }) + } + + protected captureHydratedAuthorityCommitments(): void { + this.revokedHydratedAuthorityCommitments = new WeakSet() + for (const entry of this.state.lastStatusByPaneKey.values()) { + const evidence = this.toAuthorityEvidence( + entry as EnrichedAgentHookEventPayload, + this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) + ) + if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { + this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) + } + } + this.hydratedAuthorityCommitments = Object.freeze( + Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) + ) + } + + protected recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { + const evidence = this.toAuthorityEvidence(payload) + if (evidence) { + this.currentAuthorityObservations.set(evidence.paneKey, evidence) + this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) + this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) + } + } + + protected toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null { + const launchToken = payload.launchToken?.trim() + const launchTokenHash = + launchTokenHashOverride ?? + (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) + if (!launchTokenHash) { + return null + } + return Object.freeze({ + paneKey: payload.paneKey, + launchTokenHash, + connectionId: payload.connectionId, + ...(payload.tabId ? { tabId: payload.tabId } : {}), + ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), + observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() + }) + } +} diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts new file mode 100644 index 00000000000..0ad1bdeba62 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -0,0 +1,193 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { AgentHookServerAuthorityAliases } from './server-authority-aliases' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' + +export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + retirePaneAuthority(paneKey: string): void { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, ownerPaneKey]) + const retiredAliases: RetiredPaneAlias[] = [] + let aliasChanged = false + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { + this.legacyPaneKeyAliases.delete(physicalPaneKey) + retiredAliases.push({ physicalPaneKey, entry }) + paneKeys.add(physicalPaneKey) + paneKeys.add(entry.stablePaneKey) + aliasChanged = true + } + } + this.recordRetiredPaneFence(paneKeys, retiredAliases) + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + for (const key of paneKeys) { + this.markPaneClosedForAgentStatus(key) + this.restartedStatusLaunchTokenHashByPaneKey.delete(key) + this.clearAssistantMessageRetry(key) + this.clearCodexSubagentPoll(key) + clearPaneCacheState(this.state, key) + this.activeHookTurnCompletedAtByPaneKey.delete(key) + this.runtimeObservedStatusPaneKeys.delete(key) + this.currentAuthorityObservations.delete(key) + this.promptSentDedupeByPaneKey.delete(key) + this.observations.forget(key) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + // Lifting only the key we are handed strands the rest — a detached pane's process + // keeps posting the key it launched under, so it would stay suppressed forever with + // the fence apparently lifted. Replay the recorded fence instead: same key set, same + // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger + // claim survives and a live process is never routed back into a closed tab. + protected restoreRetiredPaneFence(fence: RetiredPaneFence): void { + let aliasChanged = false + for (const { physicalPaneKey, entry } of fence.aliases) { + if ( + this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || + this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || + // Why: the pane was rebound in the meantime; the newer alias is the truth. + this.legacyPaneKeyAliases.has(physicalPaneKey) + ) { + continue + } + this.legacyPaneKeyAliases.set(physicalPaneKey, entry) + aliasChanged = true + } + for (const key of fence.paneKeys) { + if (this.retiredPaneFencesByKey.get(key) === fence) { + this.retiredPaneFencesByKey.delete(key) + } + } + if (aliasChanged) { + this.boundPaneKeyAliases() + this.notifyPaneKeyAliasPersistenceListener() + } + } + + restorePaneAuthority(paneKey: string): boolean { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { + return false + } + // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that + // exact pane disproves the claim at the moment it stops being true, so the fence + // lifts here instead of waiting for the agent to speak again — an agent re-attached + // mid-turn or left idle would otherwise stay suppressed for the rest of its life + // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. + const fence = + this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) + let restored = false + for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { + if (this.isClosedAgentStatusTabForPaneKey(key)) { + continue + } + if (this.closedAgentStatusPaneKeys.delete(key)) { + restored = true + } + } + if (fence) { + this.restoreRetiredPaneFence(fence) + } + return restored + } + + clearPaneKeyAliasesForPty( + ptyId: string, + options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } + ): void { + let aliasChanged = false + let statusChanged = false + const clearedStatusPaneKeys = new Set() + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (entry.ptyId !== ptyId) { + continue + } + const shouldClearStablePaneKey = + options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true + const revokedPaneKeys = new Set([legacyPaneKey]) + if (shouldClearStablePaneKey) { + revokedPaneKeys.add(entry.stablePaneKey) + } + if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { + statusChanged = true + } + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { + statusChanged = true + clearedStatusPaneKeys.add(entry.stablePaneKey) + } + if (shouldClearStablePaneKey) { + // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. + clearPaneCacheState(this.state, entry.stablePaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) + this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) + this.currentAuthorityObservations.delete(entry.stablePaneKey) + this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) + } + aliasChanged = true + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + for (const paneKey of clearedStatusPaneKeys) { + this.emitPaneStatusCleared({ paneKey }) + } + } + } + + protected resolvePaneKeyAlias(paneKey: string): string { + return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey + } + + protected revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { + let changed = false + for (const commitment of this.hydratedAuthorityCommitments) { + if ( + paneKeys.has(commitment.paneKey) || + paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) + ) { + this.revokedHydratedAuthorityCommitments.add(commitment) + changed = true + } + } + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed + changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed + } + return changed + } + + protected normalizeHookBodyPaneKeyAlias(body: unknown): unknown { + if (typeof body !== 'object' || body === null) { + return body + } + const record = body as Record + const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey + if (!stablePaneKey) { + return body + } + // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. + return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } + } +} diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts new file mode 100644 index 00000000000..9607e4a9dc8 --- /dev/null +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -0,0 +1,191 @@ +import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster' +import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export function attachClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { + const establishesBoundary = + next.payload.agentType === 'claude' && + (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && + !next.toolAgentId && + next.payload.state === 'working' && + next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && + next.claudeRunningNonAgentTask === false + const carriesBoundary = + previous?.claudeLeadBoundaryChildOnly === true && + next.payload.agentType === 'claude' && + next.claudeRunningNonAgentTask === false && + (next.toolAgentId !== undefined || + next.hookEventName === 'SubagentStart' || + next.hookEventName === 'SubagentStop' || + next.hookEventName === 'TeammateIdle') + return establishesBoundary || carriesBoundary + ? { ...next, claudeLeadBoundaryChildOnly: true } + : next +} + +export function invalidateClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): EnrichedAgentHookEventPayload | undefined { + if ( + previous?.claudeLeadBoundaryChildOnly !== true || + attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true + ) { + return previous + } + const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous + return withoutBoundary +} + +export function shouldKeepClaudePermissionVisible( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if (previous?.restoredUnconfirmed) { + return false + } + if ( + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'waiting' || + previous.hookEventName !== 'PermissionRequest' || + next.payload.agentType !== 'claude' || + next.payload.state !== 'working' + ) { + return false + } + if (next.hasExplicitPrompt === true) { + return false + } + if (isClaudePermissionOwningChildEnding(previous, next)) { + return false + } + if (isClaudePermissionResumingApprovedTool(previous, next)) { + return false + } + // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. + if (isAskUserQuestionTool(previous.payload.toolName)) { + return false + } + return true +} + +function isClaudePermissionOwningChildEnding( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const ownerId = previous.toolAgentId?.trim() + if (!ownerId) { + return false + } + if (next.hookEventName === 'SubagentStop') { + return ownerId === next.toolAgentId?.trim() + } + return ( + next.hookEventName === 'TeammateIdle' && + next.teammateName !== undefined && + claudeTeammateIdMatchesName(ownerId, next.teammateName) + ) +} + +function isClaudePermissionResumingApprovedTool( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const previousToolUseId = previous.toolUseId?.trim() || undefined + const nextToolUseId = next.toolUseId?.trim() || undefined + const previousAgentId = previous.toolAgentId?.trim() || undefined + const nextAgentId = next.toolAgentId?.trim() || undefined + const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined + const previousAgentType = previous.toolAgentType?.trim() || undefined + const nextAgentType = next.toolAgentType?.trim() || undefined + const hasMatchingConcreteAgentId = + previousAgentId !== undefined && previousAgentId === nextAgentId + const hasSameExplicitAgentType = + !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType + const sameToolName = + previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownInputFromConcreteAgent = + hasMatchingConcreteAgentId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + const hasMatchingToolUseId = + previousToolUseId !== undefined && previousToolUseId === nextToolUseId + const hasConflictingToolUseId = + previousToolUseId !== undefined && + nextToolUseId !== undefined && + previousToolUseId !== nextToolUseId + const sameUnknownInputFromToolUseId = + hasMatchingToolUseId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + + return ( + (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && + nextToolUseId !== undefined && + !hasConflictingToolUseId && + // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. + (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && + sameToolName && + (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) + ) +} + +export function shouldInheritClaudeToolUseIdForPermission( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if ( + previous?.restoredUnconfirmed || + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'working' || + previous.hookEventName !== 'PreToolUse' || + typeof previous.toolUseId !== 'string' || + previous.toolUseId.trim().length === 0 || + next.payload.agentType !== 'claude' || + next.payload.state !== 'waiting' || + next.hookEventName !== 'PermissionRequest' || + next.toolUseId !== undefined + ) { + return false + } + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownToolInput = + previous.payload.toolInput === undefined && next.payload.toolInput === undefined + if ( + previous.toolAgentId !== next.toolAgentId || + previous.toolAgentType !== next.toolAgentType || + previous.payload.toolName === undefined || + previous.payload.toolName !== next.payload.toolName || + (!sameKnownToolInput && !sameUnknownToolInput) + ) { + return false + } + return true +} + +export function attachClaudePermissionToolUseId( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload { + const inheritedToolUseId = previous?.toolUseId + if ( + !shouldInheritClaudeToolUseIdForPermission(previous, next) || + typeof inheritedToolUseId !== 'string' + ) { + return next + } + return { + ...next, + // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. + toolUseId: inheritedToolUseId + } +} diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts new file mode 100644 index 00000000000..04acc058dea --- /dev/null +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -0,0 +1,167 @@ +import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerAuthorityFences } from './server-authority-fences' + +export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFences { + /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state + * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live + * claims. Returns null when the row has no resumable session to keep. */ + protected toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null { + if ( + !entry?.providerSession || + !entry.payload.agentType || + entry.payload.agentType === 'unknown' + ) { + return null + } + const { launchToken: _launchToken, ...resumeIdentity } = entry + return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } + } + + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ + dropStatusEntry(paneKey: string): void { + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (!deleted) { + return + } + const retained = this.toRetainedProviderSessionRow(deleted) + if (retained) { + this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) + } + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitStatusDropped(deleted.paneKey) + } + + /** Retire panes whose owning process is certifiably dead. + * + * The ordinary teardown already does this: every attributable PTY exit reaches + * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that + * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may + * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left + * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, + * so a dead pane is cleaned up identically however its keys were resolved. */ + reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { + /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still + * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` + * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to + * resume into, and dropping it matches what `clearProviderPtyState` already does. */ + preserveResumeIdentity?: boolean + } + ): number { + // A certified PTY exit passes no resume identity; a surviving shell may opt into the remnant. + let cleared = 0 + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { + continue + } + const retained = options?.preserveResumeIdentity + ? this.toRetainedProviderSessionRow( + this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + ) + : null + this.clearPaneState(resolvedPaneKey) + if (retained) { + this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + cleared += 1 + } + return cleared + } + + /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through + * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list + * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a + * different file. */ + protected hasLiveClaimsForPaneKey(paneKey: string): boolean { + return paneHasStateClaims(this.state, paneKey) + } + + /** Clear statuses proven to belong to one lost SSH transport. */ + clearStatusEntriesForConnection(connectionId: string): void { + const normalizedConnectionId = connectionId.trim() + if (normalizedConnectionId.length === 0) { + return + } + const clearedAt = Math.max( + Date.now(), + (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 + ) + this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) + let statusChanged = false + for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { + const entry = rawEntry as EnrichedAgentHookEventPayload + // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. + if (entry.connectionId !== normalizedConnectionId) { + continue + } + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (deleted) { + statusChanged = true + if (deleted.payload.agentType === 'codex') { + // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. + this.state.codexSubagentRosterByPaneKey.delete(paneKey) + this.state.codexLeadStateByPaneKey.delete(paneKey) + } else if (deleted.payload.agentType === 'claude') { + this.state.claudeSubagentRosterByPaneKey.delete(paneKey) + this.state.claudeLeadStateByPaneKey.delete(paneKey) + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + this.state.claudeSessionOwnerByPaneKey.delete(paneKey) + } + } + } + for (const [paneKey, evidence] of this.currentAuthorityObservations) { + if (evidence.connectionId === normalizedConnectionId) { + this.currentAuthorityObservations.delete(paneKey) + } + } + if (statusChanged) { + // Why: persist/notify once — one disconnect can own many panes. + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. + this.emitPaneStatusCleared({ + transient: true, + connectionId: normalizedConnectionId, + clearedAt + }) + } + + protected deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return null + } + this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + if (!options?.preserveAuthority) { + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + } + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + if (existing.payload.state === 'done') { + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + } + return existing + } +} diff --git a/src/main/agent-hooks/server/server-constants.ts b/src/main/agent-hooks/server/server-constants.ts new file mode 100644 index 00000000000..7fa7901ae73 --- /dev/null +++ b/src/main/agent-hooks/server/server-constants.ts @@ -0,0 +1,29 @@ +import { AGENT_KIND_VALUES, type AgentKind } from '../../../shared/telemetry-events' + +// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. +export const LAST_STATUS_FILE_NAME = 'last-status.json' +export const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 +export const ASSISTANT_MESSAGE_RETRY_MS = 50 +export const CODEX_SUBAGENT_POLL_MS = 1_000 +export const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 + +// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). +export const LAST_STATUS_FILE_VERSION = 2 + +// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. +export const STATUS_PERSIST_DEBOUNCE_MS = 250 +export const TOOL_PROGRESS_HOOK_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure' +]) +export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) + +// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. +export const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. +export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 +export const PANE_KEY_ALIASES_MAX = 1024 +export const RETIRED_PANE_FENCES_MAX = 1024 diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts new file mode 100644 index 00000000000..70da93b7c3b --- /dev/null +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -0,0 +1,162 @@ +import { readFileSync } from 'node:fs' + +import { + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' +import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants' +import type { LastStatusFile } from './server-types' +import { + authorityCommitmentsMatch, + dropHydratedIdleClaudeSubagents, + readPersistedLaunchTokenHash, + sanitizeHydratedEntry, + sanitizePersistedAuthorityCommitment +} from './server-persistence-validation' +import { AgentHookServerReaping } from './server-reaping' + +export abstract class AgentHookServerHydration extends AgentHookServerReaping { + /** Hydrate the durable cache, validating every row before it reaches the live listener state. */ + protected hydrateLastStatusFromDisk(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. + this.state.lastStatusByPaneKey.clear() + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + let raw: string + try { + raw = readFileSync(this.lastStatusFilePath, 'utf8') + } catch (err) { + // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[agent-hooks] failed to read last-status file:', err) + } + return + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') + return + } + if (typeof parsed !== 'object' || parsed === null) { + console.warn('[agent-hooks] last-status file is not an object; ignoring') + return + } + const file = parsed as Partial + if (file.version !== LAST_STATUS_FILE_VERSION) { + console.warn( + `[agent-hooks] last-status file version mismatch (${String( + file.version + )} != ${LAST_STATUS_FILE_VERSION}); ignoring` + ) + return + } + const entries = file.entries + if (typeof entries !== 'object' || entries === null) { + console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') + return + } + let hydrated = 0 + let dropped = 0 + let prunedLegacyClaudeSubagents = 0 + let scrubbedLegacyLaunchTokens = 0 + // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). + const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS + for (const [paneKey, rawEntry] of Object.entries(entries)) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const rawResolvedEntry = + resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null + ? rawEntry + : { ...(rawEntry as Record), paneKey: resolvedPaneKey } + const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) + if (entry && entry.receivedAt >= ttlCutoff) { + const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) + if (launchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) + const evidence = this.toAuthorityEvidence(entry, launchTokenHash) + if (evidence) { + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) + } + } + if ( + typeof rawResolvedEntry === 'object' && + rawResolvedEntry !== null && + typeof (rawResolvedEntry as Record).launchToken === 'string' + ) { + scrubbedLegacyLaunchTokens += 1 + } + const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) + if (hydratedPayload !== entry.payload) { + prunedLegacyClaudeSubagents += + (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) + entry.payload = hydratedPayload + } + if (entry.payload.state !== 'done') { + // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. + entry.restoredUnconfirmed = true + } + this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + if (entry.connectionId) { + // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. + const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) + this.connectionTimestampWatermarkById.set( + entry.connectionId, + Math.max(previousWatermark ?? -1, entry.receivedAt) + ) + } + // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. + if (entry.payload.agentType === 'codex') { + seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) + } else if (entry.payload.agentType === 'claude') { + seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { + childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true + }) + if (entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) + } + } + hydrated += 1 + } else { + dropped += 1 + } + } + for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) + if (!commitment || commitment.observedAt < ttlCutoff) { + dropped += 1 + continue + } + const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + dropped += 1 + continue + } + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) + } + if (dropped > 0) { + console.warn( + `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` + ) + } + if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { + // Why: persist load-time pruning and bearer scrubbing once. + this.runStatusPersist() + } else if (hydrated > 0) { + // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. + this.lastWrittenJson = raw + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts new file mode 100644 index 00000000000..0a1e8d761d6 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -0,0 +1,81 @@ +import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +import type { NormalizedLocalHook } from './server-types' +import { AgentHookServerPersistence } from './server-persistence' + +export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence { + protected setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void { + if (hasRunningTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + if (hasActiveCron) { + this.state.claudeActiveSessionCronPaneKeys.add(paneKey) + } else { + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + } + + protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { + if (source !== 'claude' || typeof body !== 'object' || body === null) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const rawPaneKey = (body as Record).paneKey + const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' + if (!paneKey) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const event = normalizeHookPayload(this.state, source, body, this.env) + const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) + if (!event || event.paneKey !== paneKey) { + return { event } + } + // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. + return { + event, + onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) + } + } + + // Spool records are durable replay evidence, not a live observation. + protected ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts new file mode 100644 index 00000000000..18b0a837c32 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -0,0 +1,282 @@ +import { track } from '../../telemetry/client' +import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { isAgentHookSource, restoreShedStatusFields } from '../../../shared/agent-hook-relay' +import { + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../../shared/agent-hook-listener/listener-limits' +import { + canAcceptClaudeCompactCompletion, + isClaudeCompactCompletionConsumed, + markClaudeCompactCompletionConsumed, + resolveLegacyCompactTrigger +} from '../../../shared/claude-compact-completion' +import { launchTokenHash } from '../../../shared/agent-hook-spool' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { isValidPiProviderSessionOnly } from './server-status-identity' +import { AgentHookServerIngestTerminal } from './server-ingest-terminal' + +export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestTerminal { + /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ + ingestRemote( + envelope: { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hasExplicitPrompt?: boolean + promptInteractionKey?: string + hookEventName?: string + source?: unknown + providerPromptId?: unknown + compactTrigger?: unknown + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: unknown + providerSessionOnly?: unknown + isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown + claudeRunningNonAgentTask?: unknown + payload: unknown + }, + connectionId: string | null + ): void { + // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. + if (connectionId !== null && typeof connectionId !== 'string') { + return + } + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { + return + } + if (!envelope || typeof envelope.paneKey !== 'string') { + return + } + // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. + const physicalPaneKey = envelope.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } + if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { + return + } + if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { + return + } + // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. + const reportedTabId = + envelope.tabId !== undefined && envelope.tabId.trim().length > 0 + ? envelope.tabId.trim() + : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const hookEventName = + typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 + ? envelope.hookEventName.trim() + : undefined + const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && + (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') + ? envelope.compactTrigger + : undefined + const statusDisposition = this.getAgentStatusDisposition(paneKey, { + source, + rawSource: envelope.source, + hookEventName, + isReplay: envelope.isReplay === true, + hasExplicitPrompt: envelope.hasExplicitPrompt === true, + launchToken: envelope.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + if (statusDisposition === 'restart') { + // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. + // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the + // stable pane, so the rebind cannot land on a legacy key. + this.observations.rebind(paneKey) + } + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + const promptInteractionKey = + typeof envelope.promptInteractionKey === 'string' && + envelope.promptInteractionKey.trim().length > 0 + ? envelope.promptInteractionKey.trim() + : undefined + const toolUseId = + typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 + ? envelope.toolUseId.trim() + : undefined + const toolAgentId = + typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 + ? envelope.toolAgentId.trim() + : undefined + const teammateName = + typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 + ? envelope.teammateName.trim() + : undefined + const toolAgentType = + typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 + ? envelope.toolAgentType.trim() + : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined + // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). + const validatedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!validatedPayload) { + return + } + // Why: restore a shed roster only when its digest and turn identity still match the cache. + let normalizedPayload = restoreShedStatusFields( + validatedPayload, + envelope.shedFields, + this.state.lastStatusByPaneKey.get(paneKey)?.payload + ) + const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) + let acceptedCompactCompletion = false + if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { + // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); + // reject it here too so a host on any version cannot drive pane state from it. + if (hookEventName === 'PreCompact' || source !== 'claude') { + return + } + // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact + // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is + // fixed and known — manual produced `done`, auto produced `working` — so the payload state + // stands in for the missing trigger. Trigger substitution only; ownership is still checked. + const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) + // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older + // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so + // without this drop, every auto compact on such a host mints exactly the stuck `working` this + // change removes. + if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { + return + } + if ( + isClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) || + !canAcceptClaudeCompactCompletion(previousStatus, { + source, + connectionId: trimmedConnectionId, + providerPromptId, + providerSession + }) + ) { + return + } + markClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) + // Why: an older relay built this payload before the boundary flag existed, so it arrives as a + // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the + // boundary here so a compact stays silent regardless of which relay normalized it. + if (normalizedPayload.sessionBoundary !== true) { + normalizedPayload = { ...normalizedPayload, sessionBoundary: true } + } + acceptedCompactCompletion = true + } + // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the + // trigger-stripped replay is exactly the shape that arrives without one, and it is still the + // compact's own promptless event, so it still needs the summarized turn's label. + if ( + source === 'claude' && + (compactTrigger !== undefined || acceptedCompactCompletion) && + normalizedPayload.prompt.length === 0 && + previousStatus?.payload.prompt + ) { + normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } + } + if ( + envelope.providerSessionOnly === true && + !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) + ) { + return + } + const applyClaudeBackgroundWork = + normalizedPayload.agentType === 'claude' && + typeof envelope.claudeRunningNonAgentTask === 'boolean' && + // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. + (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) + // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. + warnOnHookEnvOrVersionMismatch(this.state, { + version: envelope.version, + env: envelope.env, + expectedEnv: this.env + }) + const event = { + paneKey, + source, + launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, + tabId, + worktreeId, + connectionId: trimmedConnectionId, + hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, + promptInteractionKey, + hookEventName, + providerPromptId, + compactTrigger, + toolUseId, + toolAgentId, + teammateName, + toolAgentType, + providerSession, + providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + isReplay: envelope.isReplay === true ? true : undefined, + claudeRunningNonAgentTask: + typeof envelope.claudeRunningNonAgentTask === 'boolean' + ? envelope.claudeRunningNonAgentTask + : undefined, + payload: normalizedPayload + } as AgentHookEventPayload + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus( + event, + applyClaudeBackgroundWork + ? () => { + if (envelope.claudeRunningNonAgentTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + } + : undefined + ) + } +} diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts new file mode 100644 index 00000000000..822c7e76f02 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -0,0 +1,104 @@ +import { track } from '../../telemetry/client' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerIngestNormalization } from './server-ingest-normalization' + +export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { + ingestTerminalStatus(event: { + paneKey: string + tabId?: string + worktreeId?: string + connectionId?: string | null + payload: ParsedAgentStatusPayload + }): void { + const physicalPaneKey = event.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + const reportedTabId = + event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + if (this.getAgentStatusDisposition(paneKey) !== 'accept') { + return + } + const worktreeId = + event.worktreeId !== undefined && event.worktreeId.trim().length > 0 + ? event.worktreeId.trim() + : undefined + const connectionId = + typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 + ? event.connectionId.trim() + : null + const previous = this.state.lastStatusByPaneKey.get(paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + previous?.claudeLeadBoundaryChildOnly === true && + previous.payload.agentType === 'claude' && + event.payload.agentType === 'claude' + ) { + // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + return + } + // Why: preserve the hook-completed turn stamp while OSC repaints the current state. + const preserveActiveTurnStamp = + previous?.payload.turnCompletedAt !== undefined && + previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) + if ( + !previous?.restoredUnconfirmed && + previous?.connectionId === connectionId && + previous.tabId === tabId && + previous.worktreeId === worktreeId && + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) + ) { + return + } + // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is + // never evidence that the session ended — yet overwriting the row dropped the cached identity. + // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which + // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). + // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. + // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an + // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so + // it must not be read as a mismatch and strip the session the renderer would have kept. + const claimedAgentType = + event.payload.agentType && event.payload.agentType !== 'unknown' + ? event.payload.agentType + : undefined + const preservedProviderSession = + previous?.providerSession && + (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && + (previous.payload.state !== 'done' || event.payload.state === 'done') + ? previous.providerSession + : undefined + // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. + this.applyNormalizedStatus( + { + paneKey, + tabId, + worktreeId, + connectionId, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: event.payload + }, + undefined, + 'osc' + ) + } +} diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts new file mode 100644 index 00000000000..9beb0ad0bbb --- /dev/null +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -0,0 +1,197 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomUUID } from 'node:crypto' + +import { + CLAUDE_STATUSLINE_PATHNAME, + parseClaudeStatusLineBody +} from '../../../shared/claude-statusline-rate-limits' +import { mergeAgentHookRequestHeaders } from '../../../shared/agent-hook-listener/hook-envelope' +import { readRequestBody } from '../../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' +import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' +import { trackEmptyPaneKeyHook } from './server-transport-rules' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { + /** Start the loopback listener after hydration and spool replay have settled. */ + async start(options?: { + env?: string + userDataPath?: string + endpointNamespace?: string + }): Promise { + if (this.server) { + return + } + + if (options?.env) { + this.env = options.env + } + if (options?.userDataPath) { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.configureEndpointPaths(options.userDataPath, options.endpointNamespace) + } + this.token = randomUUID() + this.endpointFileWritten = false + this.lastWrittenJson = null + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + // Why: authenticate before spending work reading an untrusted body. + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + return + } + // Why: bound request time so a stalled client can't hold a socket open (slowloris). + // Why: track our own destroy so the slowloris cap can't be misread as outside interference. + let destroyedBySlowlorisCap = false + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + destroyedBySlowlorisCap = true + req.destroy() + }) + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + try { + const body = await readRequestBody(req) + if (pathname === CLAUDE_STATUSLINE_PATHNAME) { + const statusLineEvent = parseClaudeStatusLineBody(body) + if (statusLineEvent) { + this.onClaudeStatusLine?.(statusLineEvent) + } + res.writeHead(204) + res.end() + return + } + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + // Why: merge transport headers before normalization so relay-compatible fields have one canonical path. + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + trackEmptyPaneKeyHook(hookBody) + const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) + const normalized = this.normalizeLocalHookPayload(source, aliasedBody) + const statusDisposition = normalized.event + ? this.getAgentStatusDisposition(normalized.event.paneKey, { + source, + hookEventName: normalized.event.hookEventName, + isReplay: normalized.event.isReplay, + hasExplicitPrompt: normalized.event.hasExplicitPrompt, + launchToken: normalized.event.launchToken + }) + : 'suppress' + if (normalized.event && statusDisposition !== 'suppress') { + const event = + statusDisposition === 'restart' + ? { ...normalized.event, launchToken: undefined } + : normalized.event + if (statusDisposition === 'restart') { + // Why: a retired pane accepting a new turn is a different agent session behind the + // same key — later observations must not be ordered against the retired one. + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) + this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + } + res.writeHead(204) + res.end() + } catch (error) { + // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut + // by something on the loopback path, not by a bad payload. Fail open as before, but count it — + // this is the one failure mode that silently stops status for every runtime at once. + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) + } + // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. + res.writeHead(204) + res.end() + } + } + // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. + this.server = createServer((req, res) => { + void handleRequest(req, res) + }) + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.maybeWriteEndpointFile() + resolve() + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) + }) + } + + stop(): void { + // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. + this.flushStatusPersistSync() + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.env = 'production' + this.onAgentStatus = null + this.onPaneStatusCleared = null + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllCodexSubagentPolls() + this.endpointDir = null + this.endpointFilePathCache = null + this.endpointFileWritten = false + this.lastStatusFilePath = null + this.lastWrittenJson = null + this.runtimeObservedStatusPaneKeys.clear() + this.hydratedAuthorityCommitments = Object.freeze([]) + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + this.revokedHydratedAuthorityCommitments = new WeakSet() + this.currentAuthorityObservations.clear() + this.promptSentDedupeByPaneKey.clear() + this.closedAgentStatusTabIds.clear() + this.closedAgentStatusPaneKeys.clear() + this.restartedStatusLaunchTokenHashByPaneKey.clear() + this.retiredPaneFencesByKey.clear() + this.connectionTimestampWatermarkById.clear() + this.legacyPaneKeyAliases.clear() + // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. + clearAllListenerCaches(this.state) + this.notifyStatusChangeListeners() + } +} diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts new file mode 100644 index 00000000000..08d2ef21a70 --- /dev/null +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -0,0 +1,218 @@ +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../shared/agent-status-types' +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference' +import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload, + StatusDropListener +} from './server-types' +import { toAgentStatusIpcPayload } from './server-status-identity' +import { AgentHookServerState } from './server-state' + +export abstract class AgentHookServerListeners extends AgentHookServerState { + /** + * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). + * Why: the listener fails open on every request error, so without this the only symptom is + * agent status quietly going stale — for every runtime at once, since they share this transport. + */ + setTransportInterferenceListener( + listener: ((report: HookTransportInterferenceReport) => void) | null + ): void { + this.onTransportInterference = listener + } + + setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { + this.onAgentStatus = listener + if (!listener) { + return + } + // Why: replay is best-effort per pane so one throwing listener can't starve the rest. + for (const payload of this.state.lastStatusByPaneKey.values()) { + try { + // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. + listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) + } catch (err) { + console.error('[agent-hooks] replay listener threw', err) + } + } + } + + // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. + setClaudeStatusLineListener( + listener: ((event: ClaudeStatusLineRateLimits) => void) | null + ): void { + this.onClaudeStatusLine = listener + } + + subscribeStatusChanges(listener: (statuses: AgentHookStatusChangeEntry[]) => void): () => void { + this.statusChangeListeners.add(listener) + return () => { + this.statusChangeListeners.delete(listener) + } + } + + subscribeProviderSessionChanges( + listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void + ): () => void { + this.providerSessionChangeListeners.add(listener) + return () => { + this.providerSessionChangeListeners.delete(listener) + } + } + + /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user + * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned + * cleanup (synthetic spinners) still has to retire with the row it was driving. */ + subscribeStatusDrop(listener: StatusDropListener): () => void { + this.statusDropListeners.add(listener) + return () => { + this.statusDropListeners.delete(listener) + } + } + + protected emitStatusDropped(paneKey: string): void { + for (const listener of this.statusDropListeners) { + // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. + try { + listener(paneKey) + } catch (err) { + console.error('[agent-hooks] status-drop listener threw', err) + } + } + } + + /** Multi-subscriber tap on every enriched status change (no replay). */ + subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { + this.enrichedStatusListeners.add(listener) + return () => { + this.enrichedStatusListeners.delete(listener) + } + } + + /** Replay is durable evidence from a prior runtime, not a live observation. */ + protected withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + setPaneStatusClearListener(listener: ((clear: AgentStatusClearIpcPayload) => void) | null): void { + this.onPaneStatusCleared = listener + } + + /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` + * (a single slot the main window owns and drops on close) this survives window + * teardown and exists at all under headless serve, which never opens one. */ + subscribePaneStatusClear(listener: (clear: AgentStatusClearIpcPayload) => void): () => void { + this.paneStatusClearListeners.add(listener) + return () => { + this.paneStatusClearListeners.delete(listener) + } + } + + protected emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { + this.onPaneStatusCleared?.(clear) + for (const listener of this.paneStatusClearListeners) { + // Why: callers are pane/connection teardown paths; one throwing subscriber must + // not strand the rest, matching every other fan-out here. + try { + listener(clear) + } catch (err) { + console.error('[agent-hooks] pane-status-clear listener threw', err) + } + } + } + + /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the + * dashboard catches up on hook events that fired during startup. */ + getStatusSnapshot(): AgentStatusIpcPayload[] { + return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => + toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) + ) + } + + /** Provider-session identities, including Pi's metadata-only rows. */ + getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { + return this.buildStatusChangeNotification().providerSessions + } + + getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { + const entry = this.state.lastStatusByPaneKey.get(paneKey) + return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] + } + + getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { + return this.hydratedAuthorityCommitments + } + + getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { + return Object.freeze( + Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) + ) + } + + protected buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } { + const statuses: AgentHookStatusChangeEntry[] = [] + const providerSessions: AgentHookProviderSessionIdentity[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if (enriched.providerSession) { + providerSessions.push({ + paneKey, + sessionId: enriched.providerSession.id, + ...(enriched.providerSession.transcriptPath + ? { transcriptPath: enriched.providerSession.transcriptPath } + : {}), + ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) + }) + } + if (!enriched.providerSessionOnly) { + statuses.push({ + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) + }) + } + } + return { statuses, providerSessions } + } + + protected notifyStatusChangeListeners(): void { + if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { + return + } + const { statuses, providerSessions } = this.buildStatusChangeNotification() + for (const listener of this.statusChangeListeners) { + try { + listener(statuses) + } catch (err) { + console.error('[agent-hooks] status-change listener threw', err) + } + } + for (const listener of this.providerSessionChangeListeners) { + try { + listener(providerSessions) + } catch (err) { + console.error('[agent-hooks] provider-session listener threw', err) + } + } + } + + getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { + return this.buildStatusChangeNotification().statuses + } + + /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ + _getStateForTests(): HookListenerState { + return this.state + } +} diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts new file mode 100644 index 00000000000..061646731ee --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto' + +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../../shared/agent-status-types' +import { isAgentHookSource } from '../../../shared/agent-hook-relay' +import { normalizeClaudePromptId } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookAuthorityEvidence, EnrichedAgentHookEventPayload } from './server-types' +import { isValidPaneKey, isValidPiProviderSessionOnly } from './server-status-identity' + +export function dropHydratedIdleClaudeSubagents( + payload: ParsedAgentStatusPayload +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !payload.subagents?.some((subagent) => subagent.state === 'idle') + ) { + return payload + } + const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') + // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. + return { + ...payload, + subagents: activeSubagents.length > 0 ? activeSubagents : undefined + } +} + +export function sanitizeHydratedEntry( + paneKey: string, + rawEntry: unknown +): EnrichedAgentHookEventPayload | null { + const parsedPaneKey = parsePaneKey(paneKey) + if (!parsedPaneKey) { + return null + } + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + if (record.paneKey !== paneKey) { + return null + } + const tabId = record.tabId + if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { + return null + } + // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. + if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { + return null + } + const worktreeId = record.worktreeId + if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { + return null + } + const receivedAt = record.receivedAt + if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { + return null + } + const stateStartedAt = record.stateStartedAt + if ( + typeof stateStartedAt !== 'number' || + !Number.isFinite(stateStartedAt) || + stateStartedAt <= 0 + ) { + return null + } + // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. + const connectionIdRaw = record.connectionId + let connectionId: string | null + if (connectionIdRaw === null || connectionIdRaw === undefined) { + connectionId = null + } else if (typeof connectionIdRaw === 'string') { + connectionId = connectionIdRaw + } else { + return null + } + const payload = normalizeAgentStatusPayload(record.payload) + if (!payload) { + return null + } + const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined + const providerSessionOnly = record.providerSessionOnly === true + const retainedForLiveness = record.retainedForLiveness === true + const validRetainedIdentity = Boolean( + retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' + ) + if ( + providerSessionOnly && + !isValidPiProviderSessionOnly(providerSession, payload.agentType) && + !validRetainedIdentity + ) { + return null + } + const source = isAgentHookSource(record.source) ? record.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') + ? record.compactTrigger + : undefined + return { + paneKey, + source, + tabId: typeof tabId === 'string' ? tabId : undefined, + worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, + connectionId, + hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, + hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, + providerPromptId, + compactTrigger, + toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, + toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, + teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, + toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, + providerSession, + providerSessionOnly: providerSessionOnly ? true : undefined, + retainedForLiveness: retainedForLiveness ? true : undefined, + payload, + receivedAt, + stateStartedAt + } +} + +export function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { + return launchTokenHash + } + const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' + return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null +} + +export function sanitizePersistedAuthorityCommitment( + paneKey: string, + value: unknown +): AgentHookAuthorityEvidence | null { + if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + const connectionId = record.connectionId + const observedAt = record.observedAt + if ( + !/^[a-f0-9]{64}$/.test(launchTokenHash) || + (connectionId !== null && typeof connectionId !== 'string') || + typeof observedAt !== 'number' || + !Number.isFinite(observedAt) + ) { + return null + } + return Object.freeze({ + paneKey, + launchTokenHash, + connectionId: connectionId as string | null, + ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), + ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), + observedAt + }) +} + +export function authorityCommitmentsMatch( + left: AgentHookAuthorityEvidence, + right: AgentHookAuthorityEvidence +): boolean { + return ( + left.paneKey === right.paneKey && + left.launchTokenHash === right.launchTokenHash && + left.connectionId === right.connectionId && + left.tabId === right.tabId && + left.worktreeId === right.worktreeId + ) +} diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts new file mode 100644 index 00000000000..ecb33c44d37 --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -0,0 +1,141 @@ +import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createHash, randomUUID } from 'node:crypto' + +import { isValidPaneKey } from './server-status-identity' +import { LAST_STATUS_FILE_VERSION, STATUS_PERSIST_DEBOUNCE_MS } from './server-constants' +import type { + EnrichedAgentHookEventPayload, + LastStatusFile, + PersistedAgentHookAuthorityCommitment, + PersistedAgentHookEventPayload +} from './server-types' +import { authorityCommitmentsMatch } from './server-persistence-validation' +import { AgentHookServerHydration } from './server-hydration' + +export abstract class AgentHookServerPersistence extends AgentHookServerHydration { + protected serializeStatusFile(): string { + const entries: Record = {} + const authorityCommitments: Record = {} + const conflictedCommitments = new Set() + for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { + authorityCommitments[paneKey] = { ...commitment } + } + for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { + // Why: never persist invalid keys (matches the hydrate-path invariant). + if (!isValidPaneKey(paneKey)) { + continue + } + const enrichedPayload = payload as EnrichedAgentHookEventPayload + const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true + const { + claudeRunningNonAgentTask: _claudeRunningNonAgentTask, + promptInteractionKey: _promptInteractionKey, + // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. + restoredUnconfirmed: _restoredUnconfirmed, + // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). + observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, + launchToken, + ...persistedPayload + } = enrichedPayload + const launchTokenHash = launchToken?.trim() + ? createHash('sha256').update(launchToken.trim()).digest('hex') + : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + entries[paneKey] = { + ...persistedPayload, + ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), + ...(launchTokenHash ? { launchTokenHash } : {}) + } + const commitment = this.toAuthorityEvidence(payload, launchTokenHash) + if (commitment && !conflictedCommitments.has(paneKey)) { + const existing = authorityCommitments[paneKey] + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + delete authorityCommitments[paneKey] + conflictedCommitments.add(paneKey) + } else { + authorityCommitments[paneKey] = { ...commitment } + } + } + } + const file: LastStatusFile = { + version: LAST_STATUS_FILE_VERSION, + entries, + authorityCommitments + } + return JSON.stringify(file) + } + + protected scheduleStatusPersist(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: reset the timer each call so the write fires only after the last event in a burst. + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + } + this.statusPersistTimer = setTimeout(() => { + this.statusPersistTimer = null + this.runStatusPersist() + }, STATUS_PERSIST_DEBOUNCE_MS) + // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. + if (typeof this.statusPersistTimer.unref === 'function') { + this.statusPersistTimer.unref() + } + } + + flushStatusPersistSync(): void { + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + this.statusPersistTimer = null + } + if (!this.lastStatusFilePath) { + return + } + this.runStatusPersist() + } + + protected runStatusPersist(): void { + if (!this.lastStatusFilePath || !this.endpointDir) { + return + } + const json = this.serializeStatusFile() + if (json === this.lastWrittenJson) { + return + } + const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) + let tmpWritten = false + try { + mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + try { + chmodSync(this.endpointDir, 0o700) + } catch { + // best-effort + } + } + writeFileSync(tmpPath, json, { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, this.lastStatusFilePath) + this.lastWrittenJson = json + } catch (err) { + console.warn('[agent-hooks] failed to write last-status file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp already gone + } + } + } + } + + _resetPromptSentDedupeForTests(): void { + this.promptSentDedupeByPaneKey.clear() + } + + _resetConnectionTimestampWatermarksForTests(): void { + this.connectionTimestampWatermarkById.clear() + } +} diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts new file mode 100644 index 00000000000..7805303ced1 --- /dev/null +++ b/src/main/agent-hooks/server/server-reaping.ts @@ -0,0 +1,124 @@ +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasWorkingSubagent, + claudeRosterToSnapshots +} from '../../../shared/claude-subagent-roster' +import { reapRestoredClaudeSubagentsForDeadPane } from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { AgentHookServerTabCleanup } from './server-tab-cleanup' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup { + /** Second reap path for restored Claude subagent rows: drop the ones whose pane + * has no live local agent process behind it any more. A PTY that dies while Orca + * is down never runs the teardown that clears pane state, so hydrate rebuilds a + * roster nothing can ever retire — the inventory reap needs the parent to emit a + * complete `background_tasks` list and an idle parent never does. The row then + * gates the pane 'working' for the rest of its life and hibernation, which + * requires 'done', can never reclaim the agent's heap. + * + * Both the execution host and relay binding must prove local ownership before + * targeted PTY liveness is consulted. Panes that reported in this runtime are + * also skipped. Returns the number of panes changed. */ + async reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalExecutionHost: (worktreeId: string | undefined) => boolean, + isLocalPaneAgentLive: (paneKey: string) => Promise, + isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean + ): Promise { + const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if ( + enriched.payload.agentType === 'claude' && + enriched.connectionId === null && + isLocalExecutionHost(enriched.worktreeId) && + // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, + // or a background-task/cron latch nothing will refresh, strands the pane just as + // permanently — and unlike the roster case there is no child event left to reap it. + (claudeRosterHasRestoredSnapshotSubagent( + this.state.claudeSubagentRosterByPaneKey.get(paneKey) + ) || + enriched.payload.state !== 'done' || + this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && + !this.runtimeObservedStatusPaneKeys.has(paneKey) + ) { + candidates.push({ paneKey, entry: enriched }) + } + } + const liveness = await Promise.all( + candidates.map(async (candidate) => { + try { + return await isLocalPaneAgentLive(candidate.paneKey) + } catch { + return true + } + }) + ) + let changedPanes = 0 + for (const [index, candidate] of candidates.entries()) { + const { paneKey, entry: enriched } = candidate + if ( + liveness[index] || + !isLocalPaneLivenessEvidenceCurrent(paneKey) || + this.state.lastStatusByPaneKey.get(paneKey) !== enriched || + this.runtimeObservedStatusPaneKeys.has(paneKey) || + !isLocalExecutionHost(enriched.worktreeId) + ) { + continue + } + if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { + // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably + // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the + // pane the same way an observed exit would — otherwise the widened candidate set is inert. + // + // Why delete rather than downgrade to `done` like the reap branch below: that branch has a + // real turn to describe — a parent whose children it just reaped — while these panes' only + // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would + // invent a completion that never happened, and leaving it non-terminal keeps the bug. This + // sweep stands in for the exit Orca never observed, so it does what that exit does: + // `clearProviderPtyState` -> `clearPaneState`. + if (this.hasLiveClaimsForPaneKey(paneKey)) { + this.clearPaneState(paneKey) + changedPanes += 1 + } + continue + } + changedPanes += 1 + const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) + const subagents = claudeRosterToSnapshots(roster) + // Why: the pane's persisted 'working' was the child gate holding a finished + // lead open (subagent events never set lead state). With the last working row + // gone and no process left to report, 'done' is the only truthful state — and + // the one hibernation needs once this pane's agent is restored. + const state = + enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) + ? 'done' + : enriched.payload.state + const stateChanged = state !== enriched.payload.state + const reconciledAt = stateChanged + ? Math.max(Date.now(), enriched.receivedAt + 1) + : enriched.receivedAt + // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying + // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. + const { restoredUnconfirmed, ...reconciledBase } = enriched + const reconciled: EnrichedAgentHookEventPayload = { + ...reconciledBase, + ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + receivedAt: reconciledAt, + stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, + payload: { + ...enriched.payload, + state, + workingMode: state === 'working' ? enriched.payload.workingMode : undefined, + subagents + } + } + this.state.lastStatusByPaneKey.set(paneKey, reconciled) + } + if (changedPanes > 0) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + return changedPanes + } +} diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts new file mode 100644 index 00000000000..e5b9e4c1363 --- /dev/null +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -0,0 +1,63 @@ +import { join } from 'node:path' +import { + getEndpointFileName, + writeEndpointFile +} from '../../../shared/agent-hook-listener/endpoint-publication' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../../../shared/agent-hook-types' +import { AgentHookServerIngestRemote } from './server-ingest-remote' + +export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + const env: Record = { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT + } + // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. + if (this.endpointFileWritten && this.endpointFilePathCache) { + env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache + } + return env + } + + get endpointFilePath(): string | null { + return this.endpointFilePathCache + } + + /** Test/diagnostic accessor for the on-disk last-status file path. */ + get lastStatusPath(): string | null { + return this.lastStatusFilePath + } + + protected maybeWriteEndpointFile(): void { + if (!this.endpointDir || !this.endpointFilePathCache) { + return + } + this.endpointFileWritten = false + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT + }) + this.endpointFileWritten = ok + } + + protected configureEndpointPaths(userDataPath: string, endpointNamespace?: string): void { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.endpointDir = endpointNamespace + ? join(userDataPath, 'agent-hooks', endpointNamespace) + : join(userDataPath, 'agent-hooks') + this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) + this.lastStatusFilePath = join(this.endpointDir, 'last-status.json') + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts new file mode 100644 index 00000000000..956be136ca6 --- /dev/null +++ b/src/main/agent-hooks/server/server-state.ts @@ -0,0 +1,217 @@ +import type { createServer } from 'node:http' +import { randomBytes } from 'node:crypto' + +import { + createHookListenerState, + type HookListenerState +} from '../../../shared/agent-hook-listener/listener-state' +import { + createHookTransportInterferenceTracker, + describeHookTransportInterference, + type HookTransportInterferenceReport +} from '../../../shared/agent-hook-transport-interference' +import { + AgentStatusObservationSequencer, + createAgentStatusAuthorityId, + type AgentStatusObservation, + type AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { SpoolRecord } from '../../../shared/agent-hook-spool' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + AgentPromptSentDedupeEntry, + EnrichedAgentHookEventPayload, + NormalizedLocalHook, + PaneKeyAliasEntry, + PaneKeyAliasPersistenceListener, + PaneStatusClearListener, + ProviderSessionChangeListener, + RetiredPaneAlias, + RetiredPaneFence, + ServerAgentStatusListener, + ServerStatusLineListener, + StatusChangeListener, + StatusDropListener +} from './server-types' + +/** Shared mutable state for the layered hook-server implementation. */ +export abstract class AgentHookServerState { + protected server: ReturnType | null = null + protected port = 0 + protected token = '' + // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. + protected env = 'production' + protected onAgentStatus: ServerAgentStatusListener = null + protected onClaudeStatusLine: ServerStatusLineListener = null + protected onPaneStatusCleared: PaneStatusClearListener | null = null + protected paneStatusClearListeners = new Set() + protected statusDropListeners = new Set() + protected statusChangeListeners = new Set() + protected providerSessionChangeListeners = new Set() + // Why: setListener is a single slot owned by the main-window fanout; the + // plugin event bus (and future consumers) need an additive subscription + // that also works in headless serve, where no window listener exists. + protected enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() + // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). + protected endpointDir: string | null = null + protected endpointFilePathCache: string | null = null + protected endpointFileWritten = false + // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. + protected state: HookListenerState = createHookListenerState() + protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = + null + protected transportInterference = createHookTransportInterferenceTracker( + (report: HookTransportInterferenceReport) => { + console.warn(describeHookTransportInterference(report)) + this.onTransportInterference?.(report) + } + ) + // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. + protected runtimeObservedStatusPaneKeys = new Set() + protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) + protected hydratedLaunchTokenHashByPaneKey = new Map() + protected persistedAuthorityCommitmentsByPaneKey = new Map() + protected revokedHydratedAuthorityCommitments = new WeakSet() + protected currentAuthorityObservations = new Map() + protected legacyPaneKeyAliases = new Map() + // Why: indexed by every key the retirement fenced, so a re-attach on any of them + // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps + // it mirrors; an evicted record simply degrades to lifting the key it was handed. + protected retiredPaneFencesByKey = new Map() + protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null + // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. + protected lastStatusFilePath: string | null = null + // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. + protected statusPersistTimer: ReturnType | null = null + protected assistantMessageRetryTimers = new Map>() + protected promptSentDedupeByPaneKey = new Map() + protected activeHookTurnCompletedAtByPaneKey = new Map() + protected promptSentHashSalt = randomBytes(16).toString('hex') + protected closedAgentStatusTabIds = new Set() + protected closedAgentStatusPaneKeys = new Set() + protected restartedStatusLaunchTokenHashByPaneKey = new Map() + protected connectionTimestampWatermarkById = new Map() + // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. + protected lastWrittenJson: string | null = null + // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own + // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. + protected readonly observations = new AgentStatusObservationSequencer( + createAgentStatusAuthorityId('main-agent-hooks') + ) + + protected abstract withdrawReplayObservation(paneKey: string): void + protected abstract ingestSpoolRecord(record: SpoolRecord): void + protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void + protected abstract buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } + protected abstract notifyStatusChangeListeners(): void + protected abstract markTabClosedForAgentStatus(tabId: string): void + protected abstract getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' + protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean + protected abstract recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void + protected abstract markPaneClosedForAgentStatus(paneKey: string): void + protected abstract attachStatusTiming( + payload: AgentHookEventPayload, + now?: number + ): EnrichedAgentHookEventPayload + protected abstract hashPromptForTelemetryDedupe(prompt: string): string + protected abstract maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void + protected abstract stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation + protected abstract applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin?: AgentStatusObservationOrigin + ): EnrichedAgentHookEventPayload + protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void + protected abstract clearAssistantMessageRetry(paneKey: string): void + protected abstract clearCodexSubagentPoll(paneKey: string): void + protected abstract clearAllCodexSubagentPolls(): void + protected abstract scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void + protected abstract scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt?: number, + discoveryReady?: boolean + ): void + protected abstract applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void + protected abstract getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] + protected abstract notifyPaneKeyAliasPersistenceListener(): void + protected abstract boundPaneKeyAliases(): void + protected abstract getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string + protected abstract restoreRetiredPaneFence(fence: RetiredPaneFence): void + protected abstract revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean + protected abstract resolvePaneKeyAlias(paneKey: string): string + protected abstract normalizeHookBodyPaneKeyAlias(body: unknown): unknown + protected abstract normalizeLocalHookPayload( + source: AgentHookSource, + body: unknown + ): NormalizedLocalHook + protected abstract setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void + protected abstract toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null + protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean + protected abstract clearPaneState(paneKey: string): void + protected abstract deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null + protected abstract maybeWriteEndpointFile(): void + protected abstract hydrateLastStatusFromDisk(): void + protected abstract captureHydratedAuthorityCommitments(): void + protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void + protected abstract toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null + protected abstract serializeStatusFile(): string + protected abstract scheduleStatusPersist(): void + protected abstract runStatusPersist(): void + + abstract _getStateForTests(): HookListenerState + abstract _resetPromptSentDedupeForTests(): void + abstract _resetConnectionTimestampWatermarksForTests(): void +} diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts new file mode 100644 index 00000000000..7fbb6a96bc1 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' + +import { getCohortAtEmit } from '../../telemetry/cohort-classifier' +import { track } from '../../telemetry/client' +import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary' +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusObservation, + AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { agentTypeToPromptSentAgentKind } from './server-status-identity' +import { AgentHookServerStatusDisposition } from './server-status-disposition' + +export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { + protected attachStatusTiming( + payload: AgentHookEventPayload, + now = Date.now() + ): EnrichedAgentHookEventPayload { + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const commandCodeNewTurn = + previous !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: payload.payload.agentType, + previousState: previous.payload.state, + incomingState: payload.payload.state, + previousPrompt: previous.payload.prompt, + incomingPrompt: payload.payload.prompt, + hasExplicitPrompt: payload.hasExplicitPrompt, + previousPromptInteractionKey: previous.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) + const stateStartedAt = + previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn + ? previous.stateStartedAt + : now + // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. + return { + ...payload, + receivedAt: now, + stateStartedAt + } + } + + protected hashPromptForTelemetryDedupe(prompt: string): string { + return createHash('sha256') + .update(this.promptSentHashSalt) + .update('\0') + .update(prompt) + .digest('hex') + } + + protected maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void { + if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { + return + } + const prompt = payload.payload.prompt?.trim() ?? '' + if (prompt.length === 0) { + return + } + const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) + const promptHash = this.hashPromptForTelemetryDedupe(prompt) + const promptInteractionKey = + typeof payload.promptInteractionKey === 'string' && + payload.promptInteractionKey.trim().length > 0 + ? payload.promptInteractionKey.trim() + : undefined + const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) + const isCompletedTurnBoundary = + previousStatus?.payload.state === 'done' && payload.payload.state === 'working' + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey === promptInteractionKey && + (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) + ) { + return + } + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptHash === promptHash && + !( + previousStatus?.payload.state === 'done' && + payload.payload.state === 'done' && + previousDedupe.promptInteractionKey !== undefined && + promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey !== promptInteractionKey + ) && + !isCompletedTurnBoundary + ) { + return + } + this.promptSentDedupeByPaneKey.set(payload.paneKey, { + agentKind, + promptHash, + promptInteractionKey + }) + try { + // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. + track('agent_prompt_sent', { + agent_kind: agentKind, + launch_source: 'unknown', + request_kind: 'followup', + ...getCohortAtEmit() + }) + } catch (err) { + console.error('[agent-hooks] prompt-sent telemetry failed', err) + } + } + + /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet + * (STA-4293) — it is stamped here because every main-side ingress funnels through + * applyNormalizedStatus, so no origin can silently arrive untagged. */ + protected stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation { + return this.observations.observe(payload.paneKey, { + origin, + observedAt, + // Why: reuse the listener's own per-provider classifier; a second list of raw event-name + // literals here would strand the providers whose boundary event is named anything else. + boundary: + payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), + kind: payload.providerSessionOnly + ? 'identity-only' + : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the + // current state rather than announcing a change — neither is a fresh transition. + payload.isReplay === true || origin === 'osc' + ? 'snapshot' + : 'transition' + }) + } +} diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts new file mode 100644 index 00000000000..b6c69967280 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -0,0 +1,161 @@ +import { createHash } from 'node:crypto' + +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + RETIRED_PANE_FENCES_MAX +} from './server-constants' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import { AgentHookServerStatusInference } from './server-status-inference' + +export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference { + protected markTabClosedForAgentStatus(tabId: string): void { + // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. + this.closedAgentStatusTabIds.delete(tabId) + this.closedAgentStatusTabIds.add(tabId) + while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + const oldest = this.closedAgentStatusTabIds.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusTabIds.delete(oldest) + } + } + + protected getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneRetired = + this.closedAgentStatusPaneKeys.has(paneKey) || + this.closedAgentStatusPaneKeys.has(ownerPaneKey) + const tabId = parsePaneKey(ownerPaneKey)?.tabId + if (tabId && this.closedAgentStatusTabIds.has(tabId)) { + return 'suppress' + } + if (!paneRetired) { + const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) + // Why: deferred retirement lets a new process start in a still-authorized pane, so + // its tokened SessionStart re-fences; prompts recur, so a stale process would win. + if ( + event?.hookEventName === 'SessionStart' && + event.isReplay !== true && + tokenFence !== undefined + ) { + const startedLaunchToken = event.launchToken?.trim() + if (startedLaunchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(startedLaunchToken).digest('hex') + ) + return 'accept' + } + } + if (event && tokenFence) { + const launchToken = event.launchToken?.trim() + if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + return 'suppress' + } + } + return 'accept' + } + // Why: command completion retires launch authority but leaves its shell pane reusable. + // A live new-turn event proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + // Why the classifier, not literals: only 5 of 18 sources name their boundary + // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. + // Why four branches: `source` collapses to undefined when an older relay omits the field, + // when a newer host sends an unknown string, and when the wire value is malformed. Only an + // unknown string is valid future-provider evidence. Unreachable from the local path, which + // 404s an unresolvable source. + const isNewTurn = + event?.source !== undefined + ? isNewTurnEvent(event.source, event.hookEventName) + : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 + ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and + // the costs are asymmetric — a stranded pane is invisible and permanent with no user + // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. + true + : event?.rawSource === undefined + ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it + // cannot revive a provider whose boundary event is named anything else. + event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' + : false + // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in + // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no + // SessionStart at all, so without this its retired panes never come back. + const freshOpenCodeFamilyPrompt = + (event?.source === 'opencode' || event?.source === 'mimo-code') && + event.hookEventName === 'MessagePart' && + event.hasExplicitPrompt === true + // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up + // status on that launch token stops a stale process reclaiming the pane's row without + // restoring retired orchestration authority. + if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + const launchToken = event?.launchToken?.trim() + if (launchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(launchToken).digest('hex') + ) + } else { + this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) + } + return 'restart' + } + return 'suppress' + } + + // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric + // keys never parse as stable ones — resolve both forms so neither slips the tab check. + protected isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { + const tabId = + parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined + return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) + } + + protected recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void { + const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } + for (const key of paneKeys) { + // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. + this.retiredPaneFencesByKey.delete(key) + this.retiredPaneFencesByKey.set(key, fence) + } + while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { + const oldest = this.retiredPaneFencesByKey.keys().next().value + if (oldest === undefined) { + break + } + this.retiredPaneFencesByKey.delete(oldest) + } + } + + protected markPaneClosedForAgentStatus(paneKey: string): void { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.add(paneKey) + while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { + const oldest = this.closedAgentStatusPaneKeys.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusPaneKeys.delete(oldest) + } + } +} diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts new file mode 100644 index 00000000000..41a87b4d7de --- /dev/null +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentKind } from '../../../shared/telemetry-events' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AGENT_PROMPT_SENT_AGENT_KINDS, TOOL_PROGRESS_HOOK_EVENTS } from './server-constants' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' + +export function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { + const normalized = agentType?.trim().toLowerCase() + if (!normalized || normalized === 'unknown') { + return 'other' + } + if (normalized === 'claude') { + return 'claude-code' + } + return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) + ? (normalized as AgentKind) + : 'other' +} + +export function equivalentInterruptAgentType( + actual: AgentType | undefined, + baseline: AgentType | undefined +): boolean { + const normalizedActual = actual === 'unknown' ? undefined : actual + const normalizedBaseline = baseline === 'unknown' ? undefined : baseline + return normalizedActual === normalizedBaseline +} + +// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. +export function isValidPaneKey(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null + ) +} + +// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. +export function isValidPiProviderSessionOnly( + providerSession: AgentProviderSessionMetadata | undefined, + agentType: AgentType | undefined +): boolean { + return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) +} + +export function toAgentStatusIpcPayload( + entry: EnrichedAgentHookEventPayload +): AgentStatusIpcPayload { + return { + paneKey: entry.paneKey, + ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + receivedAt: entry.receivedAt, + stateStartedAt: entry.stateStartedAt, + ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), + ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), + ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(entry.observation ? { observation: entry.observation } : {}), + ...entry.payload + } +} + +export function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { + if (next.payload.state !== 'working') { + return false + } + if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { + return false + } + // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. + return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) +} + +export function paneCacheKeyTabId(key: string): string | null { + const paneKey = key.split('\0', 1)[0] ?? key + return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null +} + +export function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { + return paneCacheKeyTabId(key) === tabId +} + +export function hashLaunchToken(value: string): string { + return createHash('sha256').update(value).digest('hex') +} diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts new file mode 100644 index 00000000000..ec651691982 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -0,0 +1,175 @@ +import { + markClaudeLeadTurnInterrupted, + clearClaudeAnsweredQuestionWait +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' +import { + isAgentInterruptInputIntent, + type AgentInterruptInferenceRequest +} from '../../../shared/agent-interrupt-intent' +import { + isAskUserQuestionTool, + type AgentQuestionAnsweredInferenceRequest +} from '../../../shared/agent-question-answered-intent' +import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' +import { AgentHookServerListeners } from './server-listeners' + +export abstract class AgentHookServerStatusInference extends AgentHookServerListeners { + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + if (!isAgentInterruptInputIntent(request.intent)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + if (existing.providerSessionOnly) { + return false + } + // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + const agentType: AgentType | undefined = payload.agentType + // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. + if (agentType === 'droid' && request.intent === 'ctrl-c') { + return false + } + // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. + if ( + (agentType === 'opencode' || agentType === 'copilot') && + request.intent === 'plain-escape' && + request.inputCount !== 2 + ) { + return false + } + const dismissesClaudeQuestion = + agentType === 'claude' && + request.intent === 'plain-escape' && + payload.state === 'waiting' && + isAskUserQuestionTool(payload.toolName) + if (dismissesClaudeQuestion) { + return this.inferQuestionAnswered(request) + } + // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. + if ( + payload.state !== 'working' || + !equivalentInterruptAgentType(agentType, request.baselineAgentType) || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. + if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { + return false + } + // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. + if ( + agentType === 'claude' && + (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) + ) { + return false + } + // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } + if (agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, existing.paneKey) + } + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: 'done', + prompt: payload.prompt, + agentType, + ...(payload.model ? { model: payload.model } : {}), + interrupted: true, + // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred interrupted agent status', { + paneKey: inferred.paneKey, + agentType, + intent: request.intent + }) + return true + } + + /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ + inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. + if ( + payload.agentType !== 'claude' || + payload.state !== 'waiting' || + !isAskUserQuestionTool(payload.toolName) + ) { + return false + } + if ( + payload.agentType !== request.baselineAgentType || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. + const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: restored.state, + ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), + prompt: payload.prompt, + agentType: payload.agentType, + ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred resolved question status', { + paneKey: inferred.paneKey, + state: inferred.payload.state + }) + return true + } +} diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts new file mode 100644 index 00000000000..2757806b293 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -0,0 +1,155 @@ +import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../../shared/agent-hook-listener/grok-result-discovery' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { + ASSISTANT_MESSAGE_RETRY_ATTEMPTS, + ASSISTANT_MESSAGE_RETRY_MS, + CODEX_SUBAGENT_POLL_MS +} from './server-constants' +import { AgentHookServerStatusUpdate } from './server-status-update' + +type CodexSubagentPoll = { + source: AgentHookSource + body: unknown + original: EnrichedAgentHookEventPayload +} + +export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { + private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler( + CODEX_SUBAGENT_POLL_MS, + (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + ) + + protected clearAllCodexSubagentPolls(): void { + this.codexSubagentPollScheduler.clearAll() + } + + protected clearAssistantMessageRetry(paneKey: string): void { + const timer = this.assistantMessageRetryTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.assistantMessageRetryTimers.delete(paneKey) + } + + protected clearCodexSubagentPoll(paneKey: string): void { + this.codexSubagentPollScheduler.clear(paneKey) + } + + protected scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.codexSubagentPollScheduler.clear(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) + } + + private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + const { source, body, original } = poll + // Keep the identity check at callback time: a newer event supersedes this + // payload even when its pane still has transcript children. + if ( + paneKey !== original.paneKey || + !this.server || + this.state.lastStatusByPaneKey.get(original.paneKey) !== original + ) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized) { + return + } + const subagentsChanged = + JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + this.scheduleCodexSubagentPoll(source, body, next) + } + + protected scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt = 1, + discoveryReady = false + ): void { + if ( + original.payload.lastAssistantMessage || + !hasPendingAgentResultText(source, body) || + attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS + ) { + return + } + this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, 1, true) + } + }) + .catch((err) => { + console.error('[agent-hooks] Grok result discovery failed:', err) + }) + return + } + } + const timer = setTimeout(() => { + try { + this.assistantMessageRetryTimers.delete(original.paneKey) + this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) + } catch (err) { + console.error('[agent-hooks] assistant message retry failed:', err) + } + }, ASSISTANT_MESSAGE_RETRY_MS) + this.assistantMessageRetryTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + protected applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const normalized = this.normalizeLocalHookPayload(source, body) + if (!normalized.event?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) + return + } + // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. + this.applyNormalizedStatus(normalized.event, normalized.onAccepted) + } +} diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts new file mode 100644 index 00000000000..981da873e3d --- /dev/null +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -0,0 +1,219 @@ +import { + reconcileRemoteCodexState, + markCodexLeadTurnInterrupted +} from '../../../shared/agent-hook-listener/providers/codex-state' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../shared/agent-status-identity' +import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants' +import type { EnrichedAgentHookEventPayload } from './server-types' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import { + attachClaudeChildOnlyBoundary, + attachClaudePermissionToolUseId, + invalidateClaudeChildOnlyBoundary, + shouldKeepClaudePermissionVisible +} from './server-claude-status-rules' +import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' +import { AgentHookServerStatusApplication } from './server-status-application' + +export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { + protected applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin: AgentStatusObservationOrigin = 'hook' + ): EnrichedAgentHookEventPayload { + if (payload.hookEventName === 'UserPromptSubmit') { + // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. + this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) + } + let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const connectionClearWatermark = payload.connectionId + ? this.connectionTimestampWatermarkById.get(payload.connectionId) + : undefined + // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. + const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined + const now = Math.max( + Date.now(), + (connectionClearWatermark ?? -1) + 1, + (restoredStatusWatermark ?? -1) + 1 + ) + if (payload.connectionId) { + this.connectionTimestampWatermarkById.set(payload.connectionId, now) + } + if (payload.providerSessionOnly) { + // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. + onAccepted?.() + const enriched = { + ...this.attachStatusTiming(payload, now), + observation: this.stampObservation(payload, origin, now) + } + this.clearAssistantMessageRetry(enriched.paneKey) + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + const stateReconciledPayload = + payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + ? { + ...payload, + payload: reconcileRemoteCodexState( + this.state, + payload.paneKey, + payload.hookEventName, + payload.toolAgentId, + payload.payload, + previous?.payload + ) + } + : payload + const previousCodexRoot = + stateReconciledPayload.payload.agentType === 'codex' && + stateReconciledPayload.toolAgentId && + previous?.payload.agentType === 'codex' + ? previous + : undefined + const preservedProviderSession = !stateReconciledPayload.providerSession + ? previousCodexRoot?.providerSession + : undefined + const preservedRootModel = !stateReconciledPayload.payload.model + ? previousCodexRoot?.payload.model + : undefined + // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. + const rootContextPreservingPayload = + preservedProviderSession || preservedRootModel + ? { + ...stateReconciledPayload, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: preservedRootModel + ? { ...stateReconciledPayload.payload, model: preservedRootModel } + : stateReconciledPayload.payload + } + : stateReconciledPayload + const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( + previous, + rootContextPreservingPayload + ) + if (boundaryReconciledPrevious !== previous) { + previous = boundaryReconciledPrevious + if (previous) { + this.state.lastStatusByPaneKey.set(previous.paneKey, previous) + this.scheduleStatusPersist() + } + } + const identity = resolveAgentStatusIdentity({ + existing: previous + ? { + agentType: previous.payload.agentType, + state: previous.payload.state, + updatedAt: previous.receivedAt, + restoredUnconfirmed: previous.restoredUnconfirmed + } + : undefined, + incoming: rootContextPreservingPayload.payload.agentType, + now + }) + if ( + previous && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: rootContextPreservingPayload.payload.state + }) + ) { + return previous + } + const identityResolvedPayload = + identity.agentType === rootContextPreservingPayload.payload.agentType + ? rootContextPreservingPayload + : { + ...rootContextPreservingPayload, + payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } + } + const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) + if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { + return previous + } + // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'done' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + ) { + return previous + } + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'working' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + (effectivePayload.isReplay === true || + isToolProgressWorkingAfterInterrupt(effectivePayload) || + (effectivePayload.hasExplicitPrompt !== true && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) + ) { + if (effectivePayload.payload.agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) + } + return previous + } + if ( + effectivePayload.payload.state !== 'done' || + effectivePayload.payload.lastAssistantMessage + ) { + this.clearAssistantMessageRetry(effectivePayload.paneKey) + } + onAccepted?.() + if (!identity.inheritedFromActivePane) { + this.maybeTrackAgentPromptSent(effectivePayload, previous) + } + const enriched = { + ...this.attachStatusTiming(boundaryAwarePayload, now), + observation: this.stampObservation(boundaryAwarePayload, origin, now) + } + if ( + typeof enriched.payload.turnCompletedAt === 'number' && + Number.isFinite(enriched.payload.turnCompletedAt) + ) { + this.activeHookTurnCompletedAtByPaneKey.set( + enriched.paneKey, + enriched.payload.turnCompletedAt + ) + } + // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. + if (enriched.restoredUnconfirmed) { + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + } else { + this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) + } + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + + // Why: every status emit must reach plugins too, so a new early-return path + // upstream cannot silently leave the plugin tap behind the main-window fanout. + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + this.onAgentStatus?.(enriched) + for (const listener of this.enrichedStatusListeners) { + try { + listener(enriched) + } catch (err) { + console.error('[agent-hooks] enriched status listener threw', err) + } + } + } +} diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts new file mode 100644 index 00000000000..4abacfc81d0 --- /dev/null +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -0,0 +1,122 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { paneCacheKeyMatchesTab } from './server-status-identity' +import { AgentHookServerCleanup } from './server-cleanup' + +export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { + /** Drop every status/cache claim attributable to a closed tab prefix. */ + dropStatusEntriesByTabPrefix(tabId: string): void { + this.markTabClosedForAgentStatus(tabId) + const paneKeysToClear = new Set() + for (const key of this.state.lastStatusByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key) + } + } + for (const key of this.state.lastPromptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.lastToolByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.ampCompletedCacheKeys) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const paneKey of this.runtimeObservedStatusPaneKeys) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const commitment of this.hydratedAuthorityCommitments) { + if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { + paneKeysToClear.add(commitment.paneKey) + } + } + let aliasChanged = false + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeysToClear.add(legacyPaneKey) + paneKeysToClear.add(entry.stablePaneKey) + this.markPaneClosedForAgentStatus(legacyPaneKey) + this.markPaneClosedForAgentStatus(entry.stablePaneKey) + aliasChanged = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) + let statusChanged = false + for (const paneKey of paneKeysToClear) { + if (this.state.lastStatusByPaneKey.has(paneKey)) { + statusChanged = true + } + this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) + clearPaneCacheState(this.state, paneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) + this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.currentAuthorityObservations.delete(paneKey) + this.promptSentDedupeByPaneKey.delete(paneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + clearPaneState(paneKey: string): void { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, resolvedPaneKey]) + // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. + const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + clearPaneCacheState(this.state, resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + let clearedAlias = false + for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) { + if (alias.stablePaneKey === resolvedPaneKey) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeys.add(legacyPaneKey) + paneKeys.add(alias.stablePaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) + clearedAlias = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + if (clearedAlias) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) + } + } +} diff --git a/src/main/agent-hooks/server/server-transport-rules.ts b/src/main/agent-hooks/server/server-transport-rules.ts new file mode 100644 index 00000000000..2f1aacc370e --- /dev/null +++ b/src/main/agent-hooks/server/server-transport-rules.ts @@ -0,0 +1,13 @@ +import { track } from '../../telemetry/client' + +/** Keep unattributed hook deliveries visible in telemetry without rejecting the request. */ +export function trackEmptyPaneKeyHook(body: unknown): void { + if (typeof body !== 'object' || body === null) { + return + } + const paneKey = (body as Record).paneKey + if (typeof paneKey === 'string' && paneKey.trim().length > 0) { + return + } + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) +} diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts new file mode 100644 index 00000000000..913bcd7067e --- /dev/null +++ b/src/main/agent-hooks/server/server-types.ts @@ -0,0 +1,113 @@ +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusClearIpcPayload, + AgentStatusState +} from '../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../shared/agent-status-observation' +import type { AgentKind } from '../../../shared/telemetry-events' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' + +// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). +export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { + receivedAt: number + stateStartedAt: number + /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ + observation?: AgentStatusObservation + /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ + restoredUnconfirmed?: true + /** User-hidden resume identity retained solely for destructive liveness checks. */ + retainedForLiveness?: true + /** Persisted proof that a lead boundary was held working only by child agents. */ + claudeLeadBoundaryChildOnly?: true +} + +export type PersistedAgentHookEventPayload = Omit< + EnrichedAgentHookEventPayload, + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' + // Why: revision counters are in-memory and the authority id is regenerated per process, so + // a stored observation could only rehydrate as a stale ordering claim from a dead authority. + | 'observation' +> & { + launchTokenHash?: string +} + +export type PersistedAgentHookAuthorityCommitment = { + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +} + +export type AgentHookStatusChangeEntry = { + state: AgentStatusState + receivedAt: number + observedInCurrentRuntime: boolean +} + +export type AgentHookProviderSessionIdentity = { + paneKey: string + sessionId: string + transcriptPath?: string + worktreeId?: string +} + +export type AgentHookAuthorityEvidence = Readonly<{ + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +}> + +export type AgentHookAuthorityAttestation = Readonly<{ + paneKey: string + source: 'current_hook' | 'hydrated_commitment' +}> + +export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +export type ProviderSessionChangeListener = ( + providerSessions: AgentHookProviderSessionIdentity[] +) => void +export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void +export type StatusDropListener = (paneKey: string) => void +export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void + +export type PaneKeyAliasEntry = { + stablePaneKey: string + ptyId: string | null + updatedAt: number + authorityVerified: boolean +} +export type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } +/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ +export type RetiredPaneFence = { + paneKeys: readonly string[] + aliases: readonly RetiredPaneAlias[] +} + +export type LastStatusFile = { + version: number + entries: Record + authorityCommitments?: Record +} + +export type AgentPromptSentDedupeEntry = { + agentKind: AgentKind + promptHash: string + promptInteractionKey?: string +} + +export type NormalizedLocalHook = { + event: AgentHookEventPayload | null + onAccepted?: () => void +} + +export type ServerStatusLineListener = ((event: ClaudeStatusLineRateLimits) => void) | null +export type ServerAgentStatusListener = ((payload: EnrichedAgentHookEventPayload) => void) | null diff --git a/src/main/browser/agent-browser-bridge-capture-commands.ts b/src/main/browser/agent-browser-bridge-capture-commands.ts new file mode 100644 index 00000000000..5719bfbc7b6 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-capture-commands.ts @@ -0,0 +1,185 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { BrowserScreenshotResult, BrowserEvalResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { captureFullPageScreenshot } from './cdp-screenshot' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeUtilityCommands } from './agent-browser-bridge-utility-commands' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' + +export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBridgeUtilityCommands { + async screenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) + }, + { ensureVisible: false } + ) + } + + async fullPageScreenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName, target) => { + return this.captureFullPageScreenshotCommand( + sessionName, + target.webContentsId, + 500, + format === 'jpeg' ? 'jpeg' : 'png' + ) + }, + { ensureVisible: false } + ) + } + + private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { + const parsed = raw as { path?: string } | undefined + if (!parsed?.path) { + throw new BrowserError('browser_error', 'Screenshot returned no file path') + } + if (!existsSync(parsed.path)) { + throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) + } + const data = readFileSync(parsed.path).toString('base64') + return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult + } + + private async captureScreenshotCommand( + sessionName: string, + commandArgs: string[], + settleMs: number, + format?: string + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. + await new Promise((r) => setTimeout(r, settleMs)) + const raw = await this.execAgentBrowser(sessionName, commandArgs) + return this.readScreenshotFromResult(raw, format) + } finally { + restore() + } + }) + } + + private async captureFullPageScreenshotCommand( + sessionName: string, + webContentsId: number, + settleMs: number, + format: 'png' | 'jpeg' + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. + await new Promise((r) => setTimeout(r, settleMs)) + const wc = this.getWebContents(webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + return await captureFullPageScreenshot(wc, format) + } catch (error) { + throw new BrowserError('browser_error', (error as Error).message) + } finally { + restore() + } + }) + } + + private async withSerializedScreenshotAccess(execute: () => Promise): Promise { + const previousTurn = this.screenshotTurn.catch(() => {}) + let releaseTurn!: () => void + this.screenshotTurn = new Promise((resolve) => { + releaseTurn = resolve + }) + await previousTurn + try { + return await execute() + } finally { + releaseTurn() + } + } + + async evaluate( + expression: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + let releaseDebugger = (): void => {} + try { + releaseDebugger = acquireElectronDebugger(wc).release + const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true + })) as { + result: { value?: unknown; description?: string } + exceptionDetails?: { text: string; exception?: { description?: string } } + } + if (exceptionDetails) { + throw new BrowserError( + 'browser_eval_error', + exceptionDetails.exception?.description ?? exceptionDetails.text + ) + } + + const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (currentTarget.webContentsId !== target.webContentsId) { + throw new BrowserError( + 'browser_tab_changed', + `Browser page ${target.browserPageId} changed while evaluating; retry the command` + ) + } + return { + result: + result.value !== undefined + ? typeof result.value === 'object' && result.value !== null + ? JSON.stringify(result.value) + : String(result.value) + : (result.description ?? ''), + origin: wc.getURL() + } + } catch (error) { + if (error instanceof BrowserError) { + throw error + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + throw new BrowserError( + 'browser_error', + `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + releaseDebugger() + } + }, + { ensureSession: false } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-core-commands.ts b/src/main/browser/agent-browser-bridge-core-commands.ts new file mode 100644 index 00000000000..aac82f77487 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-core-commands.ts @@ -0,0 +1,186 @@ +import type { + BrowserSnapshotResult, + BrowserClickResult, + BrowserGotoResult, + BrowserFillResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { focusedValueSetExpression } from './agent-browser-bridge-input' +import { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + EMBEDDED_NAVIGATION_TIMEOUT_MS +} from './agent-browser-bridge-types' +import { + isAbortedNavigationError, + waitForAbortedNavigationReplacement +} from './agent-browser-bridge-process' +import { AgentBrowserBridgeQueue } from './agent-browser-bridge-queue' + +export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQueue { + async snapshot(worktreeId?: string, browserPageId?: string): Promise { + // Why: snapshot creates fresh refs so it must bypass the stale-ref guard + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'snapshot' + ])) as BrowserSnapshotResult + return { + ...result, + browserPageId: target.browserPageId + } + }) + } + + async click( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult + }) + } + + async dblclick( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult + }) + } + + async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + const navigationUrl = normalizeBrowserNavigationUrl(url) + if (!navigationUrl) { + throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) + } + const navigationState: { preventUnloadEvent: Electron.Event | null } = { + preventUnloadEvent: null + } + const onWillPreventUnload = (event: Electron.Event): void => { + navigationState.preventUnloadEvent = event + } + wc.on('will-prevent-unload', onWillPreventUnload) + let navigationAborted = false + const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS + let navigationTimeout: ReturnType | null = null + try { + await Promise.race([ + wc.loadURL(navigationUrl), + new Promise((_resolve, reject) => { + navigationTimeout = setTimeout( + () => + reject( + new Error( + `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + EMBEDDED_NAVIGATION_TIMEOUT_MS + ) + navigationTimeout.unref?.() + }) + ]) + } catch (error) { + if (navigationTimeout) { + clearTimeout(navigationTimeout) + navigationTimeout = null + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. + if ( + !isAbortedNavigationError(error) || + (navigationState.preventUnloadEvent !== null && + !navigationState.preventUnloadEvent.defaultPrevented) + ) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } + navigationAborted = true + // Why: a superseding navigation rejects the first load before its replacement has landed. + await waitForAbortedNavigationReplacement( + wc, + target.browserPageId, + Math.max(0, navigationDeadline - Date.now()) + ) + } finally { + wc.removeListener('will-prevent-unload', onWillPreventUnload) + if (navigationTimeout) { + clearTimeout(navigationTimeout) + } + } + + // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. + const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) + const loadError = navigationAborted + ? this.browserManager.getBrowserPageLoadError(target.browserPageId) + : null + if (loadError) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` + ) + } + return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } + }, + { ensureSession: false } + ) + } + + async fill( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(value) + // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + await this.execAgentBrowser(sessionName, ['focus', element]) + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify('')) + ]) + for (const chunk of iterateBrowserTextInsertionChunks( + value, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(chunk), { append: true }) + ]) + } + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) + ]) + return { filled: element } as BrowserFillResult + } + + await this.fillExplicitContentEditable(sessionName, element, value) + return { filled: element } as BrowserFillResult + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-execution.ts b/src/main/browser/agent-browser-bridge-execution.ts new file mode 100644 index 00000000000..65f64b6fb08 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-execution.ts @@ -0,0 +1,263 @@ +import { execFile } from 'node:child_process' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { + focusedRichTextEditExpression, + isExplicitContentEditableResult +} from './agent-browser-bridge-input' +import { + isTabClosedTransportError, + pageUnavailableMessageForSession +} from './agent-browser-bridge-process' +import { translateResult } from './agent-browser-bridge-result' +import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { + STALE_SESSION_CLOSE_TIMEOUT_MS, + type AgentBrowserExecOptions, + type SessionState, + type ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected abstract runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise + + protected requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) + } + return wc + } + + /** + * Notice that the daemon retired itself between two commands. + * + * A replacement daemon still serves the page (every call reasserts `--cdp`) + * but carries none of the session's network routes, so without this the + * interception the caller configured is silently gone (#16367). + */ + protected reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { + if ( + this.agentBrowserIdleTimeoutMs === null || + Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs + ) { + return + } + session.initialized = false + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + } + + protected assertCommandAdmission(): void { + if (this.shutdownStarted) { + throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') + } + } + + protected async execAgentBrowser( + sessionName: string, + commandArgs: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + const session = this.sessions.get(sessionName) + if (!session) { + // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. + throw this.createPageUnavailableError(sessionName) + } + + // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. + if (!this.getWebContents(session.webContentsId)) { + await this.destroySession(sessionName) + throw this.createPageUnavailableError(sessionName) + } + + this.reinitializeIfDaemonIdledOut(sessionName, session) + session.lastCommandAt = Date.now() + + const args = ['--session', sessionName] + const managesInterceptRoutes = + commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') + + const needsInit = !session.initialized + // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. + args.push('--cdp', String(session.proxy.getPort())) + + // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. + for (const commandArg of commandArgs) { + args.push(commandArg) + } + args.push('--json') + + const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) + const translated = translateResult(stdout) + + if (!translated.ok) { + throw this.createCommandError( + sessionName, + translated.error.message, + translated.error.code, + session.webContentsId + ) + } + + // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. + if (needsInit) { + session.initialized = true + + // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. + const pendingPatterns = managesInterceptRoutes + ? undefined + : this.pendingInterceptRestore.get(sessionName) + if (pendingPatterns && pendingPatterns.length > 0) { + this.pendingInterceptRestore.delete(sessionName) + try { + const urlPattern = pendingPatterns[0] ?? '**/*' + await this.runAgentBrowserRaw(sessionName, [ + '--session', + sessionName, + '--cdp', + String(session.proxy.getPort()), + 'network', + 'route', + urlPattern, + '--json' + ]) + session.activeInterceptPatterns = pendingPatterns + } catch { + // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. + } + } + } + + return translated.result + } + + protected async isExplicitContentEditableTarget( + sessionName: string, + element: string + ): Promise { + const result = await this.execAgentBrowser(sessionName, [ + 'get', + 'attr', + element, + 'contenteditable' + ]) + return isExplicitContentEditableResult(result) + } + + protected async fillExplicitContentEditable( + sessionName: string, + element: string, + value: string + ): Promise { + await this.execAgentBrowser(sessionName, ['focus', element]) + // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. + await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { + stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) + }) + } + + protected createPageUnavailableError(sessionName: string): BrowserError { + return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) + } + + protected closeStaleAgentBrowserSession(sessionName: string): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + } else { + resolve() + } + } + + // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. + const timeout = setTimeout(() => { + child?.kill() + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` + ) + ) + }, STALE_SESSION_CLOSE_TIMEOUT_MS) + + try { + child = execFile( + this.agentBrowserBin, + ['--session', sessionName, 'close'], + // Why windowsHide: agent-browser is console-subsystem and Orca's main + // process owns no console, so each spawn gets a fresh visible conhost + // that takes foreground -- keystrokes typed into a terminal at that + // moment land in the black box (#14543). + { + env: this.agentBrowserEnv, + timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, + windowsHide: true + }, + (error) => + finish( + error + ? new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error.message}` + ) + : undefined + ) + ) + } catch (error) { + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` + ) + ) + } + }) + } + + protected createCommandError( + sessionName: string, + message: string, + fallbackCode: string, + webContentsId?: number + ): BrowserError { + // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. + if ( + fallbackCode === 'browser_error' && + isTabClosedTransportError(message) && + this.isSessionTargetClosed(sessionName, webContentsId) + ) { + return this.createPageUnavailableError(sessionName) + } + return new BrowserError(fallbackCode, message) + } + + protected isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { + const session = this.sessions.get(sessionName) + if (!session) { + return true + } + const targetWebContentsId = webContentsId ?? session.webContentsId + return !this.getWebContents(targetWebContentsId) + } +} diff --git a/src/main/browser/agent-browser-bridge-input-commands.ts b/src/main/browser/agent-browser-bridge-input-commands.ts new file mode 100644 index 00000000000..7eef4a1ddf3 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input-commands.ts @@ -0,0 +1,124 @@ +import type { + BrowserTypeResult, + BrowserSelectResult, + BrowserScrollResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCoreCommands } from './agent-browser-bridge-core-commands' + +export abstract class AgentBrowserBridgeInputCommands extends AgentBrowserBridgeCoreCommands { + async type( + input: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(input) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + for (const chunk of iterateBrowserTextInsertionChunks( + input, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) + } + return { typed: true } as BrowserTypeResult + }, + { requireScopedTarget: true } + ) + } + + async select( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'select', + element, + value + ])) as BrowserSelectResult + }) + } + + async scroll( + direction: string, + amount?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['scroll', direction] + if (amount != null) { + args.push(String(amount)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult + }) + } + + async scrollIntoView( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) + }) + } + + async get( + what: string, + selector?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['get', what] + if (selector) { + args.push(selector) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async is( + what: string, + selector: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['is', what, selector]) + }) + } + + // ── Keyboard commands ── + + async keyboardInsertText( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + let result: unknown = { inserted: true } + for (const chunk of iterateBrowserTextInsertionChunks( + text, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) + } + return result + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-input.ts b/src/main/browser/agent-browser-bridge-input.ts new file mode 100644 index 00000000000..cc4869b90bf --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input.ts @@ -0,0 +1,67 @@ +export function focusedValueSetExpression( + valueExpression: string, + options?: { append?: boolean; dispatchEvents?: boolean } +): string { + const nextValue = options?.append + ? ["String(target.value ?? '') + ", valueExpression].join('') + : valueExpression + const dispatchEvents = options?.dispatchEvents + ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" + : '' + return [ + '(() => { const el = document.activeElement; if (el) {', + // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. + " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", + " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", + ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', + ' let target = el;', + " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", + " const controls = target.getAttribute('aria-controls');", + ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', + ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', + ' }', + " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", + ' const nextValue = ', + nextValue, + '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', + dispatchEvents, + ' } })()' + ].join('') +} + +// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. +export function focusedRichTextEditExpression( + valueExpression: string, + options?: { selectAll?: boolean } +): string { + const selectAll = options?.selectAll ? 'true' : 'false' + return [ + '(() => {', + ' const target = document.activeElement;', + ' const value = ', + valueExpression, + ';', + ` const selectAll = ${selectAll};`, + " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", + " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", + ' if (selectAll) {', + " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", + ' const selection = window.getSelection();', + " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", + ' selection.selectAllChildren(target);', + ' }', + " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", + ' let edited = false;', + ' try {', + ' edited = document.execCommand(editCommand, false, value) === true;', + ' } catch { edited = false; }', + " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", + ' })()' + ].join('') +} + +export function isExplicitContentEditableResult(result: unknown): boolean { + const value = + result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined + return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) +} diff --git a/src/main/browser/agent-browser-bridge-interaction-commands.ts b/src/main/browser/agent-browser-bridge-interaction-commands.ts new file mode 100644 index 00000000000..51854878c0b --- /dev/null +++ b/src/main/browser/agent-browser-bridge-interaction-commands.ts @@ -0,0 +1,192 @@ +import type { + BrowserHoverResult, + BrowserDragResult, + BrowserUploadResult, + BrowserWaitResult, + BrowserCheckResult, + BrowserFocusResult, + BrowserClearResult, + BrowserSelectAllResult, + BrowserKeypressResult, + BrowserPdfResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands' + +export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands { + async hover( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult + }) + } + + async drag( + from: string, + to: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult + }) + } + + async upload( + element: string, + filePaths: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'upload', + element, + ...filePaths + ])) as BrowserUploadResult + }) + } + + async wait( + options?: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + }, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['wait'] + const hasCondition = + !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn + if (options?.selector) { + args.push(options.selector) + } else if (options?.timeout != null && !hasCondition) { + args.push(String(options.timeout)) + } + if (options?.text) { + args.push('--text', options.text) + } + if (options?.url) { + args.push('--url', options.url) + } + if (options?.load) { + args.push('--load', options.load) + } + if (options?.fn) { + args.push('--fn', options.fn) + } + const normalizedState = options?.state === 'visible' ? undefined : options?.state + if (normalizedState) { + args.push('--state', normalizedState) + } + // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. + return (await this.execAgentBrowser(sessionName, args, { + timeoutMs: + options?.timeout != null && hasCondition + ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS + : undefined, + timeoutError: + options?.timeout != null && hasCondition + ? new BrowserError( + 'browser_timeout', + `Timed out waiting for browser condition after ${options.timeout}ms.` + ) + : undefined + })) as BrowserWaitResult + }) + } + + async check( + element: string, + checked: boolean, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = checked ? ['check', element] : ['uncheck', element] + return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult + }) + } + + async focus( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult + }) + } + + async clear( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. + await this.execAgentBrowser(sessionName, ['fill', element, '']) + return { cleared: element } + } + + await this.fillExplicitContentEditable(sessionName, element, '') + return { cleared: element } + }, + { requireScopedTarget: true } + ) + } + + async selectAll( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser has no select-all command — implement as focus + Ctrl+A + await this.execAgentBrowser(sessionName, ['focus', element]) + return (await this.execAgentBrowser(sessionName, [ + 'press', + 'Control+a' + ])) as BrowserSelectAllResult + }) + } + + async keypress( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult + }) + } + + async pdf(worktreeId?: string, browserPageId?: string): Promise { + // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + const buffer = await wc.printToPDF({ + printBackground: true, + preferCSSPageSize: true + }) + return { data: buffer.toString('base64') } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-lifecycle.ts b/src/main/browser/agent-browser-bridge-lifecycle.ts new file mode 100644 index 00000000000..c3ec6b170e9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-lifecycle.ts @@ -0,0 +1,266 @@ +import { CdpWsProxy } from './cdp-ws-proxy' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeRawProcess } from './agent-browser-bridge-raw-process' +import type { AgentBrowserCleanupOptions } from './agent-browser-bridge-types' +import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawProcess { + async onTabClosed(webContentsId: number): Promise { + const browserPageId = this.resolveTabIdSafe(webContentsId) + const owningWorktreeId = browserPageId + ? this.browserManager.getWorktreeIdForTab(browserPageId) + : undefined + let nextWorktreeActiveWebContentsId: number | null = null + if ( + owningWorktreeId && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId + ) { + nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( + owningWorktreeId, + webContentsId + ) + } + if (this.activeWebContentsId === webContentsId) { + this.activeWebContentsId = nextWorktreeActiveWebContentsId + } + if (browserPageId) { + await this.onPageClosed(browserPageId) + } + this.options.onTabsChanged?.(owningWorktreeId) + } + + /** + * Retire a page's daemon by page id. + * + * The headless offscreen backend owns pages by id and unregisters the guest + * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it + * has to say which page closed (#16367). + */ + async onPageClosed(browserPageId: string): Promise { + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + await this.destroySession(sessionName) + this.pendingInterceptRestore.delete(sessionName) + } + + async onProcessSwap( + browserPageId: string, + newWebContentsId: number, + previousWebContentsId?: number + ): Promise { + // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + const session = this.sessions.get(sessionName) + const oldWebContentsId = previousWebContentsId ?? session?.webContentsId + const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) + // Why: save intercept patterns before destroy so the new session can restore them after init. + if (session && session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + await this.destroySession(sessionName) + if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { + this.activeWebContentsId = newWebContentsId + } + if ( + owningWorktreeId && + oldWebContentsId != null && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId + ) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + } + protected async ensureSession( + sessionName: string, + browserPageId: string, + webContentsId: number + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + } + this.assertCommandAdmission() + + if (this.sessions.has(sessionName)) { + return + } + + // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. + const pending = this.pendingSessionCreation.get(sessionName) + if (pending) { + await pending + this.assertCommandAdmission() + return + } + + const createSession = async (): Promise => { + const wc = this.getWebContents(webContentsId) + if (!wc) { + // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. + await this.closeStaleAgentBrowserSession(sessionName) + + const proxy = new CdpWsProxy(wc) + const cdpEndpoint = await proxy.start() + + this.sessions.set(sessionName, { + proxy, + cdpEndpoint, + initialized: false, + consecutiveTimeouts: 0, + activeInterceptPatterns: [], + activeCapture: false, + lastCommandAt: Date.now(), + webContentsId, + activeProcess: null + }) + } + + const promise = createSession() + this.pendingSessionCreation.set(sessionName, promise) + try { + await promise + } finally { + this.pendingSessionCreation.delete(sessionName) + } + } + + protected async restartSessionForTarget( + sessionName: string, + browserPageId: string, + webContentsId: number, + options: { recreate: boolean } = { recreate: true } + ): Promise { + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + await pendingCreation.catch(() => {}) + } + + const session = this.sessions.get(sessionName) + if (session) { + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + if (session.activeProcess) { + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { + timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS + }) + } catch { + // Session may already be dead. + } + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + if (options.recreate) { + await this.ensureSession(sessionName, browserPageId, webContentsId) + } + } + + protected async destroySession( + sessionName: string, + options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + return + } + + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. + try { + await pendingCreation + } catch { + // Creation failures are handled by the original caller; teardown still rejects queued work below. + } + } + + const session = this.sessions.get(sessionName) + if (!session) { + this.rejectQueuedCommandsForClosedSession(sessionName) + return + } + + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + + // Why: queued commands would hang forever if we just delete the queue — drain and reject them. + this.rejectQueuedCommandsForClosedSession(sessionName) + + if (session.activeProcess) { + // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + // Why: each tab has its own named session — close without --session leaves this tab's daemon running. + // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. + await this.runAgentBrowserRaw( + sessionName, + ['--session', sessionName, 'close'], + options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } + ) + } catch { + // Session may already be dead + } + + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + protected rejectQueuedCommandsForClosedSession(sessionName: string): void { + const queue = this.commandQueues.get(sessionName) + this.commandQueues.delete(sessionName) + this.processingQueues.delete(sessionName) + if (queue) { + const err = new BrowserError( + 'browser_tab_closed', + 'Tab was closed while commands were queued' + ) + for (const cmd of queue) { + cmd.reject(err) + } + queue.length = 0 + } + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse-commands.ts b/src/main/browser/agent-browser-bridge-mouse-commands.ts new file mode 100644 index 00000000000..36697978748 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse-commands.ts @@ -0,0 +1,206 @@ +import type { BrowserMouseModifier } from './agent-browser-bridge-types' +import { BrowserError } from './cdp-bridge' +import { + normalizeCdpMouseButton, + cdpMouseButtonMask, + cdpMouseModifierMask, + resolveMobileTouchClickPoint +} from './agent-browser-bridge-mouse' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeInputCommands } from './agent-browser-bridge-input-commands' + +export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridgeInputCommands { + // ── Mouse commands ── + + async mouseMove( + x: number, + y: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) + }) + } + + async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'down'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseClick( + x: number, + y: number, + button?: string, + worktreeId?: string, + browserPageId?: string, + radius?: number, + modifiers?: BrowserMouseModifier[] + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${target.browserPageId} is no longer available` + ) + } + const cdpButton = normalizeCdpMouseButton(button) + const buttons = cdpMouseButtonMask(cdpButton) + const cdpModifiers = cdpMouseModifierMask(modifiers) + const lease = acquireElectronDebugger(wc) + try { + wc.focus() + const point = + cdpButton === 'left' + ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. + await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) + : { x, y, adjusted: false, handled: false } + // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. + // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. + if (!point.handled) { + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: cdpButton, + buttons, + modifiers: cdpModifiers, + clickCount: 1 + }) + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: cdpButton, + buttons: 0, + modifiers: cdpModifiers, + clickCount: 1 + }) + } + return { + clicked: { + x: point.x, + y: point.y, + button: cdpButton, + adjusted: point.adjusted, + handled: point.handled + } + } + } finally { + lease.release() + } + }, + { ensureSession: false } + ) + } + + async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'up'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseWheel( + dy: number, + dx?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'wheel', String(dy)] + if (dx != null) { + args.push(String(dx)) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Find (semantic locators) ── + + async find( + locator: string, + value: string, + action: string, + text?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['find', locator, value, action] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Set commands ── + + async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'device', name]) + }) + } + + async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'offline'] + if (state) { + args.push(state) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async setHeaders( + headersJson: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) + }) + } + + async setCredentials( + user: string, + pass: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) + }) + } + + async setMedia( + colorScheme?: string, + reducedMotion?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'media'] + if (colorScheme) { + args.push(colorScheme) + } + if (reducedMotion) { + args.push(reducedMotion) + } + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse.ts b/src/main/browser/agent-browser-bridge-mouse.ts new file mode 100644 index 00000000000..db2ba55d150 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse.ts @@ -0,0 +1,195 @@ +import type { WebContents } from 'electron' +import type { BrowserMouseModifier } from './agent-browser-bridge-types' + +type CdpMouseButton = 'left' | 'middle' | 'right' + +type BrowserClickPoint = { + x: number + y: number + adjusted: boolean + handled: boolean +} + +export function normalizeCdpMouseButton(button?: string): CdpMouseButton { + return button === 'middle' || button === 'right' ? button : 'left' +} + +export function cdpMouseButtonMask(button: CdpMouseButton): number { + if (button === 'right') { + return 2 + } + if (button === 'middle') { + return 4 + } + return 1 +} + +export function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { + if (!modifiers || modifiers.length === 0) { + return 0 + } + let mask = 0 + for (const modifier of modifiers) { + if (modifier === 'alt') { + mask |= 1 + } else if (modifier === 'ctrl') { + mask |= 2 + } else if (modifier === 'cmd') { + mask |= 4 + } else if (modifier === 'shift') { + mask |= 8 + } + } + return mask +} + +export function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { + const point = value && typeof value === 'object' ? (value as Record) : null + const x = point?.x + const y = point?.y + if ( + typeof x !== 'number' || + !Number.isFinite(x) || + typeof y !== 'number' || + !Number.isFinite(y) + ) { + return fallback + } + return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } +} + +export function mobileTouchClickExpression( + x: number, + y: number, + radius: number, + allowDomActivation: boolean +): string { + return `(() => { + const inputX = ${JSON.stringify(x)}; + const inputY = ${JSON.stringify(y)}; + const radius = ${JSON.stringify(radius)}; + const allowDomActivation = ${JSON.stringify(allowDomActivation)}; + const selector = [ + 'a[href]', + 'button', + 'input', + 'textarea', + 'select', + 'summary', + 'label', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="tab"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[onclick]', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + const isUsable = (el) => { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && + style.visibility !== 'hidden' && style.pointerEvents !== 'none'; + }; + const dispatchClick = (target, clickX, clickY) => { + try { + if (typeof target.focus === 'function') { + target.focus({ preventScroll: true }); + } + } catch { + try { target.focus(); } catch {} + } + if (typeof target.click === 'function') { + target.click(); + return true; + } + const init = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX: clickX, + clientY: clickY, + screenX: clickX, + screenY: clickY, + button: 0, + buttons: 1 + }; + try { + if (typeof PointerEvent === 'function') { + target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); + target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); + } + } catch {} + target.dispatchEvent(new MouseEvent('mousedown', init)); + target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); + target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); + return true; + }; + const clickableFor = (el) => { + for (let node = el; node && node.nodeType === 1; node = node.parentElement) { + if (node.matches(selector)) return node; + if (window.getComputedStyle(node).cursor === 'pointer') return node; + } + return null; + }; + const offsets = [[0, 0]]; + for (const distance of [radius * 0.45, radius, radius * 1.35]) { + for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, + Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { + offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); + } + } + let best = null; + for (const [dx, dy] of offsets) { + const px = inputX + dx; + const py = inputY + dy; + if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; + for (const el of document.elementsFromPoint(px, py)) { + const target = clickableFor(el); + if (!target || !isUsable(target)) continue; + const rect = target.getBoundingClientRect(); + const clickX = clamp(inputX, rect.left + 1, rect.right - 1); + const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); + const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; + if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; + break; + } + } + if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { + return { x: best.x, y: best.y, adjusted: true, handled: true }; + } + if (best) { + return { x: best.x, y: best.y, adjusted: true, handled: false }; + } + return { x: inputX, y: inputY, adjusted: false, handled: false }; + })()` +} + +export async function resolveMobileTouchClickPoint( + dbg: WebContents['debugger'], + x: number, + y: number, + radius: number | undefined, + allowDomActivation: boolean +): Promise { + const fallback = { x, y, adjusted: false, handled: false } + if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { + return fallback + } + try { + const result = await dbg.sendCommand('Runtime.evaluate', { + expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), + returnByValue: true, + silent: true + }) + const raw = result && typeof result === 'object' ? (result as Record) : null + const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null + return readClickPoint((evaluated as Record | null)?.value, fallback) + } catch { + return fallback + } +} diff --git a/src/main/browser/agent-browser-bridge-process.ts b/src/main/browser/agent-browser-bridge-process.ts new file mode 100644 index 00000000000..363721099d9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-process.ts @@ -0,0 +1,183 @@ +import { app } from 'electron' +import { existsSync, accessSync, chmodSync, constants } from 'node:fs' +import { join } from 'node:path' +import { platform, arch } from 'node:os' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types' + +export function agentBrowserNativeName(): string { + const ext = process.platform === 'win32' ? '.exe' : '' + return `agent-browser-${platform()}-${arch()}${ext}` +} + +export function resolveAgentBrowserBinary(): string { + // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. + const bundledResourcesPath = + process.resourcesPath ?? + (process.platform === 'darwin' + ? join(app.getPath('exe'), '..', '..', 'Resources') + : join(app.getPath('exe'), '..', 'resources')) + const bundled = join(bundledResourcesPath, agentBrowserNativeName()) + if (existsSync(bundled)) { + return bundled + } + + // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. + const nmBin = join( + app.getAppPath(), + 'node_modules', + 'agent-browser', + 'bin', + agentBrowserNativeName() + ) + if (existsSync(nmBin)) { + if (process.platform !== 'win32') { + try { + accessSync(nmBin, constants.X_OK) + } catch { + chmodSync(nmBin, 0o755) + } + } + return nmBin + } + + // Last resort: assume it's on PATH + return 'agent-browser' +} + +// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. +export function parseShellArgs(input: string): string[] { + const args: string[] = [] + let current = '' + let inDouble = false + let inSingle = false + + for (let i = 0; i < input.length; i++) { + const ch = input[i] + if (ch === '"' && !inSingle) { + inDouble = !inDouble + } else if (ch === "'" && !inDouble) { + inSingle = !inSingle + } else if (ch === ' ' && !inDouble && !inSingle) { + if (current) { + args.push(current) + current = '' + } + } else { + current += ch + } + } + if (current) { + args.push(current) + } + return args +} + +export function stripAgentBrowserTargetArgs(args: string[]): string[] { + const stripped: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--cdp' || arg === '--session') { + index++ + continue + } + if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { + continue + } + stripped.push(arg) + } + return stripped +} + +// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. +export function classifyErrorCode(message: string): string { + if (/unknown ref|ref not found|element not found: @e/i.test(message)) { + return 'browser_stale_ref' + } + return 'browser_error' +} + +export function isAbortedNavigationError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const { code, errno } = error as { code?: unknown; errno?: unknown } + return code === 'ERR_ABORTED' || errno === -3 +} + +export function isWebContentsLoading(wc: WebContents): boolean { + try { + return wc.isLoading() + } catch { + // Why: destruction races are resolved against the authoritative page registration after the wait. + return false + } +} + +export function waitForAbortedNavigationReplacement( + wc: WebContents, + browserPageId: string, + timeoutMs: number +): Promise { + if (!isWebContentsLoading(wc)) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType | null = null + const finish = (error?: BrowserError): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-stop-loading', onDidStopLoading) + wc.removeListener('destroyed', onDestroyed) + if (timeout) { + clearTimeout(timeout) + } + if (error) { + reject(error) + } else { + resolve() + } + } + const onDidStopLoading = (): void => finish() + const onDestroyed = (): void => finish() + + wc.on('did-stop-loading', onDidStopLoading) + wc.on('destroyed', onDestroyed) + timeout = setTimeout( + () => + finish( + new BrowserError( + 'browser_error', + `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + timeoutMs + ) + timeout.unref?.() + + // Why: the replacement can finish between loadURL rejecting and listener attachment. + if (!isWebContentsLoading(wc)) { + finish() + } + }) +} + +export function isTabClosedTransportError(message: string): boolean { + return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( + message + ) +} + +export function pageUnavailableMessageForSession(sessionName: string): string { + const prefix = ORCA_TAB_SESSION_PREFIX + const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null + return browserPageId + ? `Browser page ${browserPageId} is no longer available` + : 'Browser tab is no longer available' +} diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts new file mode 100644 index 00000000000..74cdb50c14a --- /dev/null +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -0,0 +1,174 @@ +import type { BrowserTabSwitchResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { AgentBrowserBridgeShutdown } from './agent-browser-bridge-shutdown' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import type { + EnqueueTargetedCommandOptions, + ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown { + // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. + async tabSwitch( + index: number | undefined, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueCommand(worktreeId, async () => { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. + const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) + let switchedIndex = index ?? -1 + let resolvedPageId = browserPageId + if (resolvedPageId) { + switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) + } + if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { + const targetLabel = + resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` + throw new BrowserError( + 'browser_tab_not_found', + `${targetLabel} out of range (0-${liveEntries.length - 1})` + ) + } + const [tabId, wcId] = liveEntries[switchedIndex] + this.activeWebContentsId = wcId + // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. + const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) + // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. + if (owningWorktreeId) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + return { switched: switchedIndex, browserPageId: tabId } + }) + } + // ── Internal ── + + protected async enqueueCommand( + worktreeId: string | undefined, + execute: (sessionName: string) => Promise + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + undefined, + async (sessionName) => execute(sessionName), + { ensureVisible: false } + ) + } + + protected async enqueueTargetedCommand( + worktreeId: string | undefined, + browserPageId: string | undefined, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions = {} + ): Promise { + this.assertCommandAdmission() + const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + + if (options.ensureSession !== false) { + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + } + this.assertCommandAdmission() + + return new Promise((resolve, reject) => { + let queue = this.commandQueues.get(sessionName) + if (!queue) { + queue = [] + this.commandQueues.set(sessionName, queue) + } + queue.push({ + execute: (() => + this.executeWithVisibleTarget( + sessionName, + worktreeId, + target, + execute, + options + )) as () => Promise, + resolve: resolve as (value: unknown) => void, + reject + }) + this.processQueue(sessionName) + }) + } + + protected async executeWithVisibleTarget( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions + ): Promise { + if (options.ensureVisible === false) { + return execute(sessionName, target) + } + + // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. + const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) + try { + const visibleTarget = await this.refreshTargetAfterAutomationVisibility( + sessionName, + worktreeId, + target, + options + ) + return await execute(sessionName, visibleTarget) + } finally { + restore() + } + } + + protected async refreshTargetAfterAutomationVisibility( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + options: EnqueueTargetedCommandOptions + ): Promise { + const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (visibleTarget.webContentsId === target.webContentsId) { + return visibleTarget + } + + if (this.activeWebContentsId === target.webContentsId) { + this.activeWebContentsId = visibleTarget.webContentsId + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { + this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) + } + + // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. + await this.restartSessionForTarget( + sessionName, + visibleTarget.browserPageId, + visibleTarget.webContentsId, + { recreate: options.ensureSession !== false } + ) + + return visibleTarget + } + + protected async processQueue(sessionName: string): Promise { + if (this.processingQueues.has(sessionName)) { + return + } + this.processingQueues.add(sessionName) + + const queue = this.commandQueues.get(sessionName) + while (queue && queue.length > 0) { + const cmd = queue.shift()! + try { + const result = await cmd.execute() + cmd.resolve(result) + } catch (error) { + cmd.reject(error) + } + } + + if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { + this.commandQueues.delete(sessionName) + } + this.processingQueues.delete(sessionName) + } +} diff --git a/src/main/browser/agent-browser-bridge-raw-process.ts b/src/main/browser/agent-browser-bridge-raw-process.ts new file mode 100644 index 00000000000..bdc5aadbbd9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-raw-process.ts @@ -0,0 +1,108 @@ +import { execFile, type ChildProcess } from 'node:child_process' +import { BrowserError } from './cdp-bridge' +import { classifyErrorCode } from './agent-browser-bridge-process' +import { AgentBrowserBridgeExecution } from './agent-browser-bridge-execution' +import { + CONSECUTIVE_TIMEOUT_LIMIT, + EXEC_TIMEOUT_MS, + type AgentBrowserExecOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExecution { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + return new Promise((resolve, reject) => { + const session = this.sessions.get(sessionName) + let child: ChildProcess | null = null + child = execFile( + this.agentBrowserBin, + args, + // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). + { + timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, + maxBuffer: 50 * 1024 * 1024, + // Why windowsHide: see the stale-session close above -- every + // agent-browser invocation would otherwise flash a console (#14543). + windowsHide: true, + env: execOptions?.envOverrides + ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } + : this.agentBrowserEnv + }, + (error, stdout, stderr) => { + if (session && session.activeProcess === child) { + session.activeProcess = null + } + if (child && this.cancelledProcesses.has(child)) { + this.cancelledProcesses.delete(child) + reject( + new BrowserError('browser_tab_closed', 'Tab was closed while command was running') + ) + return + } + + const liveSession = this.sessions.get(sessionName) + + if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { + if (execOptions?.timeoutError) { + reject(execOptions.timeoutError) + return + } + if (liveSession) { + liveSession.consecutiveTimeouts++ + if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { + // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate + this.destroySession(sessionName) + } + } + reject(new BrowserError('browser_error', 'Browser command timed out')) + return + } + + if (liveSession) { + liveSession.consecutiveTimeouts = 0 + } + + if (error) { + // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. + if (stdout) { + try { + const parsed = JSON.parse(stdout) + if (parsed.error) { + const code = classifyErrorCode(parsed.error) + reject( + this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) + ) + return + } + } catch { + // stdout not valid JSON — fall through to stderr/error.message + } + } + const message = stderr || error.message + const code = classifyErrorCode(message) + reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) + return + } + + resolve(stdout) + } + ) + if (session) { + session.activeProcess = child + } + if (execOptions?.stdinText !== undefined && child?.stdin) { + // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. + child.stdin.on('error', () => {}) + child.stdin.end(execOptions.stdinText) + } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-result.ts b/src/main/browser/agent-browser-bridge-result.ts new file mode 100644 index 00000000000..9366389c2fd --- /dev/null +++ b/src/main/browser/agent-browser-bridge-result.ts @@ -0,0 +1,29 @@ +import { classifyErrorCode } from './agent-browser-bridge-process' + +export function translateResult( + stdout: string +): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { + let parsed: { success?: boolean; data?: unknown; error?: string } + try { + parsed = JSON.parse(stdout) + } catch { + return { + ok: false, + error: { + code: 'browser_error', + message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` + } + } + } + if (parsed.success) { + return { ok: true, result: parsed.data } + } + const message = parsed.error ?? 'Unknown browser error' + return { + ok: false, + error: { + code: classifyErrorCode(message), + message + } + } +} diff --git a/src/main/browser/agent-browser-bridge-shutdown.ts b/src/main/browser/agent-browser-bridge-shutdown.ts new file mode 100644 index 00000000000..4b53cc74df8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-shutdown.ts @@ -0,0 +1,40 @@ +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' +import { sweepOrphanedAgentBrowserSessions } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeLifecycle } from './agent-browser-bridge-lifecycle' +import { + AGENT_BROWSER_CLEANUP_CONCURRENCY, + type AgentBrowserCleanupOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeShutdown extends AgentBrowserBridgeLifecycle { + // ── Session lifecycle ── + + // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with + // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. + async sweepOrphanedSessions(): Promise { + return sweepOrphanedAgentBrowserSessions({ + binaryPath: this.agentBrowserBin, + env: this.agentBrowserEnv, + ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, + isSessionLive: (sessionName) => + this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) + }) + } + + async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { + this.shutdownStarted = true + // Why the union: a session still being created has already spawned its daemon but is not in + // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). + const sessionNames = new Set([ + ...this.sessions.keys(), + ...this.pendingSessionCreation.keys(), + ...this.pendingSessionDestruction.keys() + ]) + await mapSettledWithConcurrency( + [...sessionNames], + AGENT_BROWSER_CLEANUP_CONCURRENCY, + (sessionName) => this.destroySession(sessionName, options) + ) + this.pendingInterceptRestore.clear() + } +} diff --git a/src/main/browser/agent-browser-bridge-state-commands.ts b/src/main/browser/agent-browser-bridge-state-commands.ts new file mode 100644 index 00000000000..4e03055da57 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state-commands.ts @@ -0,0 +1,266 @@ +import type { + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserCookieDeleteResult, + BrowserCookie, + BrowserViewportResult, + BrowserGeolocationResult, + BrowserInterceptEnableResult, + BrowserInterceptDisableResult, + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleResult, + BrowserNetworkLogResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { parseShellArgs, stripAgentBrowserTargetArgs } from './agent-browser-bridge-process' +import { AgentBrowserBridgeInteractionCommands } from './agent-browser-bridge-interaction-commands' + +export abstract class AgentBrowserBridgeStateCommands extends AgentBrowserBridgeInteractionCommands { + // ── Cookie commands ── + + async cookieGet( + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'cookies', + 'get' + ])) as BrowserCookieGetResult + }) + } + + async cookieSet( + cookie: Partial, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] + if (cookie.domain) { + args.push('--domain', cookie.domain) + } + if (cookie.path) { + args.push('--path', cookie.path) + } + if (cookie.secure) { + args.push('--secure') + } + if (cookie.httpOnly) { + args.push('--httpOnly') + } + if (cookie.sameSite) { + args.push('--sameSite', cookie.sameSite) + } + if (cookie.expires != null) { + args.push('--expires', String(cookie.expires)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult + }) + } + + async cookieDelete( + name?: string, + domain?: string, + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'clear'] + if (name) { + args.push('--name', name) + } + if (domain) { + args.push('--domain', domain) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult + }) + } + + // ── Viewport / emulation commands ── + + async setViewport( + width: number, + height: number, + scale = 1, + mobile = false, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + const dbg = wc.debugger + if (!dbg.isAttached()) { + throw new BrowserError('browser_error', 'Debugger not attached') + } + + // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: scale, + mobile + }) + // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. + await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( + () => {} + ) + + return { + width, + height, + deviceScaleFactor: scale, + mobile + } + }) + } + + async setGeolocation( + lat: number, + lon: number, + _accuracy?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'set', + 'geo', + String(lat), + String(lon) + ])) as BrowserGeolocationResult + }) + } + + // ── Network interception commands ── + + async interceptEnable( + patterns?: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser uses "network route " to intercept. Route each pattern individually. + const urlPattern = patterns?.[0] ?? '**/*' + const args = ['network', 'route', urlPattern] + const result = (await this.execAgentBrowser( + sessionName, + args + )) as BrowserInterceptEnableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = patterns ?? ['*'] + } + return result + }) + } + + async interceptDisable( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'unroute' + ])) as BrowserInterceptDisableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = [] + } + return result + }) + } + + async interceptList( + worktreeId?: string, + browserPageId?: string + ): Promise<{ requests: unknown[] }> { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { + requests: unknown[] + } + }) + } + + // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. + + // ── Capture commands ── + + async captureStart( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'start' + ])) as BrowserCaptureStartResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = true + } + return result + }) + } + + async captureStop( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'stop' + ])) as BrowserCaptureStopResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = false + } + return result + }) + } + + async consoleLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult + }) + } + + async networkLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'network', + 'requests' + ])) as BrowserNetworkLogResult + }) + } + + // ── Generic passthrough ── + + async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. + const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-state.ts b/src/main/browser/agent-browser-bridge-state.ts new file mode 100644 index 00000000000..c23cd8e5ac5 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state.ts @@ -0,0 +1,64 @@ +import { app } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { BrowserManager } from './browser-manager' +import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' +import { resolveAgentBrowserBinary } from './agent-browser-bridge-process' +import type { + AgentBrowserBridgeOptions, + QueuedCommand, + SessionState +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeState { + // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. + protected readonly activeWebContentsPerWorktree = new Map() + protected activeWebContentsId: number | null = null + protected readonly sessions = new Map() + protected readonly commandQueues = new Map() + protected readonly processingQueues = new Set() + // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. + protected screenshotTurn: Promise = Promise.resolve() + protected readonly agentBrowserBin: string + protected readonly agentBrowserEnv: NodeJS.ProcessEnv + protected readonly ownsAgentBrowserSocketDirectory: boolean + // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. + protected readonly agentBrowserIdleTimeoutMs: number | null + // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. + protected readonly pendingInterceptRestore = new Map() + // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. + protected readonly pendingSessionCreation = new Map>() + // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. + protected readonly pendingSessionDestruction = new Map>() + protected readonly cancelledProcesses = new WeakSet() + protected shutdownStarted = false + + constructor( + protected readonly browserManager: BrowserManager, + protected readonly options: AgentBrowserBridgeOptions = {} + ) { + this.agentBrowserBin = resolveAgentBrowserBinary() + const processEnvironment = createAgentBrowserProcessEnvironment({ + inheritedEnv: process.env, + platform: process.platform, + userDataPath: app.getPath('userData') + }) + this.agentBrowserEnv = processEnvironment.env + this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory + const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) + this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null + } + + protected resolveTabIdSafe(webContentsId: number): string | null { + return this.browserManager.getTabIdForWebContentsId(webContentsId) + } + + protected getWebContents(webContentsId: number): Electron.WebContents | null { + try { + const { webContents } = require('electron') + const target = webContents.fromId(webContentsId) + return target && !target.isDestroyed() ? target : null + } catch { + return null + } + } +} diff --git a/src/main/browser/agent-browser-bridge-tabs.ts b/src/main/browser/agent-browser-bridge-tabs.ts new file mode 100644 index 00000000000..0af381f6d96 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-tabs.ts @@ -0,0 +1,232 @@ +import type { BrowserTabInfo, BrowserTabListResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import type { ResolvedBrowserCommandTarget } from './agent-browser-bridge-types' +import { AgentBrowserBridgeState } from './agent-browser-bridge-state' + +export abstract class AgentBrowserBridgeTabs extends AgentBrowserBridgeState { + // ── Tab tracking ── + + setActiveTab(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + + protected selectFallbackActiveWebContents( + worktreeId: string, + excludedWebContentsId?: number + ): number | null { + for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { + if (wcId === excludedWebContentsId) { + continue + } + if (this.getWebContents(wcId)) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + return wcId + } + } + this.activeWebContentsPerWorktree.delete(worktreeId) + return null + } + + getActiveWebContentsId(): number | null { + return this.activeWebContentsId + } + + getPageInfo( + worktreeId?: string, + browserPageId?: string + ): { browserPageId: string; url: string; title: string } | null { + try { + const target = this.resolveCommandTarget(worktreeId, browserPageId) + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + return null + } + return { + browserPageId: target.browserPageId, + url: wc.getURL() ?? '', + title: wc.getTitle() ?? '' + } + } catch { + return null + } + } + onTabChanged(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + getRegisteredTabs(worktreeId?: string): Map { + const all = this.browserManager.getWebContentsIdByTabId() + if (!worktreeId) { + return all + } + + const filtered = new Map() + for (const [tabId, wcId] of all) { + if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { + filtered.set(tabId, wcId) + } + } + return filtered + } + + // ── Tab management ── + + tabList(worktreeId?: string): BrowserTabListResult { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. + let activeWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + const result: BrowserTabInfo[] = [] + let index = 0 + let firstLiveWcId: number | null = null + for (const [tabId, wcId] of tabs) { + const wc = this.getWebContents(wcId) + if (!wc) { + this.browserManager.unregisterGuest(tabId) + continue + } + if (firstLiveWcId === null) { + firstLiveWcId = wcId + } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) + result.push({ + browserPageId: tabId, + index: index++, + // Why: failed WebContents report chrome-error://, not the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', + title: wc.getTitle() ?? '', + active: wcId === activeWcId, + loadError, + certificateFailure + }) + } + // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. + if (activeWcId == null && firstLiveWcId !== null) { + activeWcId = firstLiveWcId + if (result.length > 0) { + result[0].active = true + } + } + return { tabs: result } + } + getActivePageId(worktreeId?: string, browserPageId?: string): string | null { + try { + return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId + } catch { + return null + } + } + + protected resolveCommandTarget( + worktreeId?: string, + browserPageId?: string, + requireScopedTarget = false + ): ResolvedBrowserCommandTarget { + if (!browserPageId) { + return requireScopedTarget + ? this.resolveScopedActiveTab(worktreeId) + : this.resolveActiveTab(worktreeId) + } + + const tabs = this.getRegisteredTabs(worktreeId) + const webContentsId = tabs.get(browserPageId) + if (webContentsId == null) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + + if (!this.getWebContents(webContentsId)) { + this.browserManager.unregisterGuest(browserPageId) + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + return { browserPageId, webContentsId } + } + + protected resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + const tabs = this.getRegisteredTabs(worktreeId) + + if (tabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + + // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. + const preferredWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + + if (preferredWcId != null) { + for (const [tabId, wcId] of tabs) { + if (wcId === preferredWcId && this.getWebContents(wcId)) { + return { browserPageId: tabId, webContentsId: wcId } + } + if (wcId === preferredWcId) { + this.browserManager.unregisterGuest(tabId) + if (this.activeWebContentsId === wcId) { + this.activeWebContentsId = null + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { + this.activeWebContentsPerWorktree.delete(worktreeId) + } + } + } + } + + // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. + for (const [tabId, wcId] of tabs) { + if (this.getWebContents(wcId)) { + this.activeWebContentsId = wcId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + } + return { browserPageId: tabId, webContentsId: wcId } + } + this.browserManager.unregisterGuest(tabId) + } + + throw new BrowserError( + 'browser_no_tab', + 'No live browser tab available — all registered tabs have been destroyed' + ) + } + + // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. + protected resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + if (worktreeId) { + return this.resolveActiveTab(worktreeId) + } + + const worktreesWithLiveTabs = new Set() + for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { + if (this.getWebContents(wcId)) { + worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) + } + } + + if (worktreesWithLiveTabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + if (worktreesWithLiveTabs.size > 1) { + throw new BrowserError( + 'browser_target_ambiguous', + 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' + ) + } + + const [onlyWorktreeId] = worktreesWithLiveTabs + return this.resolveActiveTab(onlyWorktreeId) + } +} diff --git a/src/main/browser/agent-browser-bridge-types.ts b/src/main/browser/agent-browser-bridge-types.ts new file mode 100644 index 00000000000..a89c9fcbca8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-types.ts @@ -0,0 +1,65 @@ +import type { ChildProcess } from 'node:child_process' +import type { CdpWsProxy } from './cdp-ws-proxy' +import type { BrowserError } from './cdp-bridge' + +// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. +export const EXEC_TIMEOUT_MS = 90_000 +export const CONSECUTIVE_TIMEOUT_LIMIT = 3 +export const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 +export const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. +export const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 +export const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 +export const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 +export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 +export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + +export type SessionState = { + proxy: CdpWsProxy + cdpEndpoint: string + initialized: boolean + consecutiveTimeouts: number + // Why: track active interception patterns so they can be re-enabled after session restart + activeInterceptPatterns: string[] + activeCapture: boolean + // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. + lastCommandAt: number + // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. + webContentsId: number + activeProcess: ChildProcess | null +} + +export type QueuedCommand = { + execute: () => Promise + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + +export type ResolvedBrowserCommandTarget = { + browserPageId: string + webContentsId: number +} + +export type AgentBrowserCleanupOptions = { + closeTimeoutMs?: number +} + +export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' + +export type AgentBrowserExecOptions = { + envOverrides?: NodeJS.ProcessEnv + timeoutMs?: number + timeoutError?: BrowserError + stdinText?: string +} + +export type EnqueueTargetedCommandOptions = { + ensureSession?: boolean + ensureVisible?: boolean + // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). + requireScopedTarget?: boolean +} + +export type AgentBrowserBridgeOptions = { + onTabsChanged?: (worktreeId?: string) => void +} diff --git a/src/main/browser/agent-browser-bridge-utility-commands.ts b/src/main/browser/agent-browser-bridge-utility-commands.ts new file mode 100644 index 00000000000..f3697168cd8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-utility-commands.ts @@ -0,0 +1,175 @@ +import { BrowserError } from './cdp-bridge' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES } from './agent-browser-bridge-types' +import type { BrowserBackResult, BrowserReloadResult } from '../../shared/runtime-types' +import { AgentBrowserBridgeMouseCommands } from './agent-browser-bridge-mouse-commands' + +export abstract class AgentBrowserBridgeUtilityCommands extends AgentBrowserBridgeMouseCommands { + // ── Clipboard commands ── + + async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) + }) + } + + async clipboardWrite( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text, { + maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + }) + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) + }) + } + + // ── Dialog commands ── + + async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['dialog', 'accept'] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) + }) + } + + // ── Storage commands ── + + async storageLocalGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) + }) + } + + async storageLocalSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) + }) + } + + async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) + }) + } + + async storageSessionGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) + }) + } + + async storageSessionSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) + }) + } + + async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) + }) + } + + // ── Download command ── + + async download( + selector: string, + path: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['download', selector, path]) + }) + } + + // ── Highlight command ── + + async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['highlight', selector]) + }) + } + + async back(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult + }) + } + + async forward(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult + }) + } + + async reload(worktreeId?: string, browserPageId?: string): Promise { + // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + wc.reload() + await new Promise((resolve) => { + let settled = false + let fallbackTimer: ReturnType | null = null + + const finish = (): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-finish-load', onFinish) + wc.removeListener('did-fail-load', onFail) + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + resolve() + } + const onFinish = (): void => finish() + const onFail = (): void => finish() + + wc.on('did-finish-load', onFinish) + wc.on('did-fail-load', onFail) + // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. + fallbackTimer = setTimeout(finish, 10_000) + if (typeof fallbackTimer.unref === 'function') { + fallbackTimer.unref() + } + }) + return { url: wc.getURL(), title: wc.getTitle() } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 01f77b45451..aeeb64385a3 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -1,2884 +1,20 @@ -/* eslint-disable max-lines */ -import { execFile, type ChildProcess } from 'node:child_process' -import { existsSync, accessSync, chmodSync, readFileSync, constants } from 'node:fs' -import { join } from 'node:path' -import { platform, arch } from 'node:os' -import { app, type WebContents } from 'electron' -import { CdpWsProxy } from './cdp-ws-proxy' -import { captureFullPageScreenshot } from './cdp-screenshot' -import { acquireElectronDebugger } from './electron-debugger-lease' -import type { BrowserManager } from './browser-manager' -import { BrowserError } from './cdp-bridge' -import type { - BrowserTabInfo, - BrowserTabListResult, - BrowserTabSwitchResult, - BrowserSnapshotResult, - BrowserClickResult, - BrowserGotoResult, - BrowserFillResult, - BrowserTypeResult, - BrowserSelectResult, - BrowserScrollResult, - BrowserBackResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserEvalResult, - BrowserHoverResult, - BrowserDragResult, - BrowserUploadResult, - BrowserWaitResult, - BrowserCheckResult, - BrowserFocusResult, - BrowserClearResult, - BrowserSelectAllResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserCookieDeleteResult, - BrowserViewportResult, - BrowserGeolocationResult, - BrowserInterceptEnableResult, - BrowserInterceptDisableResult, - BrowserConsoleResult, - BrowserNetworkLogResult, - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserCookie -} from '../../shared/runtime-types' -import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' -import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' -import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' -import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' -import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' -import { - ORCA_TAB_SESSION_PREFIX, - sweepOrphanedAgentBrowserSessions -} from './agent-browser-orphan-sweep' - -// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. -const EXEC_TIMEOUT_MS = 90_000 -const CONSECUTIVE_TIMEOUT_LIMIT = 3 -const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 -const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 -// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. -const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 -const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 -const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 -export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 -export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - -type SessionState = { - proxy: CdpWsProxy - cdpEndpoint: string - initialized: boolean - consecutiveTimeouts: number - // Why: track active interception patterns so they can be re-enabled after session restart - activeInterceptPatterns: string[] - activeCapture: boolean - // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. - lastCommandAt: number - // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. - webContentsId: number - activeProcess: ChildProcess | null -} - -type QueuedCommand = { - execute: () => Promise - resolve: (value: unknown) => void - reject: (reason: unknown) => void -} - -type ResolvedBrowserCommandTarget = { - browserPageId: string - webContentsId: number -} - -type AgentBrowserCleanupOptions = { - closeTimeoutMs?: number -} - -export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' - -function focusedValueSetExpression( - valueExpression: string, - options?: { append?: boolean; dispatchEvents?: boolean } -): string { - const nextValue = options?.append - ? ["String(target.value ?? '') + ", valueExpression].join('') - : valueExpression - const dispatchEvents = options?.dispatchEvents - ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" - : '' - return [ - '(() => { const el = document.activeElement; if (el) {', - // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. - " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", - " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", - ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', - ' let target = el;', - " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", - " const controls = target.getAttribute('aria-controls');", - ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', - ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', - ' }', - " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", - ' const nextValue = ', - nextValue, - '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', - dispatchEvents, - ' } })()' - ].join('') -} - -// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. -function focusedRichTextEditExpression( - valueExpression: string, - options?: { selectAll?: boolean } -): string { - const selectAll = options?.selectAll ? 'true' : 'false' - return [ - '(() => {', - ' const target = document.activeElement;', - ' const value = ', - valueExpression, - ';', - ` const selectAll = ${selectAll};`, - " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", - " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", - ' if (selectAll) {', - " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", - ' const selection = window.getSelection();', - " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", - ' selection.selectAllChildren(target);', - ' }', - " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", - ' let edited = false;', - ' try {', - ' edited = document.execCommand(editCommand, false, value) === true;', - ' } catch { edited = false; }', - " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", - ' })()' - ].join('') -} - -function isExplicitContentEditableResult(result: unknown): boolean { - const value = - result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined - return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) -} - -type AgentBrowserExecOptions = { - envOverrides?: NodeJS.ProcessEnv - timeoutMs?: number - timeoutError?: BrowserError - stdinText?: string -} - -type EnqueueTargetedCommandOptions = { - ensureSession?: boolean - ensureVisible?: boolean - // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). - requireScopedTarget?: boolean -} - -type AgentBrowserBridgeOptions = { - onTabsChanged?: (worktreeId?: string) => void -} - -function agentBrowserNativeName(): string { - const ext = process.platform === 'win32' ? '.exe' : '' - return `agent-browser-${platform()}-${arch()}${ext}` -} - -function resolveAgentBrowserBinary(): string { - // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. - const bundledResourcesPath = - process.resourcesPath ?? - (process.platform === 'darwin' - ? join(app.getPath('exe'), '..', '..', 'Resources') - : join(app.getPath('exe'), '..', 'resources')) - const bundled = join(bundledResourcesPath, agentBrowserNativeName()) - if (existsSync(bundled)) { - return bundled - } - - // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. - const nmBin = join( - app.getAppPath(), - 'node_modules', - 'agent-browser', - 'bin', - agentBrowserNativeName() - ) - if (existsSync(nmBin)) { - if (process.platform !== 'win32') { - try { - accessSync(nmBin, constants.X_OK) - } catch { - chmodSync(nmBin, 0o755) - } - } - return nmBin - } - - // Last resort: assume it's on PATH - return 'agent-browser' -} - -// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. -function parseShellArgs(input: string): string[] { - const args: string[] = [] - let current = '' - let inDouble = false - let inSingle = false - - for (let i = 0; i < input.length; i++) { - const ch = input[i] - if (ch === '"' && !inSingle) { - inDouble = !inDouble - } else if (ch === "'" && !inDouble) { - inSingle = !inSingle - } else if (ch === ' ' && !inDouble && !inSingle) { - if (current) { - args.push(current) - current = '' - } - } else { - current += ch - } - } - if (current) { - args.push(current) - } - return args -} - -function stripAgentBrowserTargetArgs(args: string[]): string[] { - const stripped: string[] = [] - for (let index = 0; index < args.length; index++) { - const arg = args[index] - if (arg === '--cdp' || arg === '--session') { - index++ - continue - } - if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { - continue - } - stripped.push(arg) - } - return stripped -} - -// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. -function classifyErrorCode(message: string): string { - if (/unknown ref|ref not found|element not found: @e/i.test(message)) { - return 'browser_stale_ref' - } - return 'browser_error' -} - -function isAbortedNavigationError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - const { code, errno } = error as { code?: unknown; errno?: unknown } - return code === 'ERR_ABORTED' || errno === -3 -} - -function isWebContentsLoading(wc: WebContents): boolean { - try { - return wc.isLoading() - } catch { - // Why: destruction races are resolved against the authoritative page registration after the wait. - return false - } -} - -function waitForAbortedNavigationReplacement( - wc: WebContents, - browserPageId: string, - timeoutMs: number -): Promise { - if (!isWebContentsLoading(wc)) { - return Promise.resolve() - } - - return new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType | null = null - const finish = (error?: BrowserError): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-stop-loading', onDidStopLoading) - wc.removeListener('destroyed', onDestroyed) - if (timeout) { - clearTimeout(timeout) - } - if (error) { - reject(error) - } else { - resolve() - } - } - const onDidStopLoading = (): void => finish() - const onDestroyed = (): void => finish() - - wc.on('did-stop-loading', onDidStopLoading) - wc.on('destroyed', onDestroyed) - timeout = setTimeout( - () => - finish( - new BrowserError( - 'browser_error', - `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - timeoutMs - ) - timeout.unref?.() - - // Why: the replacement can finish between loadURL rejecting and listener attachment. - if (!isWebContentsLoading(wc)) { - finish() - } - }) -} - -function isTabClosedTransportError(message: string): boolean { - return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( - message - ) -} - -function pageUnavailableMessageForSession(sessionName: string): string { - const prefix = ORCA_TAB_SESSION_PREFIX - const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null - return browserPageId - ? `Browser page ${browserPageId} is no longer available` - : 'Browser tab is no longer available' -} - -type CdpMouseButton = 'left' | 'middle' | 'right' - -type BrowserClickPoint = { - x: number - y: number - adjusted: boolean - handled: boolean -} - -function normalizeCdpMouseButton(button?: string): CdpMouseButton { - return button === 'middle' || button === 'right' ? button : 'left' -} - -function cdpMouseButtonMask(button: CdpMouseButton): number { - if (button === 'right') { - return 2 - } - if (button === 'middle') { - return 4 - } - return 1 -} - -function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { - if (!modifiers || modifiers.length === 0) { - return 0 - } - let mask = 0 - for (const modifier of modifiers) { - if (modifier === 'alt') { - mask |= 1 - } else if (modifier === 'ctrl') { - mask |= 2 - } else if (modifier === 'cmd') { - mask |= 4 - } else if (modifier === 'shift') { - mask |= 8 - } - } - return mask -} - -function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { - const point = value && typeof value === 'object' ? (value as Record) : null - const x = point?.x - const y = point?.y - if ( - typeof x !== 'number' || - !Number.isFinite(x) || - typeof y !== 'number' || - !Number.isFinite(y) - ) { - return fallback - } - return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } -} - -function mobileTouchClickExpression( - x: number, - y: number, - radius: number, - allowDomActivation: boolean -): string { - return `(() => { - const inputX = ${JSON.stringify(x)}; - const inputY = ${JSON.stringify(y)}; - const radius = ${JSON.stringify(radius)}; - const allowDomActivation = ${JSON.stringify(allowDomActivation)}; - const selector = [ - 'a[href]', - 'button', - 'input', - 'textarea', - 'select', - 'summary', - 'label', - '[role="button"]', - '[role="link"]', - '[role="menuitem"]', - '[role="tab"]', - '[role="checkbox"]', - '[role="radio"]', - '[role="switch"]', - '[onclick]', - '[tabindex]:not([tabindex="-1"])' - ].join(','); - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - const isUsable = (el) => { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return rect.width > 0 && rect.height > 0 && style.display !== 'none' && - style.visibility !== 'hidden' && style.pointerEvents !== 'none'; - }; - const dispatchClick = (target, clickX, clickY) => { - try { - if (typeof target.focus === 'function') { - target.focus({ preventScroll: true }); - } - } catch { - try { target.focus(); } catch {} - } - if (typeof target.click === 'function') { - target.click(); - return true; - } - const init = { - bubbles: true, - cancelable: true, - composed: true, - view: window, - clientX: clickX, - clientY: clickY, - screenX: clickX, - screenY: clickY, - button: 0, - buttons: 1 - }; - try { - if (typeof PointerEvent === 'function') { - target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); - target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); - } - } catch {} - target.dispatchEvent(new MouseEvent('mousedown', init)); - target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); - target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); - return true; - }; - const clickableFor = (el) => { - for (let node = el; node && node.nodeType === 1; node = node.parentElement) { - if (node.matches(selector)) return node; - if (window.getComputedStyle(node).cursor === 'pointer') return node; - } - return null; - }; - const offsets = [[0, 0]]; - for (const distance of [radius * 0.45, radius, radius * 1.35]) { - for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, - Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { - offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); - } - } - let best = null; - for (const [dx, dy] of offsets) { - const px = inputX + dx; - const py = inputY + dy; - if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; - for (const el of document.elementsFromPoint(px, py)) { - const target = clickableFor(el); - if (!target || !isUsable(target)) continue; - const rect = target.getBoundingClientRect(); - const clickX = clamp(inputX, rect.left + 1, rect.right - 1); - const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); - const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; - if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; - break; - } - } - if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { - return { x: best.x, y: best.y, adjusted: true, handled: true }; - } - if (best) { - return { x: best.x, y: best.y, adjusted: true, handled: false }; - } - return { x: inputX, y: inputY, adjusted: false, handled: false }; - })()` -} - -async function resolveMobileTouchClickPoint( - dbg: WebContents['debugger'], - x: number, - y: number, - radius: number | undefined, - allowDomActivation: boolean -): Promise { - const fallback = { x, y, adjusted: false, handled: false } - if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { - return fallback - } - try { - const result = await dbg.sendCommand('Runtime.evaluate', { - expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), - returnByValue: true, - silent: true - }) - const raw = result && typeof result === 'object' ? (result as Record) : null - const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null - return readClickPoint((evaluated as Record | null)?.value, fallback) - } catch { - return fallback - } -} - -function translateResult( - stdout: string -): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { - let parsed: { success?: boolean; data?: unknown; error?: string } - try { - parsed = JSON.parse(stdout) - } catch { - return { - ok: false, - error: { - code: 'browser_error', - message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` - } - } - } - if (parsed.success) { - return { ok: true, result: parsed.data } - } - const message = parsed.error ?? 'Unknown browser error' - return { - ok: false, - error: { - code: classifyErrorCode(message), - message - } - } -} - -export class AgentBrowserBridge { - // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. - private readonly activeWebContentsPerWorktree = new Map() - private activeWebContentsId: number | null = null - private readonly sessions = new Map() - private readonly commandQueues = new Map() - private readonly processingQueues = new Set() - // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. - private screenshotTurn: Promise = Promise.resolve() - private readonly agentBrowserBin: string - private readonly agentBrowserEnv: NodeJS.ProcessEnv - private readonly ownsAgentBrowserSocketDirectory: boolean - // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. - private readonly agentBrowserIdleTimeoutMs: number | null - // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. - private readonly pendingInterceptRestore = new Map() - // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. - private readonly pendingSessionCreation = new Map>() - // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. - private readonly pendingSessionDestruction = new Map>() - private readonly cancelledProcesses = new WeakSet() - private shutdownStarted = false - - constructor( - private readonly browserManager: BrowserManager, - private readonly options: AgentBrowserBridgeOptions = {} - ) { - this.agentBrowserBin = resolveAgentBrowserBinary() - const processEnvironment = createAgentBrowserProcessEnvironment({ - inheritedEnv: process.env, - platform: process.platform, - userDataPath: app.getPath('userData') - }) - this.agentBrowserEnv = processEnvironment.env - this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory - const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) - this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null - } - - // ── Tab tracking ── - - setActiveTab(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - private selectFallbackActiveWebContents( - worktreeId: string, - excludedWebContentsId?: number - ): number | null { - for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { - if (wcId === excludedWebContentsId) { - continue - } - if (this.getWebContents(wcId)) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - return wcId - } - } - this.activeWebContentsPerWorktree.delete(worktreeId) - return null - } - - getActiveWebContentsId(): number | null { - return this.activeWebContentsId - } - - getPageInfo( - worktreeId?: string, - browserPageId?: string - ): { browserPageId: string; url: string; title: string } | null { - try { - const target = this.resolveCommandTarget(worktreeId, browserPageId) - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - return null - } - return { - browserPageId: target.browserPageId, - url: wc.getURL() ?? '', - title: wc.getTitle() ?? '' - } - } catch { - return null - } - } - - onTabChanged(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - async onTabClosed(webContentsId: number): Promise { - const browserPageId = this.resolveTabIdSafe(webContentsId) - const owningWorktreeId = browserPageId - ? this.browserManager.getWorktreeIdForTab(browserPageId) - : undefined - let nextWorktreeActiveWebContentsId: number | null = null - if ( - owningWorktreeId && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId - ) { - nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( - owningWorktreeId, - webContentsId - ) - } - if (this.activeWebContentsId === webContentsId) { - this.activeWebContentsId = nextWorktreeActiveWebContentsId - } - if (browserPageId) { - await this.onPageClosed(browserPageId) - } - this.options.onTabsChanged?.(owningWorktreeId) - } - - /** - * Retire a page's daemon by page id. - * - * The headless offscreen backend owns pages by id and unregisters the guest - * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it - * has to say which page closed (#16367). - */ - async onPageClosed(browserPageId: string): Promise { - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - await this.destroySession(sessionName) - this.pendingInterceptRestore.delete(sessionName) - } - - async onProcessSwap( - browserPageId: string, - newWebContentsId: number, - previousWebContentsId?: number - ): Promise { - // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - const session = this.sessions.get(sessionName) - const oldWebContentsId = previousWebContentsId ?? session?.webContentsId - const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) - // Why: save intercept patterns before destroy so the new session can restore them after init. - if (session && session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - await this.destroySession(sessionName) - if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { - this.activeWebContentsId = newWebContentsId - } - if ( - owningWorktreeId && - oldWebContentsId != null && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId - ) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - } - - // ── Worktree-scoped tab queries ── - - getRegisteredTabs(worktreeId?: string): Map { - const all = this.browserManager.getWebContentsIdByTabId() - if (!worktreeId) { - return all - } - - const filtered = new Map() - for (const [tabId, wcId] of all) { - if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { - filtered.set(tabId, wcId) - } - } - return filtered - } - - // ── Tab management ── - - tabList(worktreeId?: string): BrowserTabListResult { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. - let activeWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - const result: BrowserTabInfo[] = [] - let index = 0 - let firstLiveWcId: number | null = null - for (const [tabId, wcId] of tabs) { - const wc = this.getWebContents(wcId) - if (!wc) { - this.browserManager.unregisterGuest(tabId) - continue - } - if (firstLiveWcId === null) { - firstLiveWcId = wcId - } - const loadError = this.browserManager.getBrowserPageLoadError(tabId) - const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) - result.push({ - browserPageId: tabId, - index: index++, - // Why: failed WebContents report chrome-error://, not the address the user asked to load. - url: loadError?.validatedUrl ?? wc.getURL() ?? '', - title: wc.getTitle() ?? '', - active: wcId === activeWcId, - loadError, - certificateFailure - }) - } - // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. - if (activeWcId == null && firstLiveWcId !== null) { - activeWcId = firstLiveWcId - if (result.length > 0) { - result[0].active = true - } - } - return { tabs: result } - } - - // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. - async tabSwitch( - index: number | undefined, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueCommand(worktreeId, async () => { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. - const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) - let switchedIndex = index ?? -1 - let resolvedPageId = browserPageId - if (resolvedPageId) { - switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) - } - if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { - const targetLabel = - resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` - throw new BrowserError( - 'browser_tab_not_found', - `${targetLabel} out of range (0-${liveEntries.length - 1})` - ) - } - const [tabId, wcId] = liveEntries[switchedIndex] - this.activeWebContentsId = wcId - // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. - const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) - // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. - if (owningWorktreeId) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - return { switched: switchedIndex, browserPageId: tabId } - }) - } - - // ── Core commands (typed) ── - - async snapshot(worktreeId?: string, browserPageId?: string): Promise { - // Why: snapshot creates fresh refs so it must bypass the stale-ref guard - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'snapshot' - ])) as BrowserSnapshotResult - return { - ...result, - browserPageId: target.browserPageId - } - }) - } - - async click( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult - }) - } - - async dblclick( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult - }) - } - - async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - const navigationUrl = normalizeBrowserNavigationUrl(url) - if (!navigationUrl) { - throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) - } - const navigationState: { preventUnloadEvent: Electron.Event | null } = { - preventUnloadEvent: null - } - const onWillPreventUnload = (event: Electron.Event): void => { - navigationState.preventUnloadEvent = event - } - wc.on('will-prevent-unload', onWillPreventUnload) - let navigationAborted = false - const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS - let navigationTimeout: ReturnType | null = null - try { - await Promise.race([ - wc.loadURL(navigationUrl), - new Promise((_resolve, reject) => { - navigationTimeout = setTimeout( - () => - reject( - new Error( - `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - EMBEDDED_NAVIGATION_TIMEOUT_MS - ) - navigationTimeout.unref?.() - }) - ]) - } catch (error) { - if (navigationTimeout) { - clearTimeout(navigationTimeout) - navigationTimeout = null - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. - if ( - !isAbortedNavigationError(error) || - (navigationState.preventUnloadEvent !== null && - !navigationState.preventUnloadEvent.defaultPrevented) - ) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } - navigationAborted = true - // Why: a superseding navigation rejects the first load before its replacement has landed. - await waitForAbortedNavigationReplacement( - wc, - target.browserPageId, - Math.max(0, navigationDeadline - Date.now()) - ) - } finally { - wc.removeListener('will-prevent-unload', onWillPreventUnload) - if (navigationTimeout) { - clearTimeout(navigationTimeout) - } - } - - // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. - const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) - const loadError = navigationAborted - ? this.browserManager.getBrowserPageLoadError(target.browserPageId) - : null - if (loadError) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` - ) - } - return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } - }, - { ensureSession: false } - ) - } - - async fill( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(value) - // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - await this.execAgentBrowser(sessionName, ['focus', element]) - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify('')) - ]) - for (const chunk of iterateBrowserTextInsertionChunks( - value, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(chunk), { append: true }) - ]) - } - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) - ]) - return { filled: element } as BrowserFillResult - } - - await this.fillExplicitContentEditable(sessionName, element, value) - return { filled: element } as BrowserFillResult - }, - { requireScopedTarget: true } - ) - } - - async type( - input: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(input) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - for (const chunk of iterateBrowserTextInsertionChunks( - input, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) - } - return { typed: true } as BrowserTypeResult - }, - { requireScopedTarget: true } - ) - } - - async select( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'select', - element, - value - ])) as BrowserSelectResult - }) - } - - async scroll( - direction: string, - amount?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['scroll', direction] - if (amount != null) { - args.push(String(amount)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult - }) - } - - async scrollIntoView( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) - }) - } - - async get( - what: string, - selector?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['get', what] - if (selector) { - args.push(selector) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async is( - what: string, - selector: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['is', what, selector]) - }) - } - - // ── Keyboard commands ── - - async keyboardInsertText( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - let result: unknown = { inserted: true } - for (const chunk of iterateBrowserTextInsertionChunks( - text, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) - } - return result - }, - { requireScopedTarget: true } - ) - } - - // ── Mouse commands ── - - async mouseMove( - x: number, - y: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) - }) - } - - async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'down'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseClick( - x: number, - y: number, - button?: string, - worktreeId?: string, - browserPageId?: string, - radius?: number, - modifiers?: BrowserMouseModifier[] - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${target.browserPageId} is no longer available` - ) - } - const cdpButton = normalizeCdpMouseButton(button) - const buttons = cdpMouseButtonMask(cdpButton) - const cdpModifiers = cdpMouseModifierMask(modifiers) - const lease = acquireElectronDebugger(wc) - try { - wc.focus() - const point = - cdpButton === 'left' - ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. - await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) - : { x, y, adjusted: false, handled: false } - // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. - // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. - if (!point.handled) { - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mousePressed', - x: point.x, - y: point.y, - button: cdpButton, - buttons, - modifiers: cdpModifiers, - clickCount: 1 - }) - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mouseReleased', - x: point.x, - y: point.y, - button: cdpButton, - buttons: 0, - modifiers: cdpModifiers, - clickCount: 1 - }) - } - return { - clicked: { - x: point.x, - y: point.y, - button: cdpButton, - adjusted: point.adjusted, - handled: point.handled - } - } - } finally { - lease.release() - } - }, - { ensureSession: false } - ) - } - - async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'up'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseWheel( - dy: number, - dx?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'wheel', String(dy)] - if (dx != null) { - args.push(String(dx)) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Find (semantic locators) ── - - async find( - locator: string, - value: string, - action: string, - text?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['find', locator, value, action] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Set commands ── - - async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'device', name]) - }) - } - - async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'offline'] - if (state) { - args.push(state) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async setHeaders( - headersJson: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) - }) - } - - async setCredentials( - user: string, - pass: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) - }) - } - - async setMedia( - colorScheme?: string, - reducedMotion?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'media'] - if (colorScheme) { - args.push(colorScheme) - } - if (reducedMotion) { - args.push(reducedMotion) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Clipboard commands ── - - async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) - }) - } - - async clipboardWrite( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text, { - maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES - }) - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) - }) - } - - // ── Dialog commands ── - - async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['dialog', 'accept'] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) - }) - } - - // ── Storage commands ── - - async storageLocalGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) - }) - } - - async storageLocalSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) - }) - } - - async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) - }) - } - - async storageSessionGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) - }) - } - - async storageSessionSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) - }) - } - - async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) - }) - } - - // ── Download command ── - - async download( - selector: string, - path: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['download', selector, path]) - }) - } - - // ── Highlight command ── - - async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['highlight', selector]) - }) - } - - async back(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult - }) - } - - async forward(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult - }) - } - - async reload(worktreeId?: string, browserPageId?: string): Promise { - // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - wc.reload() - await new Promise((resolve) => { - let settled = false - let fallbackTimer: ReturnType | null = null - - const finish = (): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-finish-load', onFinish) - wc.removeListener('did-fail-load', onFail) - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - resolve() - } - const onFinish = (): void => finish() - const onFail = (): void => finish() - - wc.on('did-finish-load', onFinish) - wc.on('did-fail-load', onFail) - // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. - fallbackTimer = setTimeout(finish, 10_000) - if (typeof fallbackTimer.unref === 'function') { - fallbackTimer.unref() - } - }) - return { url: wc.getURL(), title: wc.getTitle() } - }) - } - - async screenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) - }, - { ensureVisible: false } - ) - } - - async fullPageScreenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName, target) => { - return this.captureFullPageScreenshotCommand( - sessionName, - target.webContentsId, - 500, - format === 'jpeg' ? 'jpeg' : 'png' - ) - }, - { ensureVisible: false } - ) - } - - private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { - const parsed = raw as { path?: string } | undefined - if (!parsed?.path) { - throw new BrowserError('browser_error', 'Screenshot returned no file path') - } - if (!existsSync(parsed.path)) { - throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) - } - const data = readFileSync(parsed.path).toString('base64') - return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult - } - - private async captureScreenshotCommand( - sessionName: string, - commandArgs: string[], - settleMs: number, - format?: string - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. - await new Promise((r) => setTimeout(r, settleMs)) - const raw = await this.execAgentBrowser(sessionName, commandArgs) - return this.readScreenshotFromResult(raw, format) - } finally { - restore() - } - }) - } - - private async captureFullPageScreenshotCommand( - sessionName: string, - webContentsId: number, - settleMs: number, - format: 'png' | 'jpeg' - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. - await new Promise((r) => setTimeout(r, settleMs)) - const wc = this.getWebContents(webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - return await captureFullPageScreenshot(wc, format) - } catch (error) { - throw new BrowserError('browser_error', (error as Error).message) - } finally { - restore() - } - }) - } - - private async withSerializedScreenshotAccess(execute: () => Promise): Promise { - const previousTurn = this.screenshotTurn.catch(() => {}) - let releaseTurn!: () => void - this.screenshotTurn = new Promise((resolve) => { - releaseTurn = resolve - }) - await previousTurn - try { - return await execute() - } finally { - releaseTurn() - } - } - - async evaluate( - expression: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - let releaseDebugger = (): void => {} - try { - releaseDebugger = acquireElectronDebugger(wc).release - const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true - })) as { - result: { value?: unknown; description?: string } - exceptionDetails?: { text: string; exception?: { description?: string } } - } - if (exceptionDetails) { - throw new BrowserError( - 'browser_eval_error', - exceptionDetails.exception?.description ?? exceptionDetails.text - ) - } - - const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (currentTarget.webContentsId !== target.webContentsId) { - throw new BrowserError( - 'browser_tab_changed', - `Browser page ${target.browserPageId} changed while evaluating; retry the command` - ) - } - return { - result: - result.value !== undefined - ? typeof result.value === 'object' && result.value !== null - ? JSON.stringify(result.value) - : String(result.value) - : (result.description ?? ''), - origin: wc.getURL() - } - } catch (error) { - if (error instanceof BrowserError) { - throw error - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - throw new BrowserError( - 'browser_error', - `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } finally { - releaseDebugger() - } - }, - { ensureSession: false } - ) - } - - async hover( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult - }) - } - - async drag( - from: string, - to: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult - }) - } - - async upload( - element: string, - filePaths: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'upload', - element, - ...filePaths - ])) as BrowserUploadResult - }) - } - - async wait( - options?: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - }, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['wait'] - const hasCondition = - !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn - if (options?.selector) { - args.push(options.selector) - } else if (options?.timeout != null && !hasCondition) { - args.push(String(options.timeout)) - } - if (options?.text) { - args.push('--text', options.text) - } - if (options?.url) { - args.push('--url', options.url) - } - if (options?.load) { - args.push('--load', options.load) - } - if (options?.fn) { - args.push('--fn', options.fn) - } - const normalizedState = options?.state === 'visible' ? undefined : options?.state - if (normalizedState) { - args.push('--state', normalizedState) - } - // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. - return (await this.execAgentBrowser(sessionName, args, { - timeoutMs: - options?.timeout != null && hasCondition - ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS - : undefined, - timeoutError: - options?.timeout != null && hasCondition - ? new BrowserError( - 'browser_timeout', - `Timed out waiting for browser condition after ${options.timeout}ms.` - ) - : undefined - })) as BrowserWaitResult - }) - } - - async check( - element: string, - checked: boolean, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = checked ? ['check', element] : ['uncheck', element] - return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult - }) - } - - async focus( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult - }) - } - - async clear( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. - await this.execAgentBrowser(sessionName, ['fill', element, '']) - return { cleared: element } - } - - await this.fillExplicitContentEditable(sessionName, element, '') - return { cleared: element } - }, - { requireScopedTarget: true } - ) - } - - async selectAll( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser has no select-all command — implement as focus + Ctrl+A - await this.execAgentBrowser(sessionName, ['focus', element]) - return (await this.execAgentBrowser(sessionName, [ - 'press', - 'Control+a' - ])) as BrowserSelectAllResult - }) - } - - async keypress( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult - }) - } - - async pdf(worktreeId?: string, browserPageId?: string): Promise { - // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - const buffer = await wc.printToPDF({ - printBackground: true, - preferCSSPageSize: true - }) - return { data: buffer.toString('base64') } - }) - } - - // ── Cookie commands ── - - async cookieGet( - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'cookies', - 'get' - ])) as BrowserCookieGetResult - }) - } - - async cookieSet( - cookie: Partial, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] - if (cookie.domain) { - args.push('--domain', cookie.domain) - } - if (cookie.path) { - args.push('--path', cookie.path) - } - if (cookie.secure) { - args.push('--secure') - } - if (cookie.httpOnly) { - args.push('--httpOnly') - } - if (cookie.sameSite) { - args.push('--sameSite', cookie.sameSite) - } - if (cookie.expires != null) { - args.push('--expires', String(cookie.expires)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult - }) - } - - async cookieDelete( - name?: string, - domain?: string, - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'clear'] - if (name) { - args.push('--name', name) - } - if (domain) { - args.push('--domain', domain) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult - }) - } - - // ── Viewport / emulation commands ── - - async setViewport( - width: number, - height: number, - scale = 1, - mobile = false, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - const dbg = wc.debugger - if (!dbg.isAttached()) { - throw new BrowserError('browser_error', 'Debugger not attached') - } - - // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width, - height, - deviceScaleFactor: scale, - mobile - }) - // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. - await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( - () => {} - ) - - return { - width, - height, - deviceScaleFactor: scale, - mobile - } - }) - } - - async setGeolocation( - lat: number, - lon: number, - _accuracy?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'set', - 'geo', - String(lat), - String(lon) - ])) as BrowserGeolocationResult - }) - } - - // ── Network interception commands ── - - async interceptEnable( - patterns?: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser uses "network route " to intercept. Route each pattern individually. - const urlPattern = patterns?.[0] ?? '**/*' - const args = ['network', 'route', urlPattern] - const result = (await this.execAgentBrowser( - sessionName, - args - )) as BrowserInterceptEnableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = patterns ?? ['*'] - } - return result - }) - } - - async interceptDisable( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'unroute' - ])) as BrowserInterceptDisableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = [] - } - return result - }) - } - - async interceptList( - worktreeId?: string, - browserPageId?: string - ): Promise<{ requests: unknown[] }> { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { - requests: unknown[] - } - }) - } - - // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. - - // ── Capture commands ── - - async captureStart( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'start' - ])) as BrowserCaptureStartResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = true - } - return result - }) - } - - async captureStop( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'stop' - ])) as BrowserCaptureStopResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = false - } - return result - }) - } - - async consoleLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult - }) - } - - async networkLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'network', - 'requests' - ])) as BrowserNetworkLogResult - }) - } - - // ── Generic passthrough ── - - async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. - const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Session lifecycle ── - - // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with - // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. - async sweepOrphanedSessions(): Promise { - return sweepOrphanedAgentBrowserSessions({ - binaryPath: this.agentBrowserBin, - env: this.agentBrowserEnv, - ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, - isSessionLive: (sessionName) => - this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) - }) - } - - async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { - this.shutdownStarted = true - // Why the union: a session still being created has already spawned its daemon but is not in - // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). - const sessionNames = new Set([ - ...this.sessions.keys(), - ...this.pendingSessionCreation.keys(), - ...this.pendingSessionDestruction.keys() - ]) - await mapSettledWithConcurrency( - [...sessionNames], - AGENT_BROWSER_CLEANUP_CONCURRENCY, - (sessionName) => this.destroySession(sessionName, options) - ) - this.pendingInterceptRestore.clear() - } - - // ── Internal ── - - private async enqueueCommand( - worktreeId: string | undefined, - execute: (sessionName: string) => Promise - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - undefined, - async (sessionName) => execute(sessionName), - { ensureVisible: false } - ) - } - - private async enqueueTargetedCommand( - worktreeId: string | undefined, - browserPageId: string | undefined, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions = {} - ): Promise { - this.assertCommandAdmission() - const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - - if (options.ensureSession !== false) { - await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) - } - this.assertCommandAdmission() - - return new Promise((resolve, reject) => { - let queue = this.commandQueues.get(sessionName) - if (!queue) { - queue = [] - this.commandQueues.set(sessionName, queue) - } - queue.push({ - execute: (() => - this.executeWithVisibleTarget( - sessionName, - worktreeId, - target, - execute, - options - )) as () => Promise, - resolve: resolve as (value: unknown) => void, - reject - }) - this.processQueue(sessionName) - }) - } - - private async executeWithVisibleTarget( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions - ): Promise { - if (options.ensureVisible === false) { - return execute(sessionName, target) - } - - // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. - const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) - try { - const visibleTarget = await this.refreshTargetAfterAutomationVisibility( - sessionName, - worktreeId, - target, - options - ) - return await execute(sessionName, visibleTarget) - } finally { - restore() - } - } - - private async refreshTargetAfterAutomationVisibility( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - options: EnqueueTargetedCommandOptions - ): Promise { - const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (visibleTarget.webContentsId === target.webContentsId) { - return visibleTarget - } - - if (this.activeWebContentsId === target.webContentsId) { - this.activeWebContentsId = visibleTarget.webContentsId - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { - this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) - } - - // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. - await this.restartSessionForTarget( - sessionName, - visibleTarget.browserPageId, - visibleTarget.webContentsId, - { recreate: options.ensureSession !== false } - ) - - return visibleTarget - } - - private async processQueue(sessionName: string): Promise { - if (this.processingQueues.has(sessionName)) { - return - } - this.processingQueues.add(sessionName) - - const queue = this.commandQueues.get(sessionName) - while (queue && queue.length > 0) { - const cmd = queue.shift()! - try { - const result = await cmd.execute() - cmd.resolve(result) - } catch (error) { - cmd.reject(error) - } - } - - if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { - this.commandQueues.delete(sessionName) - } - this.processingQueues.delete(sessionName) - } - - getActivePageId(worktreeId?: string, browserPageId?: string): string | null { - try { - return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId - } catch { - return null - } - } - - private resolveCommandTarget( - worktreeId?: string, - browserPageId?: string, - requireScopedTarget = false - ): ResolvedBrowserCommandTarget { - if (!browserPageId) { - return requireScopedTarget - ? this.resolveScopedActiveTab(worktreeId) - : this.resolveActiveTab(worktreeId) - } - - const tabs = this.getRegisteredTabs(worktreeId) - const webContentsId = tabs.get(browserPageId) - if (webContentsId == null) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - - if (!this.getWebContents(webContentsId)) { - this.browserManager.unregisterGuest(browserPageId) - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - return { browserPageId, webContentsId } - } - - private resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - const tabs = this.getRegisteredTabs(worktreeId) - - if (tabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - - // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. - const preferredWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - - if (preferredWcId != null) { - for (const [tabId, wcId] of tabs) { - if (wcId === preferredWcId && this.getWebContents(wcId)) { - return { browserPageId: tabId, webContentsId: wcId } - } - if (wcId === preferredWcId) { - this.browserManager.unregisterGuest(tabId) - if (this.activeWebContentsId === wcId) { - this.activeWebContentsId = null - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { - this.activeWebContentsPerWorktree.delete(worktreeId) - } - } - } - } - - // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. - for (const [tabId, wcId] of tabs) { - if (this.getWebContents(wcId)) { - this.activeWebContentsId = wcId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - } - return { browserPageId: tabId, webContentsId: wcId } - } - this.browserManager.unregisterGuest(tabId) - } - - throw new BrowserError( - 'browser_no_tab', - 'No live browser tab available — all registered tabs have been destroyed' - ) - } - - // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. - private resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - if (worktreeId) { - return this.resolveActiveTab(worktreeId) - } - - const worktreesWithLiveTabs = new Set() - for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { - if (this.getWebContents(wcId)) { - worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) - } - } - - if (worktreesWithLiveTabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - if (worktreesWithLiveTabs.size > 1) { - throw new BrowserError( - 'browser_target_ambiguous', - 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' - ) - } - - const [onlyWorktreeId] = worktreesWithLiveTabs - return this.resolveActiveTab(onlyWorktreeId) - } - - private async ensureSession( - sessionName: string, - browserPageId: string, - webContentsId: number - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - } - this.assertCommandAdmission() - - if (this.sessions.has(sessionName)) { - return - } - - // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. - const pending = this.pendingSessionCreation.get(sessionName) - if (pending) { - await pending - this.assertCommandAdmission() - return - } - - const createSession = async (): Promise => { - const wc = this.getWebContents(webContentsId) - if (!wc) { - // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. - await this.closeStaleAgentBrowserSession(sessionName) - - const proxy = new CdpWsProxy(wc) - const cdpEndpoint = await proxy.start() - - this.sessions.set(sessionName, { - proxy, - cdpEndpoint, - initialized: false, - consecutiveTimeouts: 0, - activeInterceptPatterns: [], - activeCapture: false, - lastCommandAt: Date.now(), - webContentsId, - activeProcess: null - }) - } - - const promise = createSession() - this.pendingSessionCreation.set(sessionName, promise) - try { - await promise - } finally { - this.pendingSessionCreation.delete(sessionName) - } - } - - private async restartSessionForTarget( - sessionName: string, - browserPageId: string, - webContentsId: number, - options: { recreate: boolean } = { recreate: true } - ): Promise { - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - await pendingCreation.catch(() => {}) - } - - const session = this.sessions.get(sessionName) - if (session) { - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - if (session.activeProcess) { - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { - timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS - }) - } catch { - // Session may already be dead. - } - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - if (options.recreate) { - await this.ensureSession(sessionName, browserPageId, webContentsId) - } - } - - private async destroySession( - sessionName: string, - options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - return - } - - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. - try { - await pendingCreation - } catch { - // Creation failures are handled by the original caller; teardown still rejects queued work below. - } - } - - const session = this.sessions.get(sessionName) - if (!session) { - this.rejectQueuedCommandsForClosedSession(sessionName) - return - } - - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - - // Why: queued commands would hang forever if we just delete the queue — drain and reject them. - this.rejectQueuedCommandsForClosedSession(sessionName) - - if (session.activeProcess) { - // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - // Why: each tab has its own named session — close without --session leaves this tab's daemon running. - // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. - await this.runAgentBrowserRaw( - sessionName, - ['--session', sessionName, 'close'], - options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } - ) - } catch { - // Session may already be dead - } - - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - private rejectQueuedCommandsForClosedSession(sessionName: string): void { - const queue = this.commandQueues.get(sessionName) - this.commandQueues.delete(sessionName) - this.processingQueues.delete(sessionName) - if (queue) { - const err = new BrowserError( - 'browser_tab_closed', - 'Tab was closed while commands were queued' - ) - for (const cmd of queue) { - cmd.reject(err) - } - queue.length = 0 - } - } - - /** - * Notice that the daemon retired itself between two commands. - * - * A replacement daemon still serves the page (every call reasserts `--cdp`) - * but carries none of the session's network routes, so without this the - * interception the caller configured is silently gone (#16367). - */ - private reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { - if ( - this.agentBrowserIdleTimeoutMs === null || - Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs - ) { - return - } - session.initialized = false - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - } - - private assertCommandAdmission(): void { - if (this.shutdownStarted) { - throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') - } - } - - private async execAgentBrowser( - sessionName: string, - commandArgs: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - const session = this.sessions.get(sessionName) - if (!session) { - // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. - throw this.createPageUnavailableError(sessionName) - } - - // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. - if (!this.getWebContents(session.webContentsId)) { - await this.destroySession(sessionName) - throw this.createPageUnavailableError(sessionName) - } - - this.reinitializeIfDaemonIdledOut(sessionName, session) - session.lastCommandAt = Date.now() - - const args = ['--session', sessionName] - const managesInterceptRoutes = - commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') - - const needsInit = !session.initialized - // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. - args.push('--cdp', String(session.proxy.getPort())) - - // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. - for (const commandArg of commandArgs) { - args.push(commandArg) - } - args.push('--json') - - const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) - const translated = translateResult(stdout) - - if (!translated.ok) { - throw this.createCommandError( - sessionName, - translated.error.message, - translated.error.code, - session.webContentsId - ) - } - - // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. - if (needsInit) { - session.initialized = true - - // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. - const pendingPatterns = managesInterceptRoutes - ? undefined - : this.pendingInterceptRestore.get(sessionName) - if (pendingPatterns && pendingPatterns.length > 0) { - this.pendingInterceptRestore.delete(sessionName) - try { - const urlPattern = pendingPatterns[0] ?? '**/*' - await this.runAgentBrowserRaw(sessionName, [ - '--session', - sessionName, - '--cdp', - String(session.proxy.getPort()), - 'network', - 'route', - urlPattern, - '--json' - ]) - session.activeInterceptPatterns = pendingPatterns - } catch { - // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. - } - } - } - - return translated.result - } - - private async isExplicitContentEditableTarget( - sessionName: string, - element: string - ): Promise { - const result = await this.execAgentBrowser(sessionName, [ - 'get', - 'attr', - element, - 'contenteditable' - ]) - return isExplicitContentEditableResult(result) - } - - private async fillExplicitContentEditable( - sessionName: string, - element: string, - value: string - ): Promise { - await this.execAgentBrowser(sessionName, ['focus', element]) - // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. - await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { - stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) - }) - } - - private createPageUnavailableError(sessionName: string): BrowserError { - return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) - } - - private closeStaleAgentBrowserSession(sessionName: string): Promise { - return new Promise((resolve, reject) => { - let child: ReturnType | null = null - let settled = false - - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - reject(error) - } else { - resolve() - } - } - - // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. - const timeout = setTimeout(() => { - child?.kill() - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` - ) - ) - }, STALE_SESSION_CLOSE_TIMEOUT_MS) - - try { - child = execFile( - this.agentBrowserBin, - ['--session', sessionName, 'close'], - // Why windowsHide: agent-browser is console-subsystem and Orca's main - // process owns no console, so each spawn gets a fresh visible conhost - // that takes foreground -- keystrokes typed into a terminal at that - // moment land in the black box (#14543). - { - env: this.agentBrowserEnv, - timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, - windowsHide: true - }, - (error) => - finish( - error - ? new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error.message}` - ) - : undefined - ) - ) - } catch (error) { - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` - ) - ) - } - }) - } - - private createCommandError( - sessionName: string, - message: string, - fallbackCode: string, - webContentsId?: number - ): BrowserError { - // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. - if ( - fallbackCode === 'browser_error' && - isTabClosedTransportError(message) && - this.isSessionTargetClosed(sessionName, webContentsId) - ) { - return this.createPageUnavailableError(sessionName) - } - return new BrowserError(fallbackCode, message) - } - - private isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { - const session = this.sessions.get(sessionName) - if (!session) { - return true - } - const targetWebContentsId = webContentsId ?? session.webContentsId - return !this.getWebContents(targetWebContentsId) - } - - private runAgentBrowserRaw( - sessionName: string, - args: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - return new Promise((resolve, reject) => { - const session = this.sessions.get(sessionName) - let child: ChildProcess | null = null - child = execFile( - this.agentBrowserBin, - args, - // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). - { - timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, - maxBuffer: 50 * 1024 * 1024, - // Why windowsHide: see the stale-session close above -- every - // agent-browser invocation would otherwise flash a console (#14543). - windowsHide: true, - env: execOptions?.envOverrides - ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } - : this.agentBrowserEnv - }, - (error, stdout, stderr) => { - if (session && session.activeProcess === child) { - session.activeProcess = null - } - if (child && this.cancelledProcesses.has(child)) { - this.cancelledProcesses.delete(child) - reject( - new BrowserError('browser_tab_closed', 'Tab was closed while command was running') - ) - return - } - - const liveSession = this.sessions.get(sessionName) - - if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { - if (execOptions?.timeoutError) { - reject(execOptions.timeoutError) - return - } - if (liveSession) { - liveSession.consecutiveTimeouts++ - if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { - // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate - this.destroySession(sessionName) - } - } - reject(new BrowserError('browser_error', 'Browser command timed out')) - return - } - - if (liveSession) { - liveSession.consecutiveTimeouts = 0 - } - - if (error) { - // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. - if (stdout) { - try { - const parsed = JSON.parse(stdout) - if (parsed.error) { - const code = classifyErrorCode(parsed.error) - reject( - this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) - ) - return - } - } catch { - // stdout not valid JSON — fall through to stderr/error.message - } - } - const message = stderr || error.message - const code = classifyErrorCode(message) - reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) - return - } - - resolve(stdout) - } - ) - if (session) { - session.activeProcess = child - } - if (execOptions?.stdinText !== undefined && child?.stdin) { - // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. - child.stdin.on('error', () => {}) - child.stdin.end(execOptions.stdinText) - } - }) - } - - private resolveTabIdSafe(webContentsId: number): string | null { - return this.browserManager.getTabIdForWebContentsId(webContentsId) - } - - private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) - } - return wc - } - - private getWebContents(webContentsId: number): Electron.WebContents | null { - try { - const { webContents } = require('electron') - const target = webContents.fromId(webContentsId) - return target && !target.isDestroyed() ? target : null - } catch { - return null - } - } -} +import { AgentBrowserBridgeStateCommands } from './agent-browser-bridge-state-commands' + +export { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES +} from './agent-browser-bridge-types' +export type { + BrowserMouseModifier, + AgentBrowserCleanupOptions, + AgentBrowserBridgeOptions +} from './agent-browser-bridge-types' + +/** + * Routes automation commands to the browser guest registered for the selected tab. + * + * The implementation is layered by responsibility (tab targeting, command queue, + * session lifecycle, and command families) so each layer stays independently + * reviewable while preserving this public facade. + */ +export class AgentBrowserBridge extends AgentBrowserBridgeStateCommands {} diff --git a/src/main/browser/browser-cookie-chromium-finalize.ts b/src/main/browser/browser-cookie-chromium-finalize.ts new file mode 100644 index 00000000000..46203d0d151 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-finalize.ts @@ -0,0 +1,132 @@ +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { browserSessionRegistry } from './browser-session-registry' +import { removeTransplantableCookies } from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { writeImportedCookies, type SourceCookieToWrite } from './browser-cookie-import-write' +import { deriveUrl } from './browser-cookie-validation' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function finalizeChromiumCookieImport( + context: ChromiumImportContext +): Promise { + if (context.decryptedCookies.length === 0) { + const zeroPathWarning = context.undecryptableWarning + context.closeStagingDb() + context.discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: context.sourceRows.length, + importedCookies: 0, + skippedCookies: + context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + ...(context.partitionSkipped > 0 + ? { partitionSkippedCookies: context.partitionSkipped } + : {}), + domains: [], + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) + } + } + } + + if (context.stagingDb) { + try { + context.stagingDb.exec('COMMIT') + context.closeStagingDb() + diag( + ` SQLite staging complete: ${context.imported} cookies, ${context.domainSet.size} domains` + ) + } catch (err) { + context.disableStaging(String(err)) + } + } else { + diag(` staging skipped: ${context.imported} cookies will load in-memory only`) + } + + const cookieClearStore = openCookieClearStore(context.targetSession) + try { + await removeTransplantableCookies( + { + cookies: cookieClearStore, + snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), + restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities) + }, + context.nativePlan.skippedFamilies, + context.importScope + ) + diag( + ` cleared existing cookies for ${context.domainSet.size} imported domains before loading ${context.decryptedCookies.length} imported cookies` + ) + + const writable: SourceCookieToWrite[] = [] + for (const cookie of context.decryptedCookies) { + const url = deriveUrl(cookie.domain, cookie.secure) + if (!url) { + context.memoryFailed++ + continue + } + writable.push({ ...cookie, url }) + } + const phase = await writeImportedCookies(cookieClearStore, writable, { + stopOnFailure: false, + log: diag + }) + context.memoryLoaded = phase.importedCount + context.memoryFailed += phase.writeRejected + } finally { + cookieClearStore.dispose() + } + + diag( + ` memory load: ${context.memoryLoaded} OK, ${context.memoryFailed} failed, ${context.partitionSkipped} partition-unreadable` + ) + + let warning: BrowserCookieImportSummary['warning'] + if (context.memoryFailed > 0 && context.stagingAvailable) { + browserSessionRegistry.setPendingCookieImport( + context.targetPartition, + context.stagingCookiesPath + ) + diag( + ` staged at ${context.stagingCookiesPath} for ${context.memoryFailed} cookies that need restart` + ) + } else if (context.memoryFailed > 0) { + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(` ${context.memoryFailed} cookies need a restart but staging is unavailable — skipped`) + warning = { + code: 'restart-fallback-unavailable', + loadedCookies: context.memoryLoaded, + failedCookies: context.memoryFailed + } + } else { + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(' all cookies loaded in-memory — no restart needed') + } + + if (!warning && context.undecryptableWarning) { + warning = context.undecryptableWarning + } + + const summary: BrowserCookieImportSummary = { + totalCookies: context.sourceRows.length, + importedCookies: context.imported, + skippedCookies: context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + ...(context.partitionSkipped > 0 ? { partitionSkippedCookies: context.partitionSkipped } : {}), + domains: [...context.domainSet].sort(), + ...(warning ? { warning } : {}) + } + return { ok: true, profileId: '', summary } +} diff --git a/src/main/browser/browser-cookie-chromium-import.ts b/src/main/browser/browser-cookie-chromium-import.ts new file mode 100644 index 00000000000..e9ee2ebe386 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-import.ts @@ -0,0 +1,74 @@ +import { session } from 'electron' +import { existsSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { withCookieMutationLock } from './browser-cookie-import-clear' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import { prepareChromiumCookieImport } from './browser-cookie-chromium-prepare' +import { scanChromiumCookieRows } from './browser-cookie-chromium-scan' +import { finalizeChromiumCookieImport } from './browser-cookie-chromium-finalize' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function importChromiumCookies( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions = {} +): Promise { + diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) + if (!existsSync(browser.cookiesPath)) { + diag(` cookies DB not found: ${browser.cookiesPath}`) + return { ok: false, reason: `${browser.label} cookies database not found.` } + } + + const targetSession = session.fromPartition(targetPartition) + return withCookieMutationLock(targetSession, async () => { + let context: ChromiumImportContext | null = null + try { + const preparation = await prepareChromiumCookieImport( + browser, + targetPartition, + options, + targetSession + ) + if ('result' in preparation) { + return preparation.result + } + context = preparation.context + const scanResult = scanChromiumCookieRows(context) + if (scanResult) { + return scanResult + } + return await finalizeChromiumCookieImport(context) + } catch (err) { + if (context) { + try { + context.sourceDb?.close() + } catch { + /* may already be closed */ + } + context.closeStagingDb() + context.discardStagingFile() + } + diag(` SQLite import failed: ${String(err)}`) + return { + ok: false, + reason: reasonWithDiagLog( + `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` + ) + } + } finally { + if (context) { + try { + context.sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${String(err)}`) + } + } + } + }) +} diff --git a/src/main/browser/browser-cookie-chromium-prepare.ts b/src/main/browser/browser-cookie-chromium-prepare.ts new file mode 100644 index 00000000000..d9cf4d30ba3 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-prepare.ts @@ -0,0 +1,289 @@ +import { app } from 'electron' +import { randomUUID } from 'node:crypto' +import { mkdirSync, unlinkSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain +} from './browser-cookie-import-policy' +import { createChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { planImportWrites } from './browser-cookie-import-write' +import { readChromiumRowPartition } from './browser-cookie-source-partition' +import { diag } from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { ChromiumCookieColumnInfo } from './browser-cookie-sqlite' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' +import type { Session } from 'electron' +import { getEncryptionKey } from './browser-cookie-key' + +export type ChromiumImportPreparation = + | { context: ChromiumImportContext } + | { result: BrowserCookieImportResult } + +export async function prepareChromiumCookieImport( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions, + targetSession: Session +): Promise { + await targetSession.cookies.flushStore() + const partitionDir = targetSession.getStoragePath() + if (!partitionDir) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const partitionName = targetPartition.replace('persist:', '') + let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + // Why: initialize an unused profile so Chromium creates its Cookies database. + if (!liveCookiesPath) { + try { + await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) + await targetSession.cookies.remove('https://localhost', '__init') + await targetSession.cookies.flushStore() + } catch { + // ignore — flushStore still creates the file on supported Electron versions + } + liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + } + if (!liveCookiesPath) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') + const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') + const stagingCookiesPath = join( + stagingDir, + `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` + ) + let stagingAvailable = false + if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { + diag(` restart fallback unsupported for partition "${targetPartition}" — not staging cookies`) + } else { + try { + mkdirSync(stagingDir, { recursive: true }) + copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) + stagingAvailable = true + } catch (err) { + const fsErr = err as NodeJS.ErrnoException + diag( + ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` + ) + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } + } + + let sourceSnapshot: ReturnType + try { + // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. + sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) + } catch (err) { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + diag(` Chromium snapshot failed: ${String(err)}`) + return { + result: { + ok: false, + reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` + } + } + } + + let sourceDb: InstanceType | null = null + let stagingDb: InstanceType | null = null + const closeStagingDb = (): void => { + try { + stagingDb?.close() + } catch { + /* best-effort */ + } + stagingDb = null + } + const discardStagingFile = (): void => { + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(stagingCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + stagingDb.exec('PRAGMA journal_mode = DELETE') + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols = targetColumnInfo.map((row) => row.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + discardStagingFile() + } + } + + const sourceColumns = new Set( + (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( + (column) => column.name + ) + ) + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null + diag(` source has ${sourceRows.length} cookies`) + if (sourceRows.length === 0) { + closeStagingDb() + discardStagingFile() + return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } + } + + const partitionCandidates = sourceRows.flatMap((sourceRow) => { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) + ? [] + : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] + }) + const nativePlan = planImportWrites(partitionCandidates) + const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) + const partitionBySourceRow = new Map( + partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) + ) + if (nativePlan.hasUnrepresentableSkip) { + closeStagingDb() + discardStagingFile() + return { + result: { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + } + + const needsSourceKey = sourceRows.some((sourceRow) => { + const encrypted = sourceRow.encrypted_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + return false + } + return ( + !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && + !isNonTransplantableCookieDomain(sourceRow.host_key as string) + ) + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + closeStagingDb() + discardStagingFile() + return { + result: { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } + } + + let insertStmt: ChromiumImportContext['insertStmt'] = null + const context: ChromiumImportContext = { + browser, + targetPartition, + options, + targetSession, + stagingCookiesPath, + stagingAvailable, + sourceSnapshot, + sourceDb, + stagingDb, + targetColumnInfo, + colList, + placeholders, + sourceColumns, + sourceRows, + nativePlan, + plannedSourceRows, + partitionBySourceRow, + sourceKey, + imported: 0, + skipped: 0, + decryptFailed: 0, + appBoundFailed: 0, + keyringUnavailableFailed: 0, + integritySkipped: 0, + nonTransplantableSkipped: 0, + partitionSkipped: nativePlan.skips.length, + googleCookiesSkipped: 0, + memoryLoaded: 0, + memoryFailed: 0, + domainSet: new Set(), + decryptedCookies: [], + scanned: [], + sourceDomainValidity: new Map(), + insertStmt, + importScope: { + exact: new Set(), + ancestors: new Set(), + descendantRoots: new Set() + }, + closeStagingDb, + discardStagingFile, + disableStaging: (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + context.stagingAvailable = false + context.insertStmt = null + context.closeStagingDb() + context.discardStagingFile() + } + } satisfies ChromiumImportContext + + if (context.stagingDb && context.colList && context.placeholders) { + try { + context.insertStmt = context.stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + ) + context.stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + context.disableStaging(String(err)) + } + } else if (context.stagingAvailable) { + context.disableStaging('staged database exposed no cookies columns') + } + if (context.nativePlan.skippedFamilies.size > 0) { + context.disableStaging( + `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` + ) + } + return { context } +} diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts new file mode 100644 index 00000000000..dd7e061bebc --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -0,0 +1,167 @@ +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + importedDomainScope +} from './browser-cookie-import-policy' +import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' +import { chromiumTimestampToUnix, buildChromiumCookieInsertParams } from './browser-cookie-sqlite' +import { chromiumSameSite } from './browser-cookie-validation' +import { + buildUndecryptableWarning, + cookieEncryptionVersion, + decryptCookieValueRaw +} from './browser-cookie-decryption' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +/** + * Decrypts and validates source rows without touching the target cookie jar. + * Keeping this pass separate makes the write scope derive from one complete plan. + */ +export function scanChromiumCookieRows( + context: ChromiumImportContext +): BrowserCookieImportResult | null { + const { sourceRows, sourceKey, plannedSourceRows, partitionBySourceRow, targetColumnInfo } = + context + + for (const sourceRow of sourceRows) { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + + if (isGoogleSourceBoundCookie(name, domain)) { + context.integritySkipped++ + continue + } + if (isNonTransplantableCookieDomain(domain)) { + context.nonTransplantableSkipped++ + continue + } + + const encRaw = sourceRow.encrypted_value + const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null + const plainRaw = sourceRow.value + let decryptedValue: Buffer + if (encBuf && encBuf.length > 0) { + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null + if (!raw) { + // Why: retain the prefix while it is available so diagnostics identify the failure cause. + context.decryptFailed++ + if (appBoundIneligible) { + context.appBoundFailed++ + } else if (keyringIneligible) { + context.keyringUnavailableFailed++ + } + context.skipped++ + continue + } + decryptedValue = raw + } else if (plainRaw instanceof Uint8Array) { + decryptedValue = Buffer.from(plainRaw) + } else if (typeof plainRaw === 'string') { + decryptedValue = Buffer.from(plainRaw, 'latin1') + } else { + decryptedValue = Buffer.alloc(0) + } + + let validDomain = context.sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + context.sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + context.skipped++ + continue + } + // Decryption failures are counted above; planned family omissions are counted once here. + if (!plannedSourceRows.has(sourceRow)) { + context.skipped++ + continue + } + + const path = sourceRow.path as string + const secure = sourceRow.is_secure === 1n + const httpOnly = sourceRow.is_httponly === 1n + const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) + const partition = partitionBySourceRow.get(sourceRow)! + const value = decryptedValue.toString('latin1') + context.scanned.push({ + entry: { + decryptedValue, + value, + domain, + name, + path, + secure, + httpOnly, + sameSite, + expirationDate: expiresUtc > 0 ? expiresUtc : undefined, + partition + }, + sourceRow + }) + } + + for (const { entry } of context.scanned) { + context.domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) + } + context.importScope = importedDomainScope([...context.domainSet]) + + if (context.stagingDb && context.insertStmt) { + try { + prepareStagedCookiesForImport(context.stagingDb, context.importScope) + } catch (err) { + context.disableStaging(String(err)) + } + } + + // EMIT: all downstream writes derive from the one scan, so no row can leak into the jar. + for (const { entry, sourceRow } of context.scanned) { + context.decryptedCookies.push(entry) + if (context.insertStmt && targetColumnInfo) { + try { + const params = buildChromiumCookieInsertParams( + targetColumnInfo, + sourceRow, + entry.decryptedValue + ) + context.insertStmt.run(...params) + } catch (err) { + context.disableStaging(String(err)) + } + } + context.imported++ + } + + diag( + ` skipped ${context.integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${context.nonTransplantableSkipped} non-transplantable-domain cookies` + ) + context.googleCookiesSkipped = context.integritySkipped + context.nonTransplantableSkipped + context.undecryptableWarning = buildUndecryptableWarning({ + decryptFailed: context.decryptFailed, + appBoundFailed: context.appBoundFailed, + keyringUnavailableFailed: context.keyringUnavailableFailed + }) + + if (context.partitionSkipped > 0 && context.options.canReportPartitionSkippedCookies === false) { + context.closeStagingDb() + context.discardStagingFile() + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + return null +} diff --git a/src/main/browser/browser-cookie-chromium-types.ts b/src/main/browser/browser-cookie-chromium-types.ts new file mode 100644 index 00000000000..9bf792de01c --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-types.ts @@ -0,0 +1,83 @@ +import type { Session } from 'electron' +import type { DatabaseSync } from 'node:sqlite' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { + ImportedCookieFields, + ImportWritePhase, + SourceCookieToWrite +} from './browser-cookie-import-write' +import type { SourcePartitionRead } from './browser-cookie-source-partition' +import type { ImportedDomainScope } from './browser-cookie-import-policy' +import type { ChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import type { ChromiumCookieColumnInfo, EncryptionKeyResult } from './browser-cookie-sqlite' + +export type ChromiumSourceRow = Record + +export type DecryptedCookie = Omit & { + decryptedValue: Buffer + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +export type ScannedChromiumCookie = { + entry: DecryptedCookie + sourceRow: ChromiumSourceRow +} + +export type ChromiumImportPlan = { + writes: { sourceRow: ChromiumSourceRow; domain: string; partition: SourcePartitionRead }[] + skips: unknown[] + skippedFamilies: Set + hasUnrepresentableSkip: boolean +} + +export type ChromiumImportContext = { + browser: DetectedBrowser + targetPartition: string + options: CookieImportOptions + targetSession: Session + stagingCookiesPath: string + stagingAvailable: boolean + sourceSnapshot: ChromiumCookieSnapshot + sourceDb: InstanceType | null + stagingDb: InstanceType | null + targetColumnInfo: ChromiumCookieColumnInfo[] | null + colList: string | null + placeholders: string | null + sourceColumns: Set + sourceRows: ChromiumSourceRow[] + nativePlan: ChromiumImportPlan + plannedSourceRows: Set + partitionBySourceRow: Map + sourceKey: EncryptionKeyResult | null + imported: number + skipped: number + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number + integritySkipped: number + nonTransplantableSkipped: number + partitionSkipped: number + googleCookiesSkipped: number + memoryLoaded: number + memoryFailed: number + domainSet: Set + decryptedCookies: DecryptedCookie[] + scanned: ScannedChromiumCookie[] + sourceDomainValidity: Map + insertStmt: ReturnType['prepare']> | null + importScope: ImportedDomainScope + closeStagingDb: () => void + discardStagingFile: () => void + disableStaging: (reason: string) => void + undecryptableWarning?: BrowserCookieImportSummary['warning'] + warning?: BrowserCookieImportSummary['warning'] + writePhase?: ImportWritePhase + writable?: SourceCookieToWrite[] + result?: BrowserCookieImportResult +} diff --git a/src/main/browser/browser-cookie-decryption.ts b/src/main/browser/browser-cookie-decryption.ts new file mode 100644 index 00000000000..04fb002f7f4 --- /dev/null +++ b/src/main/browser/browser-cookie-decryption.ts @@ -0,0 +1,126 @@ +import { createDecipheriv } from 'node:crypto' +import type { BrowserCookieImportSummary } from '../../shared/browser-workspace-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. +const CHROMIUM_COOKIE_HMAC_LEN = 32 + +function hasHmacPrefix(buf: Buffer): boolean { + if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { + return false + } + let nonPrintable = 0 + for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { + if (buf[i] < 0x20 || buf[i] > 0x7e) { + nonPrintable++ + } + } + return nonPrintable >= 8 +} + +function stripHmac(buf: Buffer): Buffer { + return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf +} + +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +export function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: a named cause must carry only its exact count; tied causes fall back to unknown. +export function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const unknownFailed = + counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed + const rankedCauses = [ + { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, + { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, + { reason: 'unknown' as const, count: unknownFailed } + ].sort((left, right) => right.count - left.count) + const [dominant, runnerUp] = rankedCauses + + if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } + } + + const otherFailedCookies = counts.decryptFailed - dominant.count + return { + code: 'cookies-undecryptable', + failedCookies: dominant.count, + reason: dominant.reason, + ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) + } +} + +export function decryptCookieValueRaw( + encryptedBuffer: Buffer, + keyResult: EncryptionKeyResult +): Buffer | null { + if (!encryptedBuffer || encryptedBuffer.length === 0) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + if (!/^v\d\d$/.test(version)) { + return null + } + + if (keyResult.mode === 'aes-256-gcm') { + return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) + } + + // AES-128-CBC (macOS and Linux) + const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined + if (!key) { + return null + } + + const ciphertext = encryptedBuffer.subarray(3) + if (!ciphertext.length) { + return null + } + + try { + const iv = Buffer.alloc(16, ' ') + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} + +function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { + // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] + if (payload.length < 12 + 16) { + return null + } + const nonce = payload.subarray(0, 12) + const authTag = payload.subarray(-16) + const ciphertext = payload.subarray(12, -16) + try { + const decipher = createDecipheriv('aes-256-gcm', key, nonce) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} diff --git a/src/main/browser/browser-cookie-detection-types.ts b/src/main/browser/browser-cookie-detection-types.ts new file mode 100644 index 00000000000..bac46f6d750 --- /dev/null +++ b/src/main/browser/browser-cookie-detection-types.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type { BrowserSessionProfileSource } from '../../shared/browser-workspace-types' + +export type BrowserProfile = { + name: string + directory: string +} + +export type DetectedBrowser = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + cookiesPath: string + keychainService?: string + keychainAccount?: string + profiles: BrowserProfile[] + selectedProfile: string +} + +export type ChromiumBrowserDef = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + keychainService: string + keychainAccount: string + // Per-platform data-dir roots, resolved at detection time via browserRootPath(). + macRoot?: string + winRoot?: string + linuxRoot?: string +} + +export const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ + { + family: 'chrome', + label: 'Google Chrome', + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + macRoot: 'Google/Chrome', + winRoot: 'Google/Chrome/User Data', + linuxRoot: 'google-chrome' + }, + { + family: 'edge', + label: 'Microsoft Edge', + keychainService: 'Microsoft Edge Safe Storage', + keychainAccount: 'Microsoft Edge', + macRoot: 'Microsoft Edge', + winRoot: 'Microsoft/Edge/User Data', + linuxRoot: 'microsoft-edge' + }, + { + family: 'arc', + label: 'Arc', + keychainService: 'Arc Safe Storage', + keychainAccount: 'Arc', + macRoot: 'Arc/User Data' + }, + { + family: 'chromium', + label: 'Brave', + keychainService: 'Brave Safe Storage', + keychainAccount: 'Brave', + macRoot: 'BraveSoftware/Brave-Browser', + winRoot: 'BraveSoftware/Brave-Browser/User Data', + linuxRoot: 'BraveSoftware/Brave-Browser' + }, + { + family: 'comet', + label: 'Comet', + keychainService: 'Comet Safe Storage', + keychainAccount: 'Comet', + macRoot: 'Comet', + winRoot: 'Comet/User Data' + // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 + }, + { + family: 'helium', + // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. + label: 'Helium', + keychainService: 'Helium Storage Key', + keychainAccount: 'Helium', + macRoot: 'net.imput.helium' + // winRoot/linuxRoot intentionally omitted — only the macOS install is verified + } +] + +export function browserRootPath(def: ChromiumBrowserDef): string | null { + if (process.platform === 'darwin') { + if (!def.macRoot) { + return null + } + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', def.macRoot) + } + if (process.platform === 'win32') { + if (!def.winRoot) { + return null + } + const localAppData = process.env.LOCALAPPDATA ?? '' + if (!localAppData) { + return null + } + return join(localAppData, def.winRoot) + } + // Linux + if (!def.linuxRoot) { + return null + } + const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') + return join(configHome, def.linuxRoot) +} + +export function isSafeBrowserProfileDirectory(directory: string): boolean { + return ( + directory.length > 0 && + directory !== '.' && + !directory.includes('\0') && + !directory.includes('/') && + !directory.includes('\\') && + !directory.includes('..') + ) +} + +// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. +export function discoverProfiles(browserRoot: string): BrowserProfile[] { + try { + const localStatePath = join(browserRoot, 'Local State') + if (!existsSync(localStatePath)) { + return [{ name: 'Default', directory: 'Default' }] + } + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const infoCache = localState?.profile?.info_cache + if (!infoCache || typeof infoCache !== 'object') { + return [{ name: 'Default', directory: 'Default' }] + } + const profiles: BrowserProfile[] = [] + for (const [dir, info] of Object.entries(infoCache)) { + // Why: Local State is external metadata, but profile dirs become path segments. + if (!isSafeBrowserProfileDirectory(dir)) { + continue + } + const profileName = (info as { name?: string })?.name ?? dir + profiles.push({ name: profileName, directory: dir }) + } + return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] + } catch { + return [{ name: 'Default', directory: 'Default' }] + } +} + +// --------------------------------------------------------------------------- +// Firefox detection +// --------------------------------------------------------------------------- + +export function firefoxProfilesRoot(): string | null { + if (process.platform === 'darwin') { + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? '' + return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null + } + const home = process.env.HOME ?? '' + return join(home, '.mozilla', 'firefox') +} + +export function discoverFirefoxProfiles(): BrowserProfile[] { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return [] + } + try { + if (!existsSync(profilesRoot)) { + return [] + } + const entries = readdirSync(profilesRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. + const sorted = entries.sort((a, b) => { + if (a.includes('default-release')) { + return -1 + } + if (b.includes('default-release')) { + return 1 + } + if (a.includes('default')) { + return -1 + } + if (b.includes('default')) { + return 1 + } + return 0 + }) + return sorted.map((dir) => { + const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir + return { name: label, directory: dir } + }) + } catch { + return [] + } +} + +export function detectFirefox(): DetectedBrowser | null { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const profiles = discoverFirefoxProfiles() + for (const profile of profiles) { + const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') + if (existsSync(cookiesPath)) { + return { + family: 'firefox', + label: 'Firefox', + cookiesPath, + profiles, + selectedProfile: profile.directory + } + } + } + return null +} diff --git a/src/main/browser/browser-cookie-detection.ts b/src/main/browser/browser-cookie-detection.ts new file mode 100644 index 00000000000..78c5d7c0ed8 --- /dev/null +++ b/src/main/browser/browser-cookie-detection.ts @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { + CHROMIUM_BROWSERS, + browserRootPath, + discoverProfiles, + detectFirefox, + firefoxProfilesRoot, + isSafeBrowserProfileDirectory, + type DetectedBrowser +} from './browser-cookie-detection-types' + +// --------------------------------------------------------------------------- +// Safari detection +// --------------------------------------------------------------------------- + +export function detectSafari(): DetectedBrowser | null { + if (process.platform !== 'darwin') { + return null + } + const home = process.env.HOME ?? '' + const candidates = [ + join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), + join( + home, + 'Library', + 'Containers', + 'com.apple.Safari', + 'Data', + 'Library', + 'Cookies', + 'Cookies.binarycookies' + ) + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + return { + family: 'safari', + label: 'Safari', + cookiesPath: candidate, + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } + } + } + return null +} + +export function detectInstalledBrowsers(): DetectedBrowser[] { + const detected: DetectedBrowser[] = [] + for (const browser of CHROMIUM_BROWSERS) { + const root = browserRootPath(browser) + if (!root) { + continue + } + const profiles = discoverProfiles(root) + // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. + for (const profile of profiles) { + const profileDir = join(root, profile.directory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (cookiesPath) { + detected.push({ + family: browser.family, + label: browser.label, + keychainService: browser.keychainService, + keychainAccount: browser.keychainAccount, + cookiesPath, + profiles, + selectedProfile: profile.directory + }) + break + } + } + } + + const firefox = detectFirefox() + if (firefox) { + detected.push(firefox) + } + + const safari = detectSafari() + if (safari) { + detected.push(safari) + } + + return detected +} + +export function selectBrowserProfile( + browser: DetectedBrowser, + profileDirectory: string +): DetectedBrowser | null { + if (!isSafeBrowserProfileDirectory(profileDirectory)) { + return null + } + if (browser.family === 'firefox') { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') + if (!existsSync(cookiesPath)) { + return null + } + return { ...browser, cookiesPath, selectedProfile: profileDirectory } + } + + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + const profileDir = join(root, profileDirectory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (!cookiesPath) { + return null + } + return { + ...browser, + cookiesPath, + selectedProfile: profileDirectory + } +} diff --git a/src/main/browser/browser-cookie-firefox-import.ts b/src/main/browser/browser-cookie-firefox-import.ts new file mode 100644 index 00000000000..60482b30e31 --- /dev/null +++ b/src/main/browser/browser-cookie-firefox-import.ts @@ -0,0 +1,138 @@ +import { DatabaseSync } from 'node:sqlite' +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { readFirefoxRowPartition } from './browser-cookie-source-partition' +import { + importValidatedCookies, + cookieImportTarget, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { deriveUrl, firefoxSameSite, type ValidatedCookie } from './browser-cookie-validation' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Firefox import +// --------------------------------------------------------------------------- + +export async function importCookiesFromFirefox( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions +): Promise { + diag(`importCookiesFromFirefox: partition="${targetPartition}"`) + + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) + const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') + + try { + copyFileSync(browser.cookiesPath, tmpCookiesPath) + for (const suffix of ['-wal', '-shm'] as const) { + const sidecar = browser.cookiesPath + suffix + if (existsSync(sidecar)) { + try { + copyFileSync(sidecar, tmpCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + } catch { + rmSync(tmpDir, { recursive: true, force: true }) + return { + ok: false, + reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' + } + } + + try { + const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) + type FirefoxRow = Record & { + name: string + value: string + host: string + path: string + expiry: number + isSecure: number + isHttpOnly: number + sameSite: number + isPartitionedAttributeSet?: number + } + // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema + // without the server-declared partition flag predates that cookie identity. + const firefoxColumns = new Set( + (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( + (column) => column.name + ) + ) + const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') + ? ', isPartitionedAttributeSet' + : '' + const rows = db + .prepare( + `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` + ) + .all() as FirefoxRow[] + db.close() + + diag(` Firefox source has ${rows.length} cookies`) + if (rows.length === 0) { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'No cookies found in Firefox.' } + } + + const now = Math.floor(Date.now() / 1000) + const validated: ValidatedCookie[] = [] + for (const row of rows) { + if (!row.name || !row.host) { + continue + } + if (row.expiry > 0 && row.expiry < now) { + continue + } + + const domain = row.host + const secure = row.isSecure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + continue + } + + validated.push({ + url, + name: row.name, + value: row.value ?? '', + domain, + path: row.path || '/', + secure, + httpOnly: row.isHttpOnly === 1, + sameSite: firefoxSameSite(row.sameSite), + expirationDate: row.expiry > 0 ? row.expiry : undefined, + partition: readFirefoxRowPartition(row, firefoxColumns) + }) + } + + rmSync(tmpDir, { recursive: true, force: true }) + + if (validated.length === 0) { + return { ok: false, reason: 'No valid cookies found in Firefox.' } + } + + return importValidatedCookies( + validated, + rows.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains', + options + ) + } catch (err) { + rmSync(tmpDir, { recursive: true, force: true }) + diag(` Firefox import failed: ${String(err)}`) + return { + ok: false, + reason: 'Could not import cookies from Firefox. Try closing Firefox first.' + } + } +} diff --git a/src/main/browser/browser-cookie-import-diagnostics.ts b/src/main/browser/browser-cookie-import-diagnostics.ts new file mode 100644 index 00000000000..f80850fc58e --- /dev/null +++ b/src/main/browser/browser-cookie-import-diagnostics.ts @@ -0,0 +1,55 @@ +import { app } from 'electron' +import { appendFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. +let _diagLog: string | null = null +export function getDiagLogPath(): string { + if (!_diagLog) { + try { + _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') + } catch { + _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') + } + } + return _diagLog +} +export function reasonWithDiagLog(reason: string): string { + return `${reason} Details were written to ${getDiagLogPath()}.` +} +const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 +const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 + +// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. +export function summarizeCookieImportError(err: unknown): string { + const raw = err instanceof Error && err.message ? err.message : String(err) + let summary = '' + let previousWasWhitespace = false + const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) + for (let index = 0; index < scanLimit; index += 1) { + const code = raw.charCodeAt(index) + if (code === 32 || (code >= 9 && code <= 13)) { + if (summary.length > 0 && !previousWasWhitespace) { + summary += ' ' + } + previousWasWhitespace = true + continue + } + summary += raw.charAt(index) + if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { + return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) + } + previousWasWhitespace = false + } + return summary +} +export function diag(msg: string): void { + const line = `[${new Date().toISOString()}] ${msg}\n` + try { + appendFileSync(getDiagLogPath(), line) + } catch { + /* best-effort */ + } + console.log('[cookie-import]', msg) +} diff --git a/src/main/browser/browser-cookie-import-pipeline.ts b/src/main/browser/browser-cookie-import-pipeline.ts new file mode 100644 index 00000000000..c1d5deb769b --- /dev/null +++ b/src/main/browser/browser-cookie-import-pipeline.ts @@ -0,0 +1,300 @@ +import { dialog, session, type BrowserWindow } from 'electron' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + replaceCookiesForImportedDomains, + type CookieImportMode, + type ReplacedImportedDomainCookies +} from './browser-cookie-import-policy' +import { + acquireCookieMutationLock, + type CookieClearStore, + type CookieImportWriteStore +} from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { + emptyImportWritePhase, + planImportWrites, + writeImportedCookies, + type ImportWritePhase +} from './browser-cookie-import-write' +import { readFile } from 'node:fs/promises' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import { + validateCookieEntry, + type RawCookieEntry, + type ValidatedCookie +} from './browser-cookie-validation' + +// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so +// the partition-dropping write is not merely unused here — it cannot be reached. +export type CookieImportSessionStore = CookieClearStore & + CookieImportWriteStore & { dispose: () => void } + +export type CookieImportTarget = { + partition: string + // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the + // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME + // instance for one partition string, so carrying that instance here is what keeps this path's + // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. + mutationLockOwner: object + openWriteStore: () => CookieImportSessionStore +} + +export type CookieImportOptions = { + canReportPartitionSkippedCookies?: boolean +} + +export function cookieImportTarget(targetPartition: string): CookieImportTarget { + const targetSession = session.fromPartition(targetPartition) + return { + partition: targetPartition, + mutationLockOwner: targetSession, + openWriteStore: () => openCookieClearStore(targetSession) + } +} + +export async function importValidatedCookies( + cookies: ValidatedCookie[], + totalInput: number, + target: CookieImportTarget, + mode: CookieImportMode, + options: CookieImportOptions = {} +): Promise { + const targetPartition = target.partition + const importDomainCache = new Map() + const validDomainCookies = cookies.filter((cookie) => { + let valid = importDomainCache.get(cookie.domain) + if (valid === undefined) { + valid = normalizeCookieImportDomain(cookie.domain) !== null + importDomainCache.set(cookie.domain, valid) + } + return valid + }) + const sourceBoundFiltered = validDomainCookies.filter( + (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) + ) + // Why: dropping these before the replace scope is computed is what keeps the existing + // Google session intact — replaceCookiesForImportedDomains only clears domains we import. + const importableCookies = sourceBoundFiltered.filter( + (cookie) => !isNonTransplantableCookieDomain(cookie.domain) + ) + const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length + const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const invalidDomainSkipped = cookies.length - validDomainCookies.length + diag( + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` + ) + // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is + // the single value the write set AND the removal scope both derive from, so they cannot drift + // apart the way they did in bf6dc6fcba. + const plan = planImportWrites(importableCookies) + + // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and + // clearing a family we cannot protect is the P0. Refuse before touching anything. + if (plan.hasUnrepresentableSkip) { + return { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + + // Why: an older remote client cannot surface this skip, so fail before opening the target jar. + if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a + // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or + // totalCookies === importedCookies + skippedCookies silently stops holding. + const partitionSkipped = plan.skips.length + let skipped = totalInput - importableCookies.length + partitionSkipped + let phase: ImportWritePhase = emptyImportWritePhase() + // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only + // they carry partitionKey. cookies.set drops it silently, on the success path as well. + const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null + + if (cookieClearStore) { + // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. + // Releasing after the replace lets a second import interleave, so this run's rollback could + // remove cookies the newer import already wrote and reported as imported. Taken AFTER the + // store is opened on purpose — openWriteStore only builds the adapter, it attaches no + // debugger, so holding it while queued cannot deadlock against the holder. + const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) + let replaced: ReplacedImportedDomainCookies | null = null + try { + if (mode === 'replace-imported-domains') { + try { + // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact + // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain + // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's + // live session into the removal scope with nothing written back. plan.writes is already + // family-closed, and using the same array for both makes them impossible to diverge. + const replacementDomains = plan.writes.map((cookie) => cookie.domain) + replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) + diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } + + // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. + const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + phase = await writeImportedCookies( + cookieClearStore, + plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), + { stopOnFailure: replaced !== null, log: diag } + ) + // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable + // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned + // writes reach the writer, so the count comes from the plan and is added exactly once. + skipped += phase.writeRejected + + if (phase.failure && replaced) { + const rollbackFailures: unknown[] = [] + for (const cookie of phase.attemptedKeys.toReversed()) { + try { + await cookieClearStore.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty + // restore set would spin up a hidden BrowserWindow to put nothing back. + if (replaced.identities.length > 0) { + try { + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + } catch (err) { + rollbackFailures.push(err) + } + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } + return { + ok: false, + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') + } + } + } finally { + try { + cookieClearStore.dispose() + } finally { + releaseMutationLock() + } + } + } + + diag( + `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` + ) + + const summary: BrowserCookieImportSummary = { + totalCookies: totalInput, + importedCookies: phase.importedCount, + skippedCookies: skipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), + domains: [...phase.domains].sort() + } + + return { ok: true, profileId: '', summary } +} + +// --------------------------------------------------------------------------- +// Import from JSON file +// --------------------------------------------------------------------------- + +// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. +export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { + const opts = { + title: 'Import Cookies', + filters: [ + { name: 'Cookie Files', extensions: ['json'] }, + { name: 'All Files', extensions: ['*'] } + ], + properties: ['openFile' as const] + } + const result = parentWindow + ? await dialog.showOpenDialog(parentWindow, opts) + : await dialog.showOpenDialog(opts) + + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] +} + +export async function importCookiesFromFile( + filePath: string, + targetPartition: string +): Promise { + let rawContent: string + try { + rawContent = await readFile(filePath, 'utf-8') + } catch { + return { ok: false, reason: 'Could not read the selected file.' } + } + + let parsed: unknown + try { + parsed = JSON.parse(rawContent) + } catch { + return { ok: false, reason: 'File is not valid JSON.' } + } + + if (!Array.isArray(parsed)) { + return { ok: false, reason: 'Expected a JSON array of cookie objects.' } + } + + if (parsed.length === 0) { + return { ok: false, reason: 'Cookie file is empty.' } + } + + const validated: ValidatedCookie[] = [] + let skipped = 0 + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + skipped++ + continue + } + const cookie = validateCookieEntry(entry as RawCookieEntry) + if (cookie) { + validated.push(cookie) + } else { + skipped++ + } + } + + if (validated.length === 0) { + return { + ok: false, + reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` + } + } + + return importValidatedCookies( + validated, + parsed.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) +} diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 752940c9c83..10929761731 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,968 +1,48 @@ -/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, dialog, session } from 'electron' -import { execFileSync } from 'node:child_process' -import { runProcessSync } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' -import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' +import type { BrowserWindow } from 'electron' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' import { - appendFileSync, - copyFileSync, - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - unlinkSync -} from 'node:fs' -import { readFile } from 'node:fs/promises' -import { DatabaseSync } from 'node:sqlite' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. -let _diagLog: string | null = null -function getDiagLogPath(): string { - if (!_diagLog) { - try { - _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') - } catch { - _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') - } - } - return _diagLog -} -function reasonWithDiagLog(reason: string): string { - return `${reason} Details were written to ${getDiagLogPath()}.` -} -const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 -const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 - -// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. -export function summarizeCookieImportError(err: unknown): string { - const raw = err instanceof Error && err.message ? err.message : String(err) - let summary = '' - let previousWasWhitespace = false - const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) - for (let index = 0; index < scanLimit; index += 1) { - const code = raw.charCodeAt(index) - if (code === 32 || (code >= 9 && code <= 13)) { - if (summary.length > 0 && !previousWasWhitespace) { - summary += ' ' - } - previousWasWhitespace = true - continue - } - summary += raw.charAt(index) - if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { - return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) - } - previousWasWhitespace = false - } - return summary -} -function diag(msg: string): void { - const line = `[${new Date().toISOString()}] ${msg}\n` - try { - appendFileSync(getDiagLogPath(), line) - } catch { - /* best-effort */ - } - console.log('[cookie-import]', msg) -} -import type { - BrowserCookieImportResult, - BrowserCookieImportSummary, - BrowserSessionProfileSource -} from '../../shared/browser-workspace-types' -import { browserSessionRegistry } from './browser-session-registry' -import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' + detectInstalledBrowsers as detectBrowsers, + selectBrowserProfile as selectProfile +} from './browser-cookie-detection' +import type { BrowserProfile, DetectedBrowser } from './browser-cookie-detection-types' import { - isGoogleSourceBoundCookie, - isNonTransplantableCookieDomain, - normalizeCookieDomain, - normalizeCookieImportDomain, - importedDomainScope, - replaceCookiesForImportedDomains, - type CookieImportMode, - type ReplacedImportedDomainCookies -} from './browser-cookie-import-policy' + pickCookieFile as pickFile, + importCookiesFromFile as importFile, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { importChromiumCookies } from './browser-cookie-chromium-import' +import { importCookiesFromFirefox } from './browser-cookie-firefox-import' +import { importCookiesFromSafari } from './browser-cookie-safari-import' import { - acquireCookieMutationLock, - removeTransplantableCookies, - withCookieMutationLock, - type CookieClearStore, - type CookieImportWriteStore -} from './browser-cookie-import-clear' -import { openCookieClearStore } from './browser-cookie-clear-store' -import { - readChromiumRowPartition, - readFirefoxRowPartition, - readJsonCookiePartition, - type SourcePartitionRead -} from './browser-cookie-source-partition' -import { - emptyImportWritePhase, - writeImportedCookies, - type ImportedCookieFields, - type ImportWritePhase, - type SourceCookieToWrite, - planImportWrites -} from './browser-cookie-import-write' -import { - createChromiumCookieSnapshot, - type ChromiumCookieSnapshot -} from './chromium-cookie-snapshot' -import { resolveChromiumCookiesPath } from './chromium-cookie-path' -import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' -import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' + buildChromiumCookieInsertParams as buildInsertParams, + type ChromiumCookieColumnInfo +} from './browser-cookie-sqlite' +import { isAppBoundEncryptedCookie as isAppBoundCookie } from './browser-cookie-decryption' +import { summarizeCookieImportError as summarizeError } from './browser-cookie-import-diagnostics' -// --------------------------------------------------------------------------- -// Browser detection -// --------------------------------------------------------------------------- - -export type BrowserProfile = { - name: string - directory: string -} - -export type DetectedBrowser = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - cookiesPath: string - keychainService?: string - keychainAccount?: string - profiles: BrowserProfile[] - selectedProfile: string -} - -type ChromiumBrowserDef = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - keychainService: string - keychainAccount: string - // Per-platform data-dir roots, resolved at detection time via browserRootPath(). - macRoot?: string - winRoot?: string - linuxRoot?: string -} - -const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ - { - family: 'chrome', - label: 'Google Chrome', - keychainService: 'Chrome Safe Storage', - keychainAccount: 'Chrome', - macRoot: 'Google/Chrome', - winRoot: 'Google/Chrome/User Data', - linuxRoot: 'google-chrome' - }, - { - family: 'edge', - label: 'Microsoft Edge', - keychainService: 'Microsoft Edge Safe Storage', - keychainAccount: 'Microsoft Edge', - macRoot: 'Microsoft Edge', - winRoot: 'Microsoft/Edge/User Data', - linuxRoot: 'microsoft-edge' - }, - { - family: 'arc', - label: 'Arc', - keychainService: 'Arc Safe Storage', - keychainAccount: 'Arc', - macRoot: 'Arc/User Data' - }, - { - family: 'chromium', - label: 'Brave', - keychainService: 'Brave Safe Storage', - keychainAccount: 'Brave', - macRoot: 'BraveSoftware/Brave-Browser', - winRoot: 'BraveSoftware/Brave-Browser/User Data', - linuxRoot: 'BraveSoftware/Brave-Browser' - }, - { - family: 'comet', - label: 'Comet', - keychainService: 'Comet Safe Storage', - keychainAccount: 'Comet', - macRoot: 'Comet', - winRoot: 'Comet/User Data' - // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 - }, - { - family: 'helium', - // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. - label: 'Helium', - keychainService: 'Helium Storage Key', - keychainAccount: 'Helium', - macRoot: 'net.imput.helium' - // winRoot/linuxRoot intentionally omitted — only the macOS install is verified - } -] - -function browserRootPath(def: ChromiumBrowserDef): string | null { - if (process.platform === 'darwin') { - if (!def.macRoot) { - return null - } - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', def.macRoot) - } - if (process.platform === 'win32') { - if (!def.winRoot) { - return null - } - const localAppData = process.env.LOCALAPPDATA ?? '' - if (!localAppData) { - return null - } - return join(localAppData, def.winRoot) - } - // Linux - if (!def.linuxRoot) { - return null - } - const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') - return join(configHome, def.linuxRoot) -} - -function isSafeBrowserProfileDirectory(directory: string): boolean { - return ( - directory.length > 0 && - directory !== '.' && - !directory.includes('\0') && - !directory.includes('/') && - !directory.includes('\\') && - !directory.includes('..') - ) -} - -// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. -function discoverProfiles(browserRoot: string): BrowserProfile[] { - try { - const localStatePath = join(browserRoot, 'Local State') - if (!existsSync(localStatePath)) { - return [{ name: 'Default', directory: 'Default' }] - } - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const infoCache = localState?.profile?.info_cache - if (!infoCache || typeof infoCache !== 'object') { - return [{ name: 'Default', directory: 'Default' }] - } - const profiles: BrowserProfile[] = [] - for (const [dir, info] of Object.entries(infoCache)) { - // Why: Local State is external metadata, but profile dirs become path segments. - if (!isSafeBrowserProfileDirectory(dir)) { - continue - } - const profileName = (info as { name?: string })?.name ?? dir - profiles.push({ name: profileName, directory: dir }) - } - return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] - } catch { - return [{ name: 'Default', directory: 'Default' }] - } -} - -// --------------------------------------------------------------------------- -// Firefox detection -// --------------------------------------------------------------------------- - -function firefoxProfilesRoot(): string | null { - if (process.platform === 'darwin') { - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') - } - if (process.platform === 'win32') { - const appData = process.env.APPDATA ?? '' - return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null - } - const home = process.env.HOME ?? '' - return join(home, '.mozilla', 'firefox') -} - -function discoverFirefoxProfiles(): BrowserProfile[] { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return [] - } - try { - if (!existsSync(profilesRoot)) { - return [] - } - const entries = readdirSync(profilesRoot, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) - // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. - const sorted = entries.sort((a, b) => { - if (a.includes('default-release')) { - return -1 - } - if (b.includes('default-release')) { - return 1 - } - if (a.includes('default')) { - return -1 - } - if (b.includes('default')) { - return 1 - } - return 0 - }) - return sorted.map((dir) => { - const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir - return { name: label, directory: dir } - }) - } catch { - return [] - } -} - -function detectFirefox(): DetectedBrowser | null { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const profiles = discoverFirefoxProfiles() - for (const profile of profiles) { - const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') - if (existsSync(cookiesPath)) { - return { - family: 'firefox', - label: 'Firefox', - cookiesPath, - profiles, - selectedProfile: profile.directory - } - } - } - return null -} - -// --------------------------------------------------------------------------- -// Safari detection -// --------------------------------------------------------------------------- - -const MAC_EPOCH_DELTA = 978_307_200 - -function detectSafari(): DetectedBrowser | null { - if (process.platform !== 'darwin') { - return null - } - const home = process.env.HOME ?? '' - const candidates = [ - join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), - join( - home, - 'Library', - 'Containers', - 'com.apple.Safari', - 'Data', - 'Library', - 'Cookies', - 'Cookies.binarycookies' - ) - ] - for (const candidate of candidates) { - if (existsSync(candidate)) { - return { - family: 'safari', - label: 'Safari', - cookiesPath: candidate, - profiles: [{ name: 'Default', directory: 'Default' }], - selectedProfile: 'Default' - } - } - } - return null -} +export type { BrowserProfile, DetectedBrowser, CookieImportOptions, ChromiumCookieColumnInfo } +export { summarizeError as summarizeCookieImportError } export function detectInstalledBrowsers(): DetectedBrowser[] { - const detected: DetectedBrowser[] = [] - for (const browser of CHROMIUM_BROWSERS) { - const root = browserRootPath(browser) - if (!root) { - continue - } - const profiles = discoverProfiles(root) - // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. - for (const profile of profiles) { - const profileDir = join(root, profile.directory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (cookiesPath) { - detected.push({ - family: browser.family, - label: browser.label, - keychainService: browser.keychainService, - keychainAccount: browser.keychainAccount, - cookiesPath, - profiles, - selectedProfile: profile.directory - }) - break - } - } - } - - const firefox = detectFirefox() - if (firefox) { - detected.push(firefox) - } - - const safari = detectSafari() - if (safari) { - detected.push(safari) - } - - return detected + return detectBrowsers() } export function selectBrowserProfile( browser: DetectedBrowser, profileDirectory: string ): DetectedBrowser | null { - if (!isSafeBrowserProfileDirectory(profileDirectory)) { - return null - } - if (browser.family === 'firefox') { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') - if (!existsSync(cookiesPath)) { - return null - } - return { ...browser, cookiesPath, selectedProfile: profileDirectory } - } - - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - const profileDir = join(root, profileDirectory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (!cookiesPath) { - return null - } - return { - ...browser, - cookiesPath, - selectedProfile: profileDirectory - } + return selectProfile(browser, profileDirectory) } -// --------------------------------------------------------------------------- -// Cookie validation (shared between file import and direct import) -// --------------------------------------------------------------------------- - -type RawCookieEntry = { - domain?: unknown - name?: unknown - value?: unknown - path?: unknown - secure?: unknown - httpOnly?: unknown - sameSite?: unknown - expirationDate?: unknown - partitionKey?: unknown - partitionKeyOpaque?: unknown -} - -// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to -// state what it read. An optional field would let a new source silently default to unpartitioned. -type ValidatedCookie = ImportedCookieFields & { - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead -} - -// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. -function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 1: - return 'no_restriction' - case 2: - return 'lax' - case 3: - return 'strict' - default: - return 'unspecified' - } -} - -function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 0: - return 'no_restriction' - case 1: - return 'lax' - case 2: - return 'strict' - default: - return 'unspecified' - } -} - -function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - if (typeof raw === 'number') { - return chromiumSameSite(raw) - } - if (typeof raw !== 'string') { - return 'unspecified' - } - const lower = raw.toLowerCase() - if (lower === 'lax') { - return 'lax' - } - if (lower === 'strict') { - return 'strict' - } - if (lower === 'none' || lower === 'no_restriction') { - return 'no_restriction' - } - return 'unspecified' -} - -// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. -function deriveUrl(domain: string, secure: boolean): string | null { - const normalizedDomain = normalizeCookieDomain(domain) - if (!normalizedDomain) { - return null - } - const protocol = secure ? 'https' : 'http' - try { - const url = new URL(`${protocol}://${normalizedDomain}/`) - return url.toString() - } catch { - return null - } -} - -function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { - if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { - return null - } - if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { - return null - } - if (typeof raw.value !== 'string') { - return null - } - - const domain = raw.domain.trim() - const secure = raw.secure === true || raw.secure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = - typeof raw.expirationDate === 'number' && raw.expirationDate > 0 - ? raw.expirationDate - : undefined - - return { - url, - name: raw.name.trim(), - value: raw.value, - domain, - path: typeof raw.path === 'string' ? raw.path : '/', - secure, - httpOnly: raw.httpOnly === true || raw.httpOnly === 1, - sameSite: normalizeSameSite(raw.sameSite), - expirationDate, - partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) - } -} - -// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so -// the partition-dropping write is not merely unused here — it cannot be reached. -type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { dispose: () => void } - -type CookieImportTarget = { - partition: string - // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the - // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME - // instance for one partition string, so carrying that instance here is what keeps this path's - // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. - mutationLockOwner: object - openWriteStore: () => CookieImportSessionStore -} - -type CookieImportOptions = { - canReportPartitionSkippedCookies?: boolean -} - -function cookieImportTarget(targetPartition: string): CookieImportTarget { - const targetSession = session.fromPartition(targetPartition) - return { - partition: targetPartition, - mutationLockOwner: targetSession, - openWriteStore: () => openCookieClearStore(targetSession) - } -} - -async function importValidatedCookies( - cookies: ValidatedCookie[], - totalInput: number, - target: CookieImportTarget, - mode: CookieImportMode, - options: CookieImportOptions = {} -): Promise { - const targetPartition = target.partition - const importDomainCache = new Map() - const validDomainCookies = cookies.filter((cookie) => { - let valid = importDomainCache.get(cookie.domain) - if (valid === undefined) { - valid = normalizeCookieImportDomain(cookie.domain) !== null - importDomainCache.set(cookie.domain, valid) - } - return valid - }) - const sourceBoundFiltered = validDomainCookies.filter( - (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) - ) - // Why: dropping these before the replace scope is computed is what keeps the existing - // Google session intact — replaceCookiesForImportedDomains only clears domains we import. - const importableCookies = sourceBoundFiltered.filter( - (cookie) => !isNonTransplantableCookieDomain(cookie.domain) - ) - const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length - const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - const invalidDomainSkipped = cookies.length - validDomainCookies.length - diag( - `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` - ) - // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is - // the single value the write set AND the removal scope both derive from, so they cannot drift - // apart the way they did in bf6dc6fcba. - const plan = planImportWrites(importableCookies) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and - // clearing a family we cannot protect is the P0. Refuse before touching anything. - if (plan.hasUnrepresentableSkip) { - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - // Why: an older remote client cannot surface this skip, so fail before opening the target jar. - if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a - // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or - // totalCookies === importedCookies + skippedCookies silently stops holding. - const partitionSkipped = plan.skips.length - let skipped = totalInput - importableCookies.length + partitionSkipped - let phase: ImportWritePhase = emptyImportWritePhase() - // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only - // they carry partitionKey. cookies.set drops it silently, on the success path as well. - const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null - - if (cookieClearStore) { - // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. - // Releasing after the replace lets a second import interleave, so this run's rollback could - // remove cookies the newer import already wrote and reported as imported. Taken AFTER the - // store is opened on purpose — openWriteStore only builds the adapter, it attaches no - // debugger, so holding it while queued cannot deadlock against the holder. - const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) - let replaced: ReplacedImportedDomainCookies | null = null - try { - if (mode === 'replace-imported-domains') { - try { - // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact - // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain - // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's - // live session into the removal scope with nothing written back. plan.writes is already - // family-closed, and using the same array for both makes them impossible to diverge. - const replacementDomains = plan.writes.map((cookie) => cookie.domain) - replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) - diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) - } catch (err) { - diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) - return { - ok: false, - reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') - } - } - } - - // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. - const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') - phase = await writeImportedCookies( - cookieClearStore, - plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), - { stopOnFailure: replaced !== null, log: diag } - ) - // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable - // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned - // writes reach the writer, so the count comes from the plan and is added exactly once. - skipped += phase.writeRejected - - if (phase.failure && replaced) { - const rollbackFailures: unknown[] = [] - for (const cookie of phase.attemptedKeys.toReversed()) { - try { - await cookieClearStore.remove(cookie.url, cookie.name) - } catch (err) { - rollbackFailures.push(err) - } - } - // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty - // restore set would spin up a hidden BrowserWindow to put nothing back. - if (replaced.identities.length > 0) { - try { - await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) - } catch (err) { - rollbackFailures.push(err) - } - } - if (rollbackFailures.length > 0) { - diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) - } - return { - ok: false, - reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') - } - } - } finally { - try { - cookieClearStore.dispose() - } finally { - releaseMutationLock() - } - } - } - - diag( - `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` - ) - - const summary: BrowserCookieImportSummary = { - totalCookies: totalInput, - importedCookies: phase.importedCount, - skippedCookies: skipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...phase.domains].sort() - } - - return { ok: true, profileId: '', summary } -} - -// --------------------------------------------------------------------------- -// Import from JSON file -// --------------------------------------------------------------------------- - -// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { - const opts = { - title: 'Import Cookies', - filters: [ - { name: 'Cookie Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile' as const] - } - const result = parentWindow - ? await dialog.showOpenDialog(parentWindow, opts) - : await dialog.showOpenDialog(opts) - - if (result.canceled || result.filePaths.length === 0) { - return null - } - return result.filePaths[0] + return pickFile(parentWindow) } export async function importCookiesFromFile( filePath: string, targetPartition: string ): Promise { - let rawContent: string - try { - rawContent = await readFile(filePath, 'utf-8') - } catch { - return { ok: false, reason: 'Could not read the selected file.' } - } - - let parsed: unknown - try { - parsed = JSON.parse(rawContent) - } catch { - return { ok: false, reason: 'File is not valid JSON.' } - } - - if (!Array.isArray(parsed)) { - return { ok: false, reason: 'Expected a JSON array of cookie objects.' } - } - - if (parsed.length === 0) { - return { ok: false, reason: 'Cookie file is empty.' } - } - - const validated: ValidatedCookie[] = [] - let skipped = 0 - for (const entry of parsed) { - if (typeof entry !== 'object' || entry === null) { - skipped++ - continue - } - const cookie = validateCookieEntry(entry as RawCookieEntry) - if (cookie) { - validated.push(cookie) - } else { - skipped++ - } - } - - if (validated.length === 0) { - return { - ok: false, - reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` - } - } - - return importValidatedCookies( - validated, - parsed.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) -} - -const PBKDF2_ITERATIONS = 1003 -const PBKDF2_KEY_LENGTH = 16 -const PBKDF2_SALT = 'saltysalt' - -const CHROMIUM_EPOCH_OFFSET = 11644473600n - -function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { - if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { - return 0 - } - try { - const ts = - typeof chromiumTs === 'bigint' - ? chromiumTs - : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) - if (ts === 0n) { - return 0 - } - return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) - } catch { - return 0 - } -} - -// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. - -type EncryptionKeyResult = - | { - mode: 'aes-128-cbc' - keysByVersion: Partial> - keyringUnavailable?: boolean - } - | { mode: 'aes-256-gcm'; key: Buffer } - -export type ChromiumCookieColumnInfo = { - name: string - type?: string - notnull?: number | bigint - dflt_value?: unknown -} - -function parseSqliteDefaultValue(raw: unknown, type: string): string | number | Buffer | null { - if (raw === null || raw === undefined) { - return null - } - if (typeof raw !== 'string') { - return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) - } - - const trimmed = raw.trim() - if (!trimmed || trimmed.toUpperCase() === 'NULL') { - return null - } - if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { - return Buffer.alloc(0) - } - if ( - (trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"')) - ) { - return trimmed.slice(1, -1).replaceAll("''", "'") - } - if (type.includes('INT')) { - const numeric = Number(trimmed) - return Number.isFinite(numeric) ? numeric : 0 - } - return trimmed -} - -function normalizeSqliteCookieValue(value: unknown): string | number | bigint | Buffer | null { - if (value instanceof Uint8Array) { - return Buffer.from(value) - } - if (value === undefined || value === null) { - return null - } - if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { - return value - } - return String(value) -} - -function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { - return Number(column.notnull ?? 0) !== 0 -} - -function fallbackChromiumCookieColumnValue( - column: ChromiumCookieColumnInfo, - sourceRow: Record -): string | number | bigint | Buffer | null { - const type = (column.type ?? '').toUpperCase() - const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) - if (defaultValue !== null) { - return defaultValue - } - if (!isSqliteNotNull(column)) { - return null - } - - switch (column.name) { - case 'value': - case 'encrypted_value': - return Buffer.alloc(0) - case 'top_frame_site_key': - return '' - case 'source_port': - return -1 - case 'last_update_utc': - return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 - default: - if (type.includes('BLOB')) { - return Buffer.alloc(0) - } - if (type.includes('INT')) { - return 0 - } - return '' - } + return importFile(filePath, targetPartition) } export function buildChromiumCookieInsertParams( @@ -970,1247 +50,23 @@ export function buildChromiumCookieInsertParams( sourceRow: Record, decryptedValue: Buffer ): (string | number | bigint | Buffer | null)[] { - return targetColumns.map((column) => { - if (column.name === 'encrypted_value') { - return Buffer.alloc(0) - } - if (column.name === 'value') { - return decryptedValue - } - - const sourceHasColumn = Object.hasOwn(sourceRow, column.name) - const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null - if (sourceValue !== null) { - return sourceValue - } - if (sourceHasColumn && !isSqliteNotNull(column)) { - return null - } - - // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. - return fallbackChromiumCookieColumnValue(column, sourceRow) - }) + return buildInsertParams(targetColumns, sourceRow, decryptedValue) } -function getEncryptionKey( - keychainService: string, - keychainAccount: string, - browser?: DetectedBrowser -): EncryptionKeyResult | null { - if (process.platform === 'darwin') { - return getMacEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'linux') { - return getLinuxEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'win32' && browser) { - return getWindowsEncryptionKey(browser) - } - return null -} - -function getMacEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - try { - const raw = execFileSync( - 'security', - ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], - { encoding: 'utf-8', timeout: 30_000 } - ).trim() - return { - mode: 'aes-128-cbc', - keysByVersion: { - v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') - } - } - } catch { - return null - } -} - -function getLinuxEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded - // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. - const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - - let keyringPassword = '' - try { - // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. - keyringPassword = execFileSync( - 'secret-tool', - ['lookup', 'service', keychainService, 'account', keychainAccount], - { encoding: 'utf-8', timeout: 5_000 } - ).trim() - } catch { - // Why: fall back to application-based lookup used by newer Chromium versions. - try { - const app = keychainAccount.toLowerCase().replaceAll(' ', '') - keyringPassword = execFileSync('secret-tool', ['lookup', 'application', app], { - encoding: 'utf-8', - timeout: 5_000 - }).trim() - } catch { - diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') - } - } - - if (!keyringPassword) { - return { - mode: 'aes-128-cbc', - keysByVersion: { v10: v10Key }, - keyringUnavailable: true - } - } - - const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } -} - -function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - - const localStatePath = join(root, 'Local State') - if (!existsSync(localStatePath)) { - return null - } - - try { - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const encryptedKeyB64 = localState?.os_crypt?.encrypted_key - if (typeof encryptedKeyB64 !== 'string') { - return null - } - - const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') - const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') - if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { - return null - } - - // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. - const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') - const script = [ - 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', - 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', - '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', - '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', - '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', - '[Convert]::ToBase64String($out)' - ].join('') - - // Why runProcessSync and an absolute path: a bare `powershell` spawn from a - // GUI-subsystem process opens a visible conhost that takes foreground, so - // keystrokes typed into an Orca terminal during a cookie import land in the - // black box (#14543), and PATH under Electron is not the user's (#11771). - const result = runProcessSync({ - program: windowsPowerShellPath(), - args: ['-NoProfile', '-NonInteractive', '-Command', script], - timeoutMs: 10_000, - input: dpapiData - }) - if (result.code !== 0 || result.timedOut) { - diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') - return null - } - - return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } - } catch (err) { - diag(` Windows DPAPI key extraction failed: ${String(err)}`) - return null - } -} - -// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. -const CHROMIUM_COOKIE_HMAC_LEN = 32 - -function hasHmacPrefix(buf: Buffer): boolean { - if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { - return false - } - let nonPrintable = 0 - for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { - if (buf[i] < 0x20 || buf[i] > 0x7e) { - nonPrintable++ - } - } - return nonPrintable >= 8 -} - -function stripHmac(buf: Buffer): Buffer { - return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf -} - -// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and -// share it between the decrypt path and the failure attribution. -function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { - if (encryptedBuffer.length < 3) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - return /^v\d\d$/.test(version) ? version : null -} - -// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which -// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { - return cookieEncryptionVersion(encryptedBuffer) === 'v20' + return isAppBoundCookie(encryptedBuffer) } -// Why: a named cause must carry only its exact count; tied causes fall back to unknown. -function buildUndecryptableWarning(counts: { - decryptFailed: number - appBoundFailed: number - keyringUnavailableFailed: number -}): BrowserCookieImportSummary['warning'] { - if (counts.decryptFailed === 0) { - return undefined - } - const unknownFailed = - counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed - const rankedCauses = [ - { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, - { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, - { reason: 'unknown' as const, count: unknownFailed } - ].sort((left, right) => right.count - left.count) - const [dominant, runnerUp] = rankedCauses - - if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { - return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } - } - - const otherFailedCookies = counts.decryptFailed - dominant.count - return { - code: 'cookies-undecryptable', - failedCookies: dominant.count, - reason: dominant.reason, - ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) - } -} - -function decryptCookieValueRaw( - encryptedBuffer: Buffer, - keyResult: EncryptionKeyResult -): Buffer | null { - if (!encryptedBuffer || encryptedBuffer.length === 0) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - if (!/^v\d\d$/.test(version)) { - return null - } - - if (keyResult.mode === 'aes-256-gcm') { - return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) - } - - // AES-128-CBC (macOS and Linux) - const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined - if (!key) { - return null - } - - const ciphertext = encryptedBuffer.subarray(3) - if (!ciphertext.length) { - return null - } - - try { - const iv = Buffer.alloc(16, ' ') - const decipher = createDecipheriv('aes-128-cbc', key, iv) - decipher.setAutoPadding(true) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { - // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] - if (payload.length < 12 + 16) { - return null - } - const nonce = payload.subarray(0, 12) - const authTag = payload.subarray(-16) - const ciphertext = payload.subarray(12, -16) - try { - const decipher = createDecipheriv('aes-256-gcm', key, nonce) - decipher.setAuthTag(authTag) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -// --------------------------------------------------------------------------- -// Safari binary cookie parser -// --------------------------------------------------------------------------- - -function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { - if (buffer.length < 8) { - return [] - } - if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { - return [] - } - - const pageCount = buffer.readUInt32BE(4) - let cursor = 8 - if (cursor + pageCount * 4 > buffer.length) { - return [] - } - const pageSizes: number[] = [] - for (let i = 0; i < pageCount; i++) { - pageSizes.push(buffer.readUInt32BE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const pageSize of pageSizes) { - const page = buffer.subarray(cursor, cursor + pageSize) - cursor += pageSize - appendSafariCookies(cookies, decodeSafariPage(page)) - } - return cookies -} - -function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void { - // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. - for (const cookie of cookies) { - target.push(cookie) - } -} - -function decodeSafariPage(page: Buffer): ValidatedCookie[] { - if (page.length < 16) { - return [] - } - if (page.readUInt32BE(0) !== 0x00000100) { - return [] - } - - const cookieCount = page.readUInt32LE(4) - if (8 + cookieCount * 4 > page.length) { - return [] - } - const offsets: number[] = [] - let cursor = 8 - for (let i = 0; i < cookieCount; i++) { - offsets.push(page.readUInt32LE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const offset of offsets) { - const cookie = decodeSafariCookie(page.subarray(offset)) - if (cookie) { - cookies.push(cookie) - } - } - return cookies -} - -function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { - if (buf.length < 48) { - return null - } - // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. - const size = Math.min(buf.readUInt32LE(0), buf.length) - if (size < 48) { - return null - } - - const flags = buf.readUInt32LE(8) - const secure = (flags & 1) !== 0 - const httpOnly = (flags & 4) !== 0 - - const urlOffset = buf.readUInt32LE(16) - const nameOffset = buf.readUInt32LE(20) - const pathOffset = buf.readUInt32LE(24) - const valueOffset = buf.readUInt32LE(28) - - // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). - const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 - - const name = readCString(buf, nameOffset, size) - if (!name) { - return null - } - const value = readCString(buf, valueOffset, size) ?? '' - const path = readCString(buf, pathOffset, size) ?? '/' - const rawUrl = readCString(buf, urlOffset, size) ?? '' - - // Why: Safari stores the domain in the URL field, not as a separate domain column. - const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null - if (!domain) { - return null - } - - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined - - return { - url, - name, - value, - domain, - path, - secure, - httpOnly, - sameSite: 'unspecified', - expirationDate, - // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every - // decoded cookie is genuinely unpartitioned rather than missing an identity. - partition: { status: 'unpartitioned' } - } -} - -function readCString(buf: Buffer, offset: number, end: number): string | null { - if (offset < 0 || offset >= end) { - return null - } - let cursor = offset - while (cursor < end && buf[cursor] !== 0) { - cursor++ - } - if (cursor >= end) { - return null - } - return buf.toString('utf8', offset, cursor) -} - -// --------------------------------------------------------------------------- -// Firefox import -// --------------------------------------------------------------------------- - -async function importCookiesFromFirefox( - browser: DetectedBrowser, - targetPartition: string, - options: CookieImportOptions -): Promise { - diag(`importCookiesFromFirefox: partition="${targetPartition}"`) - - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) - const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') - - try { - copyFileSync(browser.cookiesPath, tmpCookiesPath) - for (const suffix of ['-wal', '-shm'] as const) { - const sidecar = browser.cookiesPath + suffix - if (existsSync(sidecar)) { - try { - copyFileSync(sidecar, tmpCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - } catch { - rmSync(tmpDir, { recursive: true, force: true }) - return { - ok: false, - reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' - } - } - - try { - const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) - type FirefoxRow = Record & { - name: string - value: string - host: string - path: string - expiry: number - isSecure: number - isHttpOnly: number - sameSite: number - isPartitionedAttributeSet?: number - } - // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema - // without the server-declared partition flag predates that cookie identity. - const firefoxColumns = new Set( - (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( - (column) => column.name - ) - ) - const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') - ? ', isPartitionedAttributeSet' - : '' - const rows = db - .prepare( - `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` - ) - .all() as FirefoxRow[] - db.close() - - diag(` Firefox source has ${rows.length} cookies`) - if (rows.length === 0) { - rmSync(tmpDir, { recursive: true, force: true }) - return { ok: false, reason: 'No cookies found in Firefox.' } - } - - const now = Math.floor(Date.now() / 1000) - const validated: ValidatedCookie[] = [] - for (const row of rows) { - if (!row.name || !row.host) { - continue - } - if (row.expiry > 0 && row.expiry < now) { - continue - } - - const domain = row.host - const secure = row.isSecure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - continue - } - - validated.push({ - url, - name: row.name, - value: row.value ?? '', - domain, - path: row.path || '/', - secure, - httpOnly: row.isHttpOnly === 1, - sameSite: firefoxSameSite(row.sameSite), - expirationDate: row.expiry > 0 ? row.expiry : undefined, - partition: readFirefoxRowPartition(row, firefoxColumns) - }) - } - - rmSync(tmpDir, { recursive: true, force: true }) - - if (validated.length === 0) { - return { ok: false, reason: 'No valid cookies found in Firefox.' } - } - - return importValidatedCookies( - validated, - rows.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains', - options - ) - } catch (err) { - rmSync(tmpDir, { recursive: true, force: true }) - diag(` Firefox import failed: ${String(err)}`) - return { - ok: false, - reason: 'Could not import cookies from Firefox. Try closing Firefox first.' - } - } -} - -// --------------------------------------------------------------------------- -// Safari import -// --------------------------------------------------------------------------- - -async function importCookiesFromSafari( - browser: DetectedBrowser, - targetPartition: string -): Promise { - diag(`importCookiesFromSafari: partition="${targetPartition}"`) - - let data: Buffer - try { - data = readFileSync(browser.cookiesPath) - } catch (err) { - diag(` Safari read failed: ${String(err)}`) - // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. - const isPermError = - err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' - if (isPermError) { - return { - ok: false, - reason: - 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' - } - } - return { ok: false, reason: 'Could not read Safari cookies.' } - } - - try { - const cookies = decodeSafariBinaryCookies(data) - diag(` Safari source has ${cookies.length} cookies`) - - if (cookies.length === 0) { - return { ok: false, reason: 'No cookies found in Safari.' } - } - - const now = Math.floor(Date.now() / 1000) - const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) - - if (valid.length === 0) { - return { ok: false, reason: 'All Safari cookies are expired.' } - } - - return importValidatedCookies( - valid, - cookies.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) - } catch (err) { - diag(` Safari import failed: ${String(err)}`) - return { ok: false, reason: 'Could not import cookies from Safari.' } - } -} - -// --------------------------------------------------------------------------- -// Import dispatcher -// --------------------------------------------------------------------------- - export async function importCookiesFromBrowser( browser: DetectedBrowser, targetPartition: string, options: CookieImportOptions = {} ): Promise { - diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) - if (!existsSync(browser.cookiesPath)) { - diag(` cookies DB not found: ${browser.cookiesPath}`) - return { ok: false, reason: `${browser.label} cookies database not found.` } - } - if (browser.family === 'firefox') { return importCookiesFromFirefox(browser, targetPartition, options) } if (browser.family === 'safari') { return importCookiesFromSafari(browser, targetPartition) } - - // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. - - // Why: CookieMonster can reject otherwise valid imported bytes, so stage a populated copy whose - // imported-domain rows can be merged into the live DB on the next cold start. - const targetSession = session.fromPartition(targetPartition) - // Why (STA-4601): native imports mutate the live jar and their staged image before the old - // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, - // live replacement, pending-image bookkeeping, and cleanup so an older image cannot race a newer - // import on the same partition. - return withCookieMutationLock(targetSession, async () => { - await targetSession.cookies.flushStore() - - // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from - // the caller's partition string. String surgery on a caller-supplied name is what let a value like - // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement - // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. - const partitionDir = targetSession.getStoragePath() - if (!partitionDir) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - const partitionName = targetPartition.replace('persist:', '') - let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - - // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. - // Why (STA-4601): this probe MUTATES the live jar, so it runs under the same per-partition lock as - // the import itself. An earlier revision left it outside on the argument that no import writes - // https://localhost/__init — that was wrong. normalizeCookieImportDomain accepts `localhost`, - // cookie names are unrestricted, and deriveUrl produces exactly this URL, so an import CAN write - // that coordinate. Unlocked, this probe's remove() would delete a cookie a concurrent import had - // just written and reported as imported. The cost is negligible: the probe only runs for a - // partition that has never stored a cookie, so it is at most a one-time wait per profile. - if (!liveCookiesPath) { - try { - await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) - await targetSession.cookies.remove('https://localhost', '__init') - await targetSession.cookies.flushStore() - } catch { - // ignore — the set/remove may fail but flushStore should still create the file - } - liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - } - - if (!liveCookiesPath) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - - const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') - const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') - const stagingCookiesPath = join( - stagingDir, - `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` - ) - // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory - // import rejects, so losing it must degrade that fallback rather than abort the import. - let stagingAvailable = false - // Why: a client-hosted route partition is derived at runtime and never reaches the startup - // replay, so staging it would only leave a plaintext cookie DB nothing ever consumes. - if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { - diag( - ` restart fallback unsupported for partition "${targetPartition}" — not staging cookies` - ) - } else { - try { - mkdirSync(stagingDir, { recursive: true }) - copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) - stagingAvailable = true - } catch (err) { - const fsErr = err as NodeJS.ErrnoException - diag( - ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` - ) - // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - } - } - - let sourceSnapshot: ChromiumCookieSnapshot - try { - // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. - sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) - } catch (err) { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - diag(` Chromium snapshot failed: ${String(err)}`) - return { - ok: false, - reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` - } - } - - let sourceDb: InstanceType | null = null - let stagingDb: InstanceType | null = null - const closeStagingDb = (): void => { - try { - stagingDb?.close() - } catch { - /* best-effort */ - } - stagingDb = null - } - const discardStagingFile = (): void => { - // Why: the staged copy holds plaintext cookie values, and SQLite may have left sidecars beside it. - for (const suffix of ['', '-wal', '-shm']) { - try { - unlinkSync(stagingCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - - try { - // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. - sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { - readOnly: true, - readBigInts: true - }) - let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null - let colList: string | null = null - let placeholders: string | null = null - if (stagingAvailable) { - // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same - // transient AV handle can make opening it throw — degrade instead of killing the import. - try { - stagingDb = new DatabaseSync(stagingCookiesPath) - // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL - // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image - // import, restoring the unrelated-cookie data loss this format is meant to prevent. - stagingDb.exec('PRAGMA journal_mode = DELETE') - targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols: string[] = targetColumnInfo.map((r) => r.name) - colList = targetCols.join(', ') - placeholders = targetCols.map(() => '?').join(', ') - } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${String(err)}`) - stagingAvailable = false - targetColumnInfo = null - colList = null - placeholders = null - closeStagingDb() - // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. - discardStagingFile() - } - } - - // Why (STA-4300): the partition columns drift across Chromium versions, so read the source - // schema rather than assuming a row's missing column means "unpartitioned". - const sourceColumns = new Set( - (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( - (column) => column.name - ) - ) - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null - - diag(` source has ${sourceRows.length} cookies`) - - if (sourceRows.length === 0) { - closeStagingDb() - discardStagingFile() - return { ok: false, reason: `No cookies found in ${browser.label}.` } - } - - // Why (STA-4300): partition fidelity is a property of the source row, even when its value - // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. - const partitionCandidates = sourceRows.flatMap((sourceRow) => { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) - ? [] - : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] - }) - const nativePlan = planImportWrites(partitionCandidates) - const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) - const partitionBySourceRow = new Map( - partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) - ) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a - // family we cannot protect is the P0. Refuse before the jar is touched. - if (nativePlan.hasUnrepresentableSkip) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - const needsSourceKey = sourceRows.some((sourceRow) => { - const encRaw = sourceRow.encrypted_value - if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { - return false - } - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) - }) - const sourceKey = needsSourceKey - ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - : null - if (needsSourceKey && !sourceKey) { - closeStagingDb() - // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - discardStagingFile() - return { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` - } - } - - let imported = 0 - let skipped = 0 - let decryptFailed = 0 - let appBoundFailed = 0 - let keyringUnavailableFailed = 0 - let integritySkipped = 0 - let nonTransplantableSkipped = 0 - const partitionSkipped = nativePlan.skips.length - let memoryLoaded = 0 - let memoryFailed = 0 - const domainSet = new Set() - - type DecryptedCookie = Omit & { - decryptedValue: Buffer - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead - } - - const decryptedCookies: DecryptedCookie[] = [] - // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. - // A plan record holding only the derived fields compiles fine and then cannot stage. - const scanned: { entry: DecryptedCookie; sourceRow: Record }[] = [] - const sourceDomainValidity = new Map() - - // Why: staging only backs the cold-restart replay, so any failure writing it disables that - // fallback instead of aborting an import whose in-memory half still works. - let insertStmt: ReturnType['prepare']> | null = null - const disableStaging = (reason: string): void => { - diag(` staging disabled, restart fallback unavailable: ${reason}`) - stagingAvailable = false - insertStmt = null - closeStagingDb() - discardStagingFile() - } - - if (stagingDb && colList && placeholders) { - try { - insertStmt = stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` - ) - stagingDb.exec('BEGIN TRANSACTION') - } catch (err) { - disableStaging(String(err)) - } - } else if (stagingAvailable) { - disableStaging('staged database exposed no cookies columns') - } - - // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding - // partial-import restart behavior is separate from narrowing what a staged replay may replace. - if (nativePlan.skippedFamilies.size > 0) { - disableStaging( - `${nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` - ) - } - - for (const sourceRow of sourceRows) { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - - if (isGoogleSourceBoundCookie(name, domain)) { - integritySkipped++ - continue - } - - // Why: transplanting these replaces a working sign-in with a session the site rejects. - if (isNonTransplantableCookieDomain(domain)) { - nonTransplantableSkipped++ - continue - } - - const encRaw = sourceRow.encrypted_value - // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. - const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null - const plainRaw = sourceRow.value - - let decryptedValue: Buffer - if (encBuf && encBuf.length > 0) { - const version = cookieEncryptionVersion(encBuf) - const appBoundIneligible = version === 'v20' - const keyringIneligible = - version === 'v11' && - sourceKey?.mode === 'aes-128-cbc' && - sourceKey.keyringUnavailable === true - const raw = - sourceKey && !appBoundIneligible && !keyringIneligible - ? decryptCookieValueRaw(encBuf, sourceKey) - : null - if (!raw) { - // Why: once decrypt returns null every failure looks identical, so attribute the cause - // here while the version prefix is still in hand. Without this an undecryptable profile - // is indistinguishable from an empty one and reports success. - decryptFailed++ - if (appBoundIneligible) { - appBoundFailed++ - } else if (keyringIneligible) { - keyringUnavailableFailed++ - } - skipped++ - continue - } - decryptedValue = raw - } else if (plainRaw instanceof Uint8Array) { - decryptedValue = Buffer.from(plainRaw) - } else if (typeof plainRaw === 'string') { - decryptedValue = Buffer.from(plainRaw, 'latin1') - } else { - decryptedValue = Buffer.alloc(0) - } - - let validDomain = sourceDomainValidity.get(domain) - if (validDomain === undefined) { - validDomain = normalizeCookieImportDomain(domain) !== null - sourceDomainValidity.set(domain, validDomain) - } - if (!validDomain) { - skipped++ - continue - } - - // Decryption failures are already counted above. Every other row suppressed by the - // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. - if (!plannedSourceRows.has(sourceRow)) { - skipped++ - continue - } - - const path = sourceRow.path as string - const secure = sourceRow.is_secure === 1n - const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) - const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) - const partition = partitionBySourceRow.get(sourceRow)! - // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. - const value = decryptedValue.toString('latin1') - - // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not - // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and - // THEN applied the unreadable guard, so an unreadable row discovered late could not retract - // a sibling already emitted, and the jar-wide clear then removed more than was written back. - scanned.push({ - entry: { - decryptedValue, - value, - domain, - name, - path, - secure, - httpOnly, - sameSite, - expirationDate: expiresUtc > 0 ? expiresUtc : undefined, - partition - }, - sourceRow - }) - } - - for (const { entry } of scanned) { - domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) - } - // Why (STA-4797): the import may only destroy what it is replacing. Naming the scope from the - // plan — the same rows the writes come from — is what keeps the removal set from drifting past - // the write set, and it is derived here rather than at the clear because the staged image below - // has to be cleared to the identical scope. - const importScope = importedDomainScope([...domainSet]) - - // Why (STA-4797): the staged image must carry the same imported-domain scope as the live clear. - // Cold-start replay uses it to replace only those rows and preserve newer unrelated sessions. - if (stagingDb && insertStmt) { - try { - prepareStagedCookiesForImport(stagingDb, importScope) - } catch (err) { - disableStaging(String(err)) - } - } - - // EMIT: everything downstream derives from the plan, so there is no second place a row can - // leak in. - for (const { entry, sourceRow } of scanned) { - decryptedCookies.push(entry) - if (insertStmt && targetColumnInfo) { - try { - const params = buildChromiumCookieInsertParams( - targetColumnInfo, - sourceRow, - entry.decryptedValue - ) - insertStmt.run(...params) - } catch (err) { - disableStaging(String(err)) - } - } - // Why: counts importable cookies, not staged rows — the summary must stay truthful when - // the optional staging DB is unavailable. - imported++ - } - diag( - ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` - ) - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - - const undecryptableWarning = buildUndecryptableWarning({ - decryptFailed, - appBoundFailed, - keyringUnavailableFailed - }) - - // Why: an older remote client ignores the new counter and would present this loss as success. - // Placed before the early return and before any jar mutation, so a client that cannot render - // the skip fails the import outright rather than reporting a partial import as complete. - if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - - if (decryptedCookies.length === 0) { - const zeroPathWarning = undecryptableWarning - closeStagingDb() - discardStagingFile() - return { - ok: true, - profileId: '', - summary: { - totalCookies: sourceRows.length, - importedCookies: 0, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so - // totalCookies === importedCookies + skippedCookies keeps holding on this path too. - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [], - // Why: a profile whose rows cannot be decrypted returns here, and without this it is - // reported as a successful empty import. - ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) - } - } - } - - if (stagingDb) { - try { - stagingDb.exec('COMMIT') - closeStagingDb() - diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) - } catch (err) { - disableStaging(String(err)) - } - } else { - diag(` staging skipped: ${imported} cookies will load in-memory only`) - } - - // Why: clear stale cookies for the domains being imported first; mixing them with the imported - // set makes sites reject the session. Non-transplantable families are exempt — nothing was - // imported for them, and their live session is the only one that works. - // Why (STA-4797): every other site in the partition is exempt too. The rationale above reaches - // only as far as the domains this import writes; beyond them a clear has nothing to reconcile - // and only signs the user out of sessions the import was never about. - // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak - // the same CDP identities — cookies.set() cannot express the partition either one reads. - const cookieClearStore = openCookieClearStore(targetSession) - try { - // Why (STA-4601): the outer lock spans the clear and the writes that repopulate the jar, so a - // second import cannot clear between them and write on top of a newer import's jar. - await removeTransplantableCookies( - { - cookies: cookieClearStore, - snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), - restoreClearIdentities: (identities) => - cookieClearStore.restoreClearIdentities(identities) - }, - // Why (STA-4300): the families this import declined to write must not be removed either. - // Passing them here keeps their coordinates out of the removal plan AND out of the CDP - // snapshot taken from it, so they are never submitted to any mutation. - nativePlan.skippedFamilies, - importScope - ) - diag( - ` cleared existing cookies for ${domainSet.size} imported domains before loading ${decryptedCookies.length} imported cookies` - ) - - const writable: SourceCookieToWrite[] = [] - for (const cookie of decryptedCookies) { - const url = deriveUrl(cookie.domain, cookie.secure) - if (!url) { - memoryFailed++ - continue - } - writable.push({ ...cookie, url }) - } - // Why: a rejected cookie here falls back to the staged cold-start replay rather than - // unwinding the import, so one failure must not stop the rest from loading. - const phase = await writeImportedCookies(cookieClearStore, writable, { - stopOnFailure: false, - log: diag - }) - memoryLoaded = phase.importedCount - memoryFailed += phase.writeRejected - } finally { - cookieClearStore.dispose() - } - - diag( - ` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable` - ) - - let warning: BrowserCookieImportSummary['warning'] - if (memoryFailed > 0 && stagingAvailable) { - // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. - browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) - diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) - } else if (memoryFailed > 0) { - // Why: never register a path that was never written or can never be replayed — cold start - // would replay a missing or partial DB over the live partition. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) - // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. - warning = { - code: 'restart-fallback-unavailable', - loadedCookies: memoryLoaded, - failedCookies: memoryFailed - } - } else { - // Why: this import already rewrote the live session, so an older staged DB must not replay over it. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` all cookies loaded in-memory — no restart needed`) - } - - // Why: the session keeps the UA the registry set at startup (clean or native). - // Imports must not impersonate the source browser — the synthesized UA read a - // fork's marketing version as a Chromium version (STA-3514), and Google binds - // sessions to the re-import, not the UA (#12884), so it bought nothing. - // Google-bound integrity cookies are already excluded by - // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. - - // Why: a partial import still drops every undecryptable row, so silence here would report it - // as an unqualified success. The restart-fallback warning describes a lossier outcome and - // keeps precedence. - if (!warning && undecryptableWarning) { - warning = undecryptableWarning - } - - const summary: BrowserCookieImportSummary = { - totalCookies: sourceRows.length, - importedCookies: imported, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...domainSet].sort(), - ...(warning ? { warning } : {}) - } - - return { ok: true, profileId: '', summary } - } catch (err) { - try { - sourceDb?.close() - } catch { - /* may already be closed */ - } - try { - stagingDb?.close() - } catch { - /* may already be closed */ - } - // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* may not exist yet */ - } - diag(` SQLite import failed: ${String(err)}`) - return { - ok: false, - reason: reasonWithDiagLog( - `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` - ) - } - } finally { - try { - sourceSnapshot.cleanup() - } catch (err) { - diag(` Chromium snapshot cleanup failed: ${String(err)}`) - } - } - }) + return importChromiumCookies(browser, targetPartition, options) } diff --git a/src/main/browser/browser-cookie-key.ts b/src/main/browser/browser-cookie-key.ts new file mode 100644 index 00000000000..620b279c140 --- /dev/null +++ b/src/main/browser/browser-cookie-key.ts @@ -0,0 +1,158 @@ +import { execFileSync } from 'node:child_process' +import { pbkdf2Sync } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { runProcessSync } from '../../shared/child-process/run-process' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { diag } from './browser-cookie-import-diagnostics' +import { + CHROMIUM_BROWSERS, + browserRootPath, + type DetectedBrowser +} from './browser-cookie-detection-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +const PBKDF2_ITERATIONS = 1003 +const PBKDF2_KEY_LENGTH = 16 +const PBKDF2_SALT = 'saltysalt' + +export function getEncryptionKey( + keychainService: string, + keychainAccount: string, + browser?: DetectedBrowser +): EncryptionKeyResult | null { + if (process.platform === 'darwin') { + return getMacEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'linux') { + return getLinuxEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'win32' && browser) { + return getWindowsEncryptionKey(browser) + } + return null +} + +export function getMacEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + try { + const raw = execFileSync( + 'security', + ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], + { encoding: 'utf-8', timeout: 30_000 } + ).trim() + return { + mode: 'aes-128-cbc', + keysByVersion: { + v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } + } + } catch { + return null + } +} + +export function getLinuxEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded + // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. + const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + + let keyringPassword = '' + try { + // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. + keyringPassword = execFileSync( + 'secret-tool', + ['lookup', 'service', keychainService, 'account', keychainAccount], + { encoding: 'utf-8', timeout: 5_000 } + ).trim() + } catch { + // Why: fall back to application-based lookup used by newer Chromium versions. + try { + const app = keychainAccount.toLowerCase().replaceAll(' ', '') + keyringPassword = execFileSync('secret-tool', ['lookup', 'application', app], { + encoding: 'utf-8', + timeout: 5_000 + }).trim() + } catch { + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') + } + } + + if (!keyringPassword) { + return { + mode: 'aes-128-cbc', + keysByVersion: { v10: v10Key }, + keyringUnavailable: true + } + } + + const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } +} + +export function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + + const localStatePath = join(root, 'Local State') + if (!existsSync(localStatePath)) { + return null + } + + try { + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const encryptedKeyB64 = localState?.os_crypt?.encrypted_key + if (typeof encryptedKeyB64 !== 'string') { + return null + } + + const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') + const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') + if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { + return null + } + + // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. + const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') + const script = [ + 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', + 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', + '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', + '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', + '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', + '[Convert]::ToBase64String($out)' + ].join('') + + // Why runProcessSync and an absolute path: a bare `powershell` spawn from a + // GUI-subsystem process opens a visible conhost that takes foreground, so + // keystrokes typed into an Orca terminal during a cookie import land in the + // black box (#14543), and PATH under Electron is not the user's (#11771). + const result = runProcessSync({ + program: windowsPowerShellPath(), + args: ['-NoProfile', '-NonInteractive', '-Command', script], + timeoutMs: 10_000, + input: dpapiData + }) + if (result.code !== 0 || result.timedOut) { + diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') + return null + } + + return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } + } catch (err) { + diag(` Windows DPAPI key extraction failed: ${String(err)}`) + return null + } +} diff --git a/src/main/browser/browser-cookie-safari-import.ts b/src/main/browser/browser-cookie-safari-import.ts new file mode 100644 index 00000000000..e8fa79c5fa0 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-import.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { decodeSafariBinaryCookies } from './browser-cookie-safari-parser' +import { importValidatedCookies, cookieImportTarget } from './browser-cookie-import-pipeline' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Safari import +// --------------------------------------------------------------------------- + +export async function importCookiesFromSafari( + browser: DetectedBrowser, + targetPartition: string +): Promise { + diag(`importCookiesFromSafari: partition="${targetPartition}"`) + + let data: Buffer + try { + data = readFileSync(browser.cookiesPath) + } catch (err) { + diag(` Safari read failed: ${String(err)}`) + // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. + const isPermError = + err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' + if (isPermError) { + return { + ok: false, + reason: + 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' + } + } + return { ok: false, reason: 'Could not read Safari cookies.' } + } + + try { + const cookies = decodeSafariBinaryCookies(data) + diag(` Safari source has ${cookies.length} cookies`) + + if (cookies.length === 0) { + return { ok: false, reason: 'No cookies found in Safari.' } + } + + const now = Math.floor(Date.now() / 1000) + const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) + + if (valid.length === 0) { + return { ok: false, reason: 'All Safari cookies are expired.' } + } + + return importValidatedCookies( + valid, + cookies.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) + } catch (err) { + diag(` Safari import failed: ${String(err)}`) + return { ok: false, reason: 'Could not import cookies from Safari.' } + } +} diff --git a/src/main/browser/browser-cookie-safari-parser.ts b/src/main/browser/browser-cookie-safari-parser.ts new file mode 100644 index 00000000000..9c4f7552488 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-parser.ts @@ -0,0 +1,148 @@ +import { deriveUrl } from './browser-cookie-validation' +import type { ValidatedCookie } from './browser-cookie-validation' + +const MAC_EPOCH_DELTA = 978_307_200 + +// --------------------------------------------------------------------------- +// Safari binary cookie parser +// --------------------------------------------------------------------------- + +export function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { + if (buffer.length < 8) { + return [] + } + if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { + return [] + } + + const pageCount = buffer.readUInt32BE(4) + let cursor = 8 + if (cursor + pageCount * 4 > buffer.length) { + return [] + } + const pageSizes: number[] = [] + for (let i = 0; i < pageCount; i++) { + pageSizes.push(buffer.readUInt32BE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const pageSize of pageSizes) { + const page = buffer.subarray(cursor, cursor + pageSize) + cursor += pageSize + appendSafariCookies(cookies, decodeSafariPage(page)) + } + return cookies +} + +export function appendSafariCookies( + target: ValidatedCookie[], + cookies: readonly ValidatedCookie[] +): void { + // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. + for (const cookie of cookies) { + target.push(cookie) + } +} + +export function decodeSafariPage(page: Buffer): ValidatedCookie[] { + if (page.length < 16) { + return [] + } + if (page.readUInt32BE(0) !== 0x00000100) { + return [] + } + + const cookieCount = page.readUInt32LE(4) + if (8 + cookieCount * 4 > page.length) { + return [] + } + const offsets: number[] = [] + let cursor = 8 + for (let i = 0; i < cookieCount; i++) { + offsets.push(page.readUInt32LE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const offset of offsets) { + const cookie = decodeSafariCookie(page.subarray(offset)) + if (cookie) { + cookies.push(cookie) + } + } + return cookies +} + +export function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { + if (buf.length < 48) { + return null + } + // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. + const size = Math.min(buf.readUInt32LE(0), buf.length) + if (size < 48) { + return null + } + + const flags = buf.readUInt32LE(8) + const secure = (flags & 1) !== 0 + const httpOnly = (flags & 4) !== 0 + + const urlOffset = buf.readUInt32LE(16) + const nameOffset = buf.readUInt32LE(20) + const pathOffset = buf.readUInt32LE(24) + const valueOffset = buf.readUInt32LE(28) + + // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). + const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 + + const name = readCString(buf, nameOffset, size) + if (!name) { + return null + } + const value = readCString(buf, valueOffset, size) ?? '' + const path = readCString(buf, pathOffset, size) ?? '/' + const rawUrl = readCString(buf, urlOffset, size) ?? '' + + // Why: Safari stores the domain in the URL field, not as a separate domain column. + const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null + if (!domain) { + return null + } + + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined + + return { + url, + name, + value, + domain, + path, + secure, + httpOnly, + sameSite: 'unspecified', + expirationDate, + // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every + // decoded cookie is genuinely unpartitioned rather than missing an identity. + partition: { status: 'unpartitioned' } + } +} + +export function readCString(buf: Buffer, offset: number, end: number): string | null { + if (offset < 0 || offset >= end) { + return null + } + let cursor = offset + while (cursor < end && buf[cursor] !== 0) { + cursor++ + } + if (cursor >= end) { + return null + } + return buf.toString('utf8', offset, cursor) +} diff --git a/src/main/browser/browser-cookie-sqlite.ts b/src/main/browser/browser-cookie-sqlite.ts new file mode 100644 index 00000000000..05593c79024 --- /dev/null +++ b/src/main/browser/browser-cookie-sqlite.ts @@ -0,0 +1,147 @@ +const CHROMIUM_EPOCH_OFFSET = 11644473600n + +export function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { + if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { + return 0 + } + try { + const ts = + typeof chromiumTs === 'bigint' + ? chromiumTs + : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) + if (ts === 0n) { + return 0 + } + return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) + } catch { + return 0 + } +} + +// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. + +export type EncryptionKeyResult = + | { + mode: 'aes-128-cbc' + keysByVersion: Partial> + keyringUnavailable?: boolean + } + | { mode: 'aes-256-gcm'; key: Buffer } + +export type ChromiumCookieColumnInfo = { + name: string + type?: string + notnull?: number | bigint + dflt_value?: unknown +} + +export function parseSqliteDefaultValue( + raw: unknown, + type: string +): string | number | Buffer | null { + if (raw === null || raw === undefined) { + return null + } + if (typeof raw !== 'string') { + return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) + } + + const trimmed = raw.trim() + if (!trimmed || trimmed.toUpperCase() === 'NULL') { + return null + } + if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { + return Buffer.alloc(0) + } + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (type.includes('INT')) { + const numeric = Number(trimmed) + return Number.isFinite(numeric) ? numeric : 0 + } + return trimmed +} + +export function normalizeSqliteCookieValue( + value: unknown +): string | number | bigint | Buffer | null { + if (value instanceof Uint8Array) { + return Buffer.from(value) + } + if (value === undefined || value === null) { + return null + } + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return value + } + return String(value) +} + +export function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { + return Number(column.notnull ?? 0) !== 0 +} + +export function fallbackChromiumCookieColumnValue( + column: ChromiumCookieColumnInfo, + sourceRow: Record +): string | number | bigint | Buffer | null { + const type = (column.type ?? '').toUpperCase() + const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) + if (defaultValue !== null) { + return defaultValue + } + if (!isSqliteNotNull(column)) { + return null + } + + switch (column.name) { + case 'value': + case 'encrypted_value': + return Buffer.alloc(0) + case 'top_frame_site_key': + return '' + case 'source_port': + return -1 + case 'last_update_utc': + return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 + default: + if (type.includes('BLOB')) { + return Buffer.alloc(0) + } + if (type.includes('INT')) { + return 0 + } + return '' + } +} + +export function buildChromiumCookieInsertParams( + targetColumns: ChromiumCookieColumnInfo[], + sourceRow: Record, + decryptedValue: Buffer +): (string | number | bigint | Buffer | null)[] { + return targetColumns.map((column) => { + if (column.name === 'encrypted_value') { + return Buffer.alloc(0) + } + if (column.name === 'value') { + return decryptedValue + } + + const sourceHasColumn = Object.hasOwn(sourceRow, column.name) + const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null + if (sourceValue !== null) { + return sourceValue + } + if (sourceHasColumn && !isSqliteNotNull(column)) { + return null + } + + // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. + return fallbackChromiumCookieColumnValue(column, sourceRow) + }) +} diff --git a/src/main/browser/browser-cookie-validation.ts b/src/main/browser/browser-cookie-validation.ts new file mode 100644 index 00000000000..6ae543245ef --- /dev/null +++ b/src/main/browser/browser-cookie-validation.ts @@ -0,0 +1,127 @@ +import { normalizeCookieDomain } from './browser-cookie-import-policy' +import { + readJsonCookiePartition, + type SourcePartitionRead +} from './browser-cookie-source-partition' +import type { ImportedCookieFields } from './browser-cookie-import-write' + +export type RawCookieEntry = { + domain?: unknown + name?: unknown + value?: unknown + path?: unknown + secure?: unknown + httpOnly?: unknown + sameSite?: unknown + expirationDate?: unknown + partitionKey?: unknown + partitionKeyOpaque?: unknown +} + +// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to +// state what it read. An optional field would let a new source silently default to unpartitioned. +export type ValidatedCookie = ImportedCookieFields & { + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. +export function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 1: + return 'no_restriction' + case 2: + return 'lax' + case 3: + return 'strict' + default: + return 'unspecified' + } +} + +export function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 0: + return 'no_restriction' + case 1: + return 'lax' + case 2: + return 'strict' + default: + return 'unspecified' + } +} + +export function normalizeSameSite( + raw: unknown +): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + if (typeof raw === 'number') { + return chromiumSameSite(raw) + } + if (typeof raw !== 'string') { + return 'unspecified' + } + const lower = raw.toLowerCase() + if (lower === 'lax') { + return 'lax' + } + if (lower === 'strict') { + return 'strict' + } + if (lower === 'none' || lower === 'no_restriction') { + return 'no_restriction' + } + return 'unspecified' +} + +// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. +export function deriveUrl(domain: string, secure: boolean): string | null { + const normalizedDomain = normalizeCookieDomain(domain) + if (!normalizedDomain) { + return null + } + const protocol = secure ? 'https' : 'http' + try { + const url = new URL(`${protocol}://${normalizedDomain}/`) + return url.toString() + } catch { + return null + } +} + +export function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { + if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { + return null + } + if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { + return null + } + if (typeof raw.value !== 'string') { + return null + } + + const domain = raw.domain.trim() + const secure = raw.secure === true || raw.secure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = + typeof raw.expirationDate === 'number' && raw.expirationDate > 0 + ? raw.expirationDate + : undefined + + return { + url, + name: raw.name.trim(), + value: raw.value, + domain, + path: typeof raw.path === 'string' ? raw.path : '/', + secure, + httpOnly: raw.httpOnly === true || raw.httpOnly === 1, + sameSite: normalizeSameSite(raw.sameSite), + expirationDate, + partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) + } +} diff --git a/src/main/browser/browser-manager-bindings.ts b/src/main/browser/browser-manager-bindings.ts new file mode 100644 index 00000000000..931c5c6bc3b --- /dev/null +++ b/src/main/browser/browser-manager-bindings.ts @@ -0,0 +1,89 @@ +import { resolveRendererWebContents } from './browser-guest-renderer-target' +import { setupGuestContextMenu } from './browser-guest-context-menu' +import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' +import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' +import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' +import { BrowserManagerGrab } from './browser-manager-grab' + +export abstract class BrowserManagerBindings extends BrowserManagerGrab { + protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { + this.contextMenuCleanupByTabId.set( + browserTabId, + setupGuestContextMenu({ + browserTabId, + guest, + resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) + }) + ) + } + + // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. + protected setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + + this.grabShortcutCleanupByTabId.set( + browserTabId, + setupGrabShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), + getKeybindings: () => this.settingsResolver?.().keybindings + }) + ) + } + + // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. + protected setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + + this.shortcutForwardingCleanupByTabId.set( + browserTabId, + setupGuestShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, + isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, + getKeybindings: () => this.settingsResolver?.().keybindings, + resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, + resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null + }) + ) + } + + protected setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + + this.mouseWheelZoomCleanupByTabId.set( + browserTabId, + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + isViewportPresetActive: () => { + const state = this.viewportPresetActiveByTabId.get(browserTabId) + return state?.guestWebContentsId === guest.id && state.active + }, + canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), + onViewportWheelConsumed: (deltaX, deltaY) => + this.recordViewportScrollDelta(browserTabId, deltaX, deltaY) + }) + ) + } +} diff --git a/src/main/browser/browser-manager-download-creation.ts b/src/main/browser/browser-manager-download-creation.ts new file mode 100644 index 00000000000..fcef84b9809 --- /dev/null +++ b/src/main/browser/browser-manager-download-creation.ts @@ -0,0 +1,188 @@ +import { randomUUID } from 'node:crypto' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { routeBrowserClientDownload } from './browser-client-download-routing' +import { + safeOrigin, + type ActiveDownload, + type BrowserDownloadDoneState +} from './browser-manager-types' +import type { BrowserDownloadFinishedEvent } from '../../shared/browser-guest-events' +import { BrowserManagerQueries } from './browser-manager-queries' + +export abstract class BrowserManagerDownloadCreation extends BrowserManagerQueries { + handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { + const { guestWebContentsId, item } = args + const downloadId = randomUUID() + const requestedFilename = (() => { + try { + return item.getFilename() || 'download' + } catch { + return 'download' + } + })() + const totalBytes = (() => { + try { + const total = item.getTotalBytes() + return total > 0 ? total : null + } catch { + return null + } + })() + const mimeType = (() => { + try { + const mime = item.getMimeType() + return mime || null + } catch { + return null + } + })() + const origin = (() => { + try { + return safeOrigin(item.getURL()) + } catch { + return 'unknown' + } + })() + + // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself + // instead of reserving a name in the desktop Downloads folder. A popup downloads to its + // opener's page: the popup itself is a client-local transient with no logical page of its own. + const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) + const decision = routeBrowserClientDownload({ + guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId + }) + const clientRoute = decision.kind === 'remote' ? decision.route : null + const destination = (() => { + if (clientRoute) { + return { + filename: requestedFilename, + savePath: clientRoute.stagingPath, + reservationKey: null + } + } + // Why: a client-hosted download with no resolvable remote destination is canceled rather than + // written to this desktop's Downloads folder. + if (decision.kind === 'blocked') { + return null + } + try { + return browserDownloadDestinationReservations.reserve(requestedFilename) + } catch (error) { + console.error('[browser-download] Failed to choose download destination:', error) + return null + } + })() + + const fallbackSavePath = destination?.savePath ?? '' + + const download: ActiveDownload = { + downloadId, + guestWebContentsId, + browserTabId: null, + rendererWebContentsId: null, + origin, + filename: destination?.filename ?? requestedFilename, + totalBytes, + mimeType, + item, + savePath: fallbackSavePath, + reservationKey: destination?.reservationKey ?? null, + clientRoute, + remoteDestination: undefined, + receivedBytes: 0, + transientState: null, + terminalEvent: null, + startedSent: false, + cleanup: null + } + this.downloadsById.set(downloadId, download) + + const browserTabId = ownerContext?.browserTabId ?? null + if (browserTabId) { + this.bindDownloadToTab(downloadId, browserTabId) + } else { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] + pending.push(downloadId) + this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) + } + + if (!destination) { + this.finishDownloadInternal( + downloadId, + 'failed', + decision.kind === 'blocked' + ? 'Could not save the download to the remote workspace.' + : 'Could not choose a Downloads file name.' + ) + try { + item.cancel() + } catch { + // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. + } + return + } + + try { + item.setSavePath(destination.savePath) + } catch (error) { + console.error('[browser-download] Failed to set download destination:', error) + this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') + try { + item.cancel() + } catch { + // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. + } + return + } + + const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { + download.receivedBytes = this.getDownloadReceivedBytes(download.item) + download.transientState = state + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state + }) + } + const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { + const status: BrowserDownloadFinishedEvent['status'] = + state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' + const failure = + status === 'failed' + ? state === 'interrupted' + ? 'Download was interrupted.' + : 'Download failed.' + : null + if (download.clientRoute) { + void this.settleClientHostedDownload(download, status, failure) + return + } + this.finishDownloadInternal(download.downloadId, status, failure) + } + download.cleanup = (): void => { + try { + download.item.off('updated', updatedHandler) + download.item.off('done', doneHandler) + } catch { + // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. + } + } + item.on('updated', updatedHandler) + item.once('done', doneHandler) + + if (browserTabId) { + this.sendDownloadStarted(downloadId) + } + } + + cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { + const download = this.downloadsById.get(args.downloadId) + if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { + return false + } + this.cancelDownloadInternal(args.downloadId, 'Canceled.') + return true + } +} diff --git a/src/main/browser/browser-manager-download-lifecycle.ts b/src/main/browser/browser-manager-download-lifecycle.ts new file mode 100644 index 00000000000..614b7651d16 --- /dev/null +++ b/src/main/browser/browser-manager-download-lifecycle.ts @@ -0,0 +1,241 @@ +import { browserDownloadDestinationReservations } from './browser-download-destination' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadRequestedEvent +} from '../../shared/browser-guest-events' +import type { ActiveDownload } from './browser-manager-types' +import { BrowserManagerDownloadCreation } from './browser-manager-download-creation' + +export abstract class BrowserManagerDownloadLifecycle extends BrowserManagerDownloadCreation { + protected bindDownloadToTab(downloadId: string, browserTabId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + download.browserTabId = browserTabId + download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null + } + + protected flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + for (const downloadId of pending) { + this.bindDownloadToTab(downloadId, browserTabId) + this.flushDownloadSnapshot(downloadId) + } + } + + protected flushDownloadSnapshot(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + this.sendDownloadStarted(downloadId) + if (download.receivedBytes > 0 || download.transientState) { + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state: download.transientState + }) + } + if (download.terminalEvent) { + this.sendDownloadFinished(download.browserTabId, { + ...download.terminalEvent, + browserPageId: download.browserTabId ?? undefined + }) + this.downloadsById.delete(downloadId) + } + } + + protected sendDownloadStarted(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download?.browserTabId) { + return + } + if (download.startedSent) { + return + } + const renderer = this.resolveRendererForBrowserTab(download.browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-requested', { + browserPageId: download.browserTabId, + downloadId: download.downloadId, + origin: download.origin, + filename: download.filename, + totalBytes: download.totalBytes, + mimeType: download.mimeType, + savePath: download.savePath, + status: 'downloading' + } satisfies BrowserDownloadRequestedEvent) + download.startedSent = true + } + + protected sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-progress', payload) + } + + protected sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-finished', payload) + } + + protected async settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise { + const route = download.clientRoute + if (!route) { + return + } + if (status !== 'completed') { + download.clientRoute = null + await route.abort().catch(() => undefined) + this.finishDownloadInternal(download.downloadId, status, failure) + return + } + try { + // Why: the route stays on the record for the whole commit, which spans many round trips -- a + // cancel arriving mid-stream has to find something to abort or the bytes land anyway. + const remoteDestination = await route.complete(download.filename) + download.clientRoute = null + download.remoteDestination = remoteDestination + // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. + download.savePath = '' + this.finishDownloadInternal(download.downloadId, 'completed', null) + } catch (error) { + download.clientRoute = null + if (download.terminalEvent) { + // A cancel already reported the outcome; this rejection is that cancel taking effect. + return + } + console.error('[browser-download] Failed to save download to the remote workspace:', error) + this.finishDownloadInternal( + download.downloadId, + 'failed', + 'Could not save the download to the remote workspace.' + ) + } + } + + protected cancelDownloadInternal(downloadId: string, reason: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + const shouldSendCancel = !download.terminalEvent + + try { + download.item.cancel() + } catch { + // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. + } + + if (shouldSendCancel) { + this.finishDownloadInternal(downloadId, 'canceled', reason || null) + return + } + + this.downloadsById.delete(downloadId) + } + + protected finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void { + const download = this.downloadsById.get(downloadId) + if (!download || download.terminalEvent) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + browserDownloadDestinationReservations.release(download.reservationKey) + download.reservationKey = null + if (download.clientRoute) { + // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. + void download.clientRoute.abort().catch(() => undefined) + download.clientRoute = null + } + const event: BrowserDownloadFinishedEvent = { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + status, + savePath: download.savePath || null, + ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), + error + } + download.terminalEvent = event + if (download.browserTabId) { + this.sendDownloadStarted(downloadId) + this.sendDownloadFinished(download.browserTabId, event) + this.downloadsById.delete(downloadId) + } + } + + protected cancelPendingDownloadsForGuest(guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + if (!pending?.length) { + return + } + for (const downloadId of pending) { + const download = this.downloadsById.get(downloadId) + if (!download) { + continue + } + if (download.terminalEvent) { + this.downloadsById.delete(downloadId) + continue + } + this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') + const afterCancel = this.downloadsById.get(downloadId) + if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { + this.downloadsById.delete(downloadId) + } + } + } + + protected getDownloadReceivedBytes(item: Electron.DownloadItem): number { + try { + return Math.max(0, item.getReceivedBytes()) + } catch { + return 0 + } + } +} diff --git a/src/main/browser/browser-manager-event-forwarding.ts b/src/main/browser/browser-manager-event-forwarding.ts new file mode 100644 index 00000000000..62c6e1d00a1 --- /dev/null +++ b/src/main/browser/browser-manager-event-forwarding.ts @@ -0,0 +1,123 @@ +import type { + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { BrowserManagerBindings } from './browser-manager-bindings' +import type { PendingPermissionEvent, PendingPopupEvent } from './browser-manager-types' + +export abstract class BrowserManagerEventForwarding extends BrowserManagerBindings { + protected forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (!browserTabId) { + // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. + this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) + return + } + this.sendGuestLoadFailure(browserTabId, loadError) + } + + protected forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPermissionDenied(browserTabId, event) + } + + protected flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPermissionDenied(browserTabId, event) + } + } + + protected sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:permission-denied', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPermissionDeniedEvent) + } + + protected forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPopupEvent(browserTabId, event) + } + + protected flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPopupEvent(browserTabId, event) + } + } + + protected sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:popup', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPopupEvent) + } + + protected flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) + if (!pending) { + return + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.sendGuestLoadFailure(browserTabId, pending) + } + + protected sendGuestLoadFailure( + browserTabId: string, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:guest-load-failed', { + browserPageId: browserTabId, + loadError: { + ...loadError, + validatedUrl: redactKagiSessionToken(loadError.validatedUrl) + } + }) + } +} diff --git a/src/main/browser/browser-manager-final.ts b/src/main/browser/browser-manager-final.ts new file mode 100644 index 00000000000..b19369fc91d --- /dev/null +++ b/src/main/browser/browser-manager-final.ts @@ -0,0 +1,22 @@ +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { BrowserManagerEventForwarding } from './browser-manager-event-forwarding' + +export abstract class BrowserManagerFinal extends BrowserManagerEventForwarding { + protected openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { + return false + } + // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } +} diff --git a/src/main/browser/browser-manager-grab.ts b/src/main/browser/browser-manager-grab.ts new file mode 100644 index 00000000000..77e890138c5 --- /dev/null +++ b/src/main/browser/browser-manager-grab.ts @@ -0,0 +1,126 @@ +import { webContents } from 'electron' +import { buildGuestOverlayScript } from './grab-guest-script' +import { clampGrabPayload } from './browser-grab-payload' +import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import type { + BrowserGrabCancelReason, + BrowserGrabResult, + BrowserGrabRect, + BrowserGrabPayload, + BrowserGrabScreenshot +} from './browser-manager-types' +import { BrowserManagerViewport } from './browser-manager-viewport' + +export abstract class BrowserManagerGrab extends BrowserManagerViewport { + // --- Browser Context Grab — main-owned operations --- + + /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ + getAuthorizedGuest( + browserTabId: string, + senderWebContentsId: number + ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } + const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) + if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { + return null + } + const guestId = this.webContentsIdByTabId.get(browserTabId) + if (guestId == null) { + return null + } + const guest = webContents.fromId(guestId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return null + } + return guest + } + + /** Returns true if a grab operation is currently active for this tab. */ + hasActiveGrabOp(browserTabId: string): boolean { + return this.grabSessionController.hasActiveGrabOp(browserTabId) + } + + /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ + async setGrabMode( + browserTabId: string, + enabled: boolean, + guest: Electron.WebContents + ): Promise { + if (!enabled) { + const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) + this.cancelGrabOp(browserTabId, 'user') + if (hadActiveGrabOp) { + return true + } + try { + await guest.executeJavaScript(buildGuestOverlayScript('teardown')) + return true + } catch { + return false + } + } + // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. + try { + await guest.executeJavaScript(buildGuestOverlayScript('arm')) + return true + } catch { + return false + } + } + + /** + * Await a single grab selection on the given tab; resolves once on click, cancel, or error. + * + * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. + */ + awaitGrabSelection( + browserTabId: string, + opId: string, + guest: Electron.WebContents + ): Promise { + return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) + } + + /** Cancel an active grab operation for the given tab. */ + cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { + this.grabSessionController.cancelGrabOp(browserTabId, reason) + } + + /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ + async captureSelectionScreenshot( + _browserTabId: string, + rect: BrowserGrabRect, + guest: Electron.WebContents + ): Promise { + return captureGrabSelectionScreenshot(rect, guest) + } + + /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ + async extractHoverPayload( + _browserTabId: string, + guest: Electron.WebContents + ): Promise { + try { + const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) + if (!rawPayload || typeof rawPayload !== 'object') { + return null + } + return clampGrabPayload(rawPayload) + } catch { + return null + } + } +} diff --git a/src/main/browser/browser-manager-guest-cleanup.ts b/src/main/browser/browser-manager-guest-cleanup.ts new file mode 100644 index 00000000000..9dac4f3f665 --- /dev/null +++ b/src/main/browser/browser-manager-guest-cleanup.ts @@ -0,0 +1,44 @@ +import { BrowserManagerGuestNavigationPolicy } from './browser-manager-guest-navigation-policy' + +export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavigationPolicy { + protected retireStaleGuestWebContents(previousWebContentsId: number): void { + // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. + this.cleanupGuestPolicyAttachment(previousWebContentsId) + } + + protected cleanupGuestPolicyAttachment(guestWebContentsId: number): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + const isPrimaryGuest = browserTabId !== undefined + if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { + this.webContentsIdByTabId.delete(browserTabId) + } + this.tabIdByWebContentsId.delete(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) + const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) + if (policyCleanup) { + policyCleanup() + this.policyCleanupByGuestId.delete(guestWebContentsId) + } + this.policyAttachedGuestIds.delete(guestWebContentsId) + this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) + this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) + this.pendingNavigationByGuestId.delete(guestWebContentsId) + // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. + if (isPrimaryGuest) { + for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { + if (owner.rootGuestWebContentsId === guestWebContentsId) { + this.popupOwnerContextByGuestId.delete(popupGuestId) + } + } + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + this.cancelPendingDownloadsForGuest(guestWebContentsId) + } +} diff --git a/src/main/browser/browser-manager-guest-navigation-policy.ts b/src/main/browser/browser-manager-guest-navigation-policy.ts new file mode 100644 index 00000000000..abacd268640 --- /dev/null +++ b/src/main/browser/browser-manager-guest-navigation-policy.ts @@ -0,0 +1,152 @@ +import { + normalizeBrowserNavigationUrl, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { isChromiumInternalErrorUrl } from './browser-manager-types' +import { BrowserManagerGuestPopupPolicy } from './browser-manager-guest-popup-policy' + +export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManagerGuestPopupPolicy { + protected installGuestNavigationPolicy(guest: Electron.WebContents): () => void { + const navigationGuard = (event: Electron.Event, url: string): boolean => { + // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. + if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { + return true + } + // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. + if (url.startsWith('file:')) { + event.preventDefault() + return false + } + if (!normalizeBrowserNavigationUrl(url)) { + // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. + event.preventDefault() + return false + } + return true + } + + const willRedirectHandler = ( + event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.updatePendingNavigationForRedirect(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) + } + + const didFailLoadHandler = ( + _event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ): void => { + if (!isMainFrame) { + return + } + // Why: a nav that never committed must not leave its target standing as the tab's host. + const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) + if (failedNavigationWasCurrent) { + // The attempted host never committed, so restore every UA layer to the document that remains. + this.applyGoogleAuthUserAgent(guest, guest.getURL()) + } + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + const certificateFailure = browserPageId + ? this.certificateTrustController?.getFailure(browserPageId) + : null + if ( + certificateFailure && + toSecureCertificateEndpoint(validatedURL || guest.getURL()) === + toSecureCertificateEndpoint(certificateFailure.origin) + ) { + // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. + return + } + if (errorCode === -3) { + // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. + const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) + if (clearedError !== undefined) { + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.loadErrorsByGuestId.set(guest.id, clearedError) + this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) + this.notifyBrowserGuestStateChanged(guest.id) + } + return + } + this.clearedLoadErrorsByGuestId.delete(guest.id) + const loadError = this.buildLoadError( + errorCode, + errorDescription || 'This site could not be reached.', + validatedURL || guest.getURL() || 'about:blank' + ) + this.loadErrorsByGuestId.set(guest.id, loadError) + this.forwardOrQueueGuestLoadFailure(guest.id, loadError) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didStartNavigationHandler = ( + _event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + // Why: getURL() still reports the previous committed URL until this navigation commits, so + // every UA writer must read the in-flight target or they disagree about the tab's host. + this.startPendingNavigation(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. + this.pendingLoadFailuresByGuestId.delete(guest.id) + const activeError = this.loadErrorsByGuestId.get(guest.id) + if (activeError === undefined) { + // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure. + this.clearedLoadErrorsByGuestId.delete(guest.id) + return + } + this.clearedLoadErrorsByGuestId.set(guest.id, activeError) + this.loadErrorsByGuestId.delete(guest.id) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: once committed, getURL() reports this url, so the pending target is redundant. + this.pendingNavigationByGuestId.delete(guest.id) + // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) + } + + guest.on('will-navigate', navigationGuard) + guest.on('will-redirect', willRedirectHandler) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) + guest.on('did-fail-load', didFailLoadHandler) + const handleDestroyed = (): void => { + // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + + return () => { + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + if (!guest.isDestroyed()) { + guest.off('will-navigate', navigationGuard) + guest.off('will-redirect', willRedirectHandler) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) + guest.off('did-fail-load', didFailLoadHandler) + } + } + } +} diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts new file mode 100644 index 00000000000..c0d522235c8 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -0,0 +1,76 @@ +import { randomUUID } from 'node:crypto' +import { + BROWSING_GUEST_POLICY, + type BrowserGuestPolicy, + type PopupOwnerContext +} from './browser-manager-types' +import { BrowserManagerGuestCleanup } from './browser-manager-guest-cleanup' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' + +export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestCleanup { + attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY + ): void { + if (this.policyAttachedGuestIds.has(guest.id)) { + return + } + this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } + if (inheritedOwnerContext) { + this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) + } + // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. + const clickedLinkFrameName = inheritedOwnerContext + ? null + : `__orca_clicked_link_foreground_${randomUUID()}` + if (clickedLinkFrameName) { + this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) + } + + // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. + const disposeAntiDetection = this.injectAntiDetection(guest) + // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. + guest.setBackgroundThrottling(false) + const disposePopupPolicy = this.installGuestPopupPolicy(guest, clickedLinkFrameName) + const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest) + + // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. + this.policyCleanupByGuestId.set(guest.id, () => { + disposeAntiDetection() + disposePopupPolicy() + disposeNavigationPolicy() + }) + } + + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + protected attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } +} diff --git a/src/main/browser/browser-manager-guest-popup-policy.ts b/src/main/browser/browser-manager-guest-popup-policy.ts new file mode 100644 index 00000000000..43133fd8e29 --- /dev/null +++ b/src/main/browser/browser-manager-guest-popup-policy.ts @@ -0,0 +1,210 @@ +import { shell } from 'electron' +import { randomUUID } from 'node:crypto' +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl, + redactKagiSessionToken +} from '../../shared/browser-url' +import { + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript +} from './browser-clicked-link-routing' +import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' +import { SAFE_POPUP_WINDOW_OPTIONS, safeOrigin } from './browser-manager-types' +import type { PopupChildWindowOptions } from './popup-origin-bar-window' +import { BrowserManagerNavigation } from './browser-manager-navigation' + +export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavigation { + protected installGuestPopupPolicy( + guest: Electron.WebContents, + clickedLinkFrameName: string | null + ): () => void { + let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) + const installClickedLinkRouting = (): void => { + if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + return + } + // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. + void guest + .executeJavaScriptInIsolatedWorld( + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + [ + { + // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. + code: buildBrowserClickedLinkRoutingScript( + clickedLinkFrameName, + process.platform === 'darwin' + ) + } + ], + false + ) + .catch(() => {}) + } + if (clickedLinkFrameName) { + guest.on('dom-ready', installClickedLinkRouting) + } + const pendingIframeRoutingInstalls = new Map void>() + const iframeFrameNameByFrame = new Map() + const iframeFrameByFrameName = new Map() + const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { + const name = iframeFrameNameByFrame.get(frame) + if (!name) { + return + } + iframeFrameNameByFrame.delete(frame) + iframeFrameByFrameName.delete(name) + } + const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { + clearIframeFrameName(frame) + if (!clickedLinkRoutingActive || frame.isDestroyed()) { + return + } + const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + iframeFrameNameByFrame.set(frame, name) + iframeFrameByFrameName.set(name, frame) + // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. + void frame + .executeJavaScript( + buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + false + ) + .catch(() => { + if (iframeFrameNameByFrame.get(frame) === name) { + clearIframeFrameName(frame) + } + }) + } + const handleFrameCreated = ( + _event: Electron.Event, + { frame }: Electron.FrameCreatedDetails + ): void => { + if (!clickedLinkFrameName || !frame || frame.parent === null) { + return + } + for (const knownFrame of iframeFrameNameByFrame.keys()) { + if (knownFrame.isDestroyed()) { + clearIframeFrameName(knownFrame) + } + } + const installAfterDomReady = (): void => { + pendingIframeRoutingInstalls.delete(frame) + installIframeClickedLinkRouting(frame) + } + pendingIframeRoutingInstalls.set(frame, installAfterDomReady) + frame.once('dom-ready', installAfterDomReady) + } + if (clickedLinkFrameName) { + guest.on('frame-created', handleFrameCreated) + } + const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { + // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. + this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) + } + guest.on('did-create-window', handleDidCreateWindow) + guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { + const ownerContext = this.resolvePopupOwnerContext(guest.id) + const browserTabId = ownerContext?.browserTabId ?? null + const browserUrl = normalizeBrowserNavigationUrl(url) + const externalUrl = normalizeExternalBrowserUrl(url) + const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) + const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined + let isClickedLink = Boolean( + expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName + ) + if (!isClickedLink && iframeFrame) { + isClickedLink = true + clearIframeFrameName(iframeFrame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + } + + if (isClickedLink) { + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. + return { action: 'deny' } + } + + // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is + // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are + // excluded by isNewBrowserTabPopupIntent and still get a real child window below. + if ( + ownerContext && + externalUrl && + isNewBrowserTabPopupIntent({ frameName, disposition, features }) + ) { + // Why: one activation lets a page loop window.open, and each routed tab persists into + // workspace session state, so it survives the quit that used to clear popup windows. + if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'blocked' + }) + return { action: 'deny' } + } + if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. + return { action: 'deny' } + } + + // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. + const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) + if (browserTabId && canOpenAsChild) { + // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. + return { + action: 'allow', + overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, + // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. + createWindow: (options: PopupChildWindowOptions) => + this.createPopupChildWindowWithOriginBar(guest, url, options) + } + } else if (externalUrl) { + // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. + void shell.openExternal(redactKagiSessionToken(externalUrl)) + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-external' + }) + } else { + // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(url), + action: 'blocked' + }) + } + return { action: 'deny' } + }) + + return () => { + clickedLinkRoutingActive = false + try { + guest.off('did-create-window', handleDidCreateWindow) + if (clickedLinkFrameName) { + guest.off('dom-ready', installClickedLinkRouting) + guest.off('frame-created', handleFrameCreated) + for (const [frame, install] of pendingIframeRoutingInstalls) { + if (!frame.isDestroyed()) { + frame.off('dom-ready', install) + } + } + pendingIframeRoutingInstalls.clear() + iframeFrameNameByFrame.clear() + iframeFrameByFrameName.clear() + } + } catch { + // guest may already be destroyed + } + } + } +} diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts new file mode 100644 index 00000000000..4e061d288aa --- /dev/null +++ b/src/main/browser/browser-manager-navigation.ts @@ -0,0 +1,263 @@ +import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { cleanElectronUserAgent } from './browser-session-ua' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' +import { + safeOrigin, + type AuthUserAgentOverrideOperation, + type AuthUserAgentOverrideState +} from './browser-manager-types' +import { BrowserManagerVisibility } from './browser-manager-visibility' + +export abstract class BrowserManagerNavigation extends BrowserManagerVisibility { + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, + // not the request header, so the header-level Firefox switch in setupClientHintsOverride + // must be matched here per navigation or the two layers disagree — itself a bot tell. + // Restores the session's base identity off the auth hosts. Native-UA profiles opt out + // of the whole clean-UA path, so they keep their untouched identity everywhere. + protected applyGoogleAuthUserAgent( + guest: Electron.WebContents, + url: string, + options: { duringRedirect?: boolean } = {} + ): void { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct + // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. + // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so + // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. + const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + // Session state is authoritative before renderer registration and after a native profile imports a source UA. + const mode = + getBrowserSessionUserAgentMode(guest.session) ?? + (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) + if (mode === 'native') { + return + } + const firefoxUa = googleAuthUserAgent() + const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) + const latestPendingOverride = overrideState?.pending.at(-1) + const confirmedOverride = overrideState?.confirmed + const currentOverride = + latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) + ? latestPendingOverride + : confirmedOverride + const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() + const nextUa = isGoogleAuthUrl(url) + ? firefoxUa + : // Only restore when the auth-host override is actually in place, so normal + // navigation never touches the session UA. + currentUa === firefoxUa + ? guest.session.getUserAgent() + : null + let authOverrideIssuedOverCdp = false + if (nextUa !== null && nextUa !== currentUa) { + // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight + // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain + // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without + // touching the navigation, and it outranks the WebContents UA from then on, so a guest that + // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride + // rewrites User-Agent per request for auth-host URLs on its own. + if (options.duringRedirect === true || overrideState !== undefined) { + if (this.canOverrideUserAgentOverCdp(guest)) { + authOverrideIssuedOverCdp = true + // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers + // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off + // them, any mobile preset preserved. Writing the session UA directly would put the + // unlaundered Electron token back on the wire. + void this.applyAuthUserAgentOverrideOverCdp( + guest, + (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? + false, + url, + nextUa + ) + } + // Why: with no debugger there is no way to retarget the identity without cancelling the + // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. + } else { + guest.setUserAgent(nextUa) + } + } + // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of + // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. + if (browserPageId && !authOverrideIssuedOverCdp) { + this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + } + } + + protected canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { + try { + return !guest.isDestroyed() && guest.debugger.isAttached() + } catch { + return false + } + } + + protected applyAuthUserAgentOverrideOverCdp( + guest: Electron.WebContents, + mobile: boolean, + url: string, + userAgent: string + ): Promise { + if (!this.canOverrideUserAgentOverCdp(guest)) { + return Promise.resolve(false) + } + const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { + confirmed: null, + nextSequence: 0, + pending: [] + } + const operation = { sequence: ++state.nextSequence, userAgent } + state.pending.push(operation) + this.authUserAgentOverrideStateByGuestId.set(guest.id, state) + return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( + () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), + () => { + this.settleAuthUserAgentOverride(guest.id, state, operation, false) + return false + } + ) + } + + protected settleAuthUserAgentOverride( + guestId: number, + state: AuthUserAgentOverrideState, + operation: AuthUserAgentOverrideOperation, + succeeded: boolean + ): boolean { + if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { + return false + } + if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { + state.confirmed = operation + } + const pendingIndex = state.pending.indexOf(operation) + if (pendingIndex !== -1) { + state.pending.splice(pendingIndex, 1) + } + if (state.confirmed === null && state.pending.length === 0) { + this.authUserAgentOverrideStateByGuestId.delete(guestId) + } + return true + } + + protected startPendingNavigation(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] + }) + } + + protected updatePendingNavigationForRedirect(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: [] + }) + return + } + pending.currentUrl = url + } + + protected failPendingNavigation(guestId: number, failedUrl: string): boolean { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + return false + } + const supersededIndex = pending.supersededUrls.indexOf(failedUrl) + if (supersededIndex !== -1) { + pending.supersededUrls.splice(supersededIndex, 1) + return false + } + if (pending.currentUrl !== failedUrl) { + return false + } + this.pendingNavigationByGuestId.delete(guestId) + return true + } + + // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host + // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through + // here or two writers racing the same navigation will pick opposite identities. + protected resolveTabNavigationUrl(guest: Electron.WebContents): string { + return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() + } + + // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, + // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an + // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the + // request header says Firefox — the two-layer disagreement this scope exists to remove. + protected reapplyViewportUserAgentOverride( + guest: Electron.WebContents, + browserTabId: string, + url: string + ): void { + const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + if (mobile === undefined) { + return + } + // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the + // later-issued write wins. What matters is that both writers resolve the SAME host, which they + // now do via the navigation target rather than the stale committed URL. + void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) + } + + protected async sendViewportUserAgentOverride( + guest: Electron.WebContents, + mobile: boolean, + url?: string, + baseUserAgent?: string + ): Promise { + if (guest.isDestroyed() || !guest.debugger.isAttached()) { + return + } + await guest.debugger.sendCommand( + 'Emulation.setUserAgentOverride', + buildViewportUserAgentOverride({ + url: url ?? this.resolveTabNavigationUrl(guest), + mobile, + // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: + // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to + // the CDP override, so reading it back here would republish that identity on ordinary hosts. + baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) + }) + ) + } + + /** Route guests own their own popup handler, so their denials arrive here instead. */ + reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { + this.forwardOrQueuePopupEvent(input.openerWebContentsId, { + origin: safeOrigin(input.url), + action: 'blocked' + }) + } + + protected createPopupChildWindowWithOriginBar( + openerGuest: Electron.WebContents, + targetUrl: string, + options: PopupChildWindowOptions + ): Electron.WebContents { + const popup = openPopupWithOriginBar(options, targetUrl) + // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. + this.attachGuestPolicies( + popup.contentWebContents, + this.resolvePopupOwnerContext(openerGuest.id) + ) + this.forwardOrQueuePopupEvent(openerGuest.id, { + origin: safeOrigin(targetUrl), + action: 'opened-in-orca' + }) + // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. + const closePopupWithOpener = (): void => popup.close() + openerGuest.once('destroyed', closePopupWithOpener) + popup.onClosed(() => { + if (!openerGuest.isDestroyed()) { + openerGuest.off('destroyed', closePopupWithOpener) + } + }) + return popup.contentWebContents + } +} diff --git a/src/main/browser/browser-manager-queries.ts b/src/main/browser/browser-manager-queries.ts new file mode 100644 index 00000000000..dd8a5dc87a7 --- /dev/null +++ b/src/main/browser/browser-manager-queries.ts @@ -0,0 +1,136 @@ +import { webContents } from 'electron' +import type { + BrowserCertificateFailure, + BrowserLoadError +} from '../../shared/browser-workspace-types' +import type { ManagedBrowserGuestContext } from './browser-certificate-trust-controller' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { safeOrigin } from './browser-manager-types' +import { BrowserManagerRegistration } from './browser-manager-registration' + +export abstract class BrowserManagerQueries extends BrowserManagerRegistration { + getGuestWebContentsId(browserTabId: string): number | null { + return this.webContentsIdByTabId.get(browserTabId) ?? null + } + + getWebContentsIdByTabId(): Map { + return this.webContentsIdByTabId + } + + getTabIdForWebContentsId(webContentsId: number): string | null { + return this.tabIdByWebContentsId.get(webContentsId) ?? null + } + + getWorktreeIdForTab(browserTabId: string): string | undefined { + return this.worktreeIdByTabId.get(browserTabId) + } + + getRendererContextForGuest( + guestWebContentsId: number + ): { browserPageId: string; renderer: Electron.WebContents } | null { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return null + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + return renderer ? { browserPageId, renderer } : null + } + + getSessionProfileIdForTab(browserTabId: string): string | null { + return this.sessionProfileIdByPageId.get(browserTabId) ?? null + } + + getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + return webContentsId === undefined + ? null + : (this.loadErrorsByGuestId.get(webContentsId) ?? null) + } + + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { + return this.certificateTrustController?.getFailure(browserPageId) ?? null + } + + getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { + if (this.popupOwnerContextByGuestId.has(webContentsId)) { + return null + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null + const offscreen = this.offscreenGuestIds.has(webContentsId) + if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { + return null + } + if (!offscreen) { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { + return null + } + } + return { + browserPageId, + worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, + sessionProfileId: browserPageId + ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) + : null, + owner: offscreen ? 'offscreen' : 'desktop-webview' + } + } + + // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. + protected buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { + return { + code, + description, + validatedUrl: redactKagiSessionToken(rawUrl) + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, navigationUrl) + this.loadErrorsByGuestId.set(webContentsId, loadError) + this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + if (!browserPageId) { + return + } + if (this.offscreenGuestIds.has(webContentsId)) { + this.notifyBrowserGuestStateChanged(webContentsId) + return + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) + } + + protected notifyBrowserGuestStateChanged(webContentsId: number): void { + if (!this.offscreenGuestIds.has(webContentsId)) { + return + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null + if (worktreeId) { + // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + + notifyPermissionDenied(args: { + guestWebContentsId: number + permission: string + rawUrl: string + }): void { + this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { + permission: args.permission, + origin: safeOrigin(args.rawUrl) + }) + } +} diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts new file mode 100644 index 00000000000..6850c240950 --- /dev/null +++ b/src/main/browser/browser-manager-registration.ts @@ -0,0 +1,230 @@ +import { webContents } from 'electron' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { isWorkspaceDocPageId } from './doc-preview-guest-policy' +import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' +import type { BrowserGuestRegistration } from './browser-manager-types' +import { BrowserManagerGuestPolicy } from './browser-manager-guest-policy' + +export abstract class BrowserManagerRegistration extends BrowserManagerGuestPolicy { + registerGuest({ + browserPageId, + browserTabId: legacyBrowserTabId, + workspaceId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId, + rendererWebContentsId + }: BrowserGuestRegistration): boolean { + const browserTabId = browserPageId ?? legacyBrowserTabId + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { + return false + } + // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. + this.cancelGrabOp(browserTabId, 'evicted') + + const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + + // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. + if (guest.getType() !== 'webview') { + return false + } + if (!this.policyAttachedGuestIds.has(webContentsId)) { + // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. + return false + } + + const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportScrollStateByTabId.delete(browserTabId) + } + this.webContentsIdByTabId.set(browserTabId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserTabId) + if (workspaceId) { + this.workspaceIdByPageId.set(browserTabId, workspaceId) + } + this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserTabId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserTabId) + } + this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) + if (worktreeId) { + this.worktreeIdByTabId.set(browserTabId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) + + this.setupContextMenu(browserTabId, guest) + this.setupGrabShortcut(browserTabId, guest) + this.setupShortcutForwarding(browserTabId, guest) + this.setupMouseWheelZoomForwarding(browserTabId, guest) + this.flushPendingLoadFailure(browserTabId, webContentsId) + this.flushPendingPermissionEvents(browserTabId, webContentsId) + this.flushPendingPopupEvents(browserTabId, webContentsId) + this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true + } + + unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } + // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. + this.cancelGrabOp(browserTabId, 'evicted') + + // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. + const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (guestWebContentsId !== undefined) { + this.cleanupGuestPolicyAttachment(guestWebContentsId) + } + + const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (cleanup) { + cleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (shortcutCleanup) { + shortcutCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (fwdCleanup) { + fwdCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (mouseWheelZoomCleanup) { + mouseWheelZoomCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. + for (const [downloadId, download] of this.downloadsById.entries()) { + if (download.browserTabId === browserTabId && !download.terminalEvent) { + this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') + } + } + const wcId = this.webContentsIdByTabId.get(browserTabId) + if (wcId !== undefined) { + this.tabIdByWebContentsId.delete(wcId) + } + this.webContentsIdByTabId.delete(browserTabId) + this.rendererWebContentsIdByTabId.delete(browserTabId) + this.workspaceIdByPageId.delete(browserTabId) + this.sessionProfileIdByPageId.delete(browserTabId) + this.userAgentModeByPageId.delete(browserTabId) + this.worktreeIdByTabId.delete(browserTabId) + // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. + this.viewportOpsByTabId.delete(browserTabId) + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportScrollStateByTabId.delete(browserTabId) + if (wcId !== undefined) { + this.pendingNavigationByGuestId.delete(wcId) + } + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + + // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. + registerOffscreenGuest({ + browserPageId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId + }: { + browserPageId: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) + const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + this.viewportPresetActiveByTabId.delete(browserPageId) + this.viewportScrollStateByTabId.delete(browserPageId) + } + this.webContentsIdByTabId.set(browserPageId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserPageId) + this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserPageId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserPageId) + } + if (worktreeId) { + this.worktreeIdByTabId.set(browserPageId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true + } + + unregisterAll(): void { + // Cancel all active grab ops before tearing down registrations + this.grabSessionController.cancelAll('evicted') + for (const downloadId of this.downloadsById.keys()) { + this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') + } + browserDownloadDestinationReservations.clear() + for (const browserTabId of this.webContentsIdByTabId.keys()) { + this.unregisterGuest(browserTabId) + } + this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() + // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. + for (const cleanup of this.policyCleanupByGuestId.values()) { + cleanup() + } + this.policyCleanupByGuestId.clear() + this.clickedLinkFrameNameByGuestId.clear() + this.tabIdByWebContentsId.clear() + this.popupOwnerContextByGuestId.clear() + this.pageInitiatedTabBudgetByRootGuestId.clear() + this.worktreeIdByTabId.clear() + this.sessionProfileIdByPageId.clear() + this.userAgentModeByPageId.clear() + this.viewportUaOverrideMobileByTabId.clear() + this.viewportPresetActiveByTabId.clear() + this.viewportScrollStateByTabId.clear() + this.authUserAgentOverrideStateByGuestId.clear() + this.pendingNavigationByGuestId.clear() + this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() + this.pendingPermissionEventsByGuestId.clear() + this.pendingPopupEventsByGuestId.clear() + this.pendingDownloadIdsByGuestId.clear() + this.mouseWheelZoomCleanupByTabId.clear() + this.annotationViewportBridgeOpsByTabId.clear() + } +} diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts new file mode 100644 index 00000000000..bc65cc3d2dc --- /dev/null +++ b/src/main/browser/browser-manager-state.ts @@ -0,0 +1,289 @@ +import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { BrowserGrabSessionController } from './browser-grab-session-controller' +import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { + createPageInitiatedTabBudget, + type PageInitiatedTabBudget +} from './browser-page-initiated-tab-budget' +import type { KeybindingOverrides } from '../../shared/keybindings' +import type { + BrowserLoadError, + BrowserSessionUserAgentMode +} from '../../shared/browser-workspace-types' +import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' +import type { + ActiveDownload, + AuthUserAgentOverrideState, + PendingMainFrameNavigation, + PendingPermissionEvent, + PendingPopupEvent, + BrowserGuestPolicy, + BrowserManagerLoadError, + PopupOwnerContext +} from './browser-manager-types' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent +} from '../../shared/browser-guest-events' +import type { BrowserGrabCancelReason } from '../../shared/browser-grab-types' +import { BrowserManagerViewportScrollState } from './browser-manager-viewport-scroll-state' + +export abstract class BrowserManagerState extends BrowserManagerViewportScrollState { + protected abstract attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext?: PopupOwnerContext | null, + policy?: BrowserGuestPolicy + ): void + + protected abstract forwardOrQueuePopupEvent( + guestWebContentsId: number, + event: PendingPopupEvent + ): void + + protected abstract cancelPendingDownloadsForGuest(guestWebContentsId: number): void + + protected abstract cleanupGuestPolicyAttachment(guestWebContentsId: number): void + protected abstract notifyBrowserGuestStateChanged(webContentsId: number): void + protected abstract buildLoadError( + code: number, + description: string, + rawUrl: string + ): BrowserLoadError + protected abstract forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: BrowserManagerLoadError + ): void + protected abstract forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void + protected abstract flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingPermissionEvents( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingDownloadRequests( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract setupContextMenu(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupShortcutForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract setupMouseWheelZoomForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void + protected abstract hasActiveGrabOp(browserTabId: string): boolean + protected abstract unregisterGuest(browserTabId: string): void + protected abstract cancelDownloadInternal(downloadId: string, reason: string): void + protected abstract bindDownloadToTab(downloadId: string, browserTabId: string): void + protected abstract flushDownloadSnapshot(downloadId: string): void + protected abstract sendDownloadStarted(downloadId: string): void + protected abstract sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void + protected abstract sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void + protected abstract settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise + protected abstract finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void + protected abstract getDownloadReceivedBytes(item: Electron.DownloadItem): number + protected abstract openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean + + protected settingsResolver: + | (() => { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean + }) + | null = null + protected readonly webContentsIdByTabId = new Map() + // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. + protected readonly tabIdByWebContentsId = new Map() + protected readonly popupOwnerContextByGuestId = new Map() + // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. + protected readonly pageInitiatedTabBudgetByRootGuestId = new Map() + // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. + protected readonly workspaceIdByPageId = new Map() + protected readonly sessionProfileIdByPageId = new Map() + protected readonly userAgentModeByPageId = new Map() + // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. + protected readonly viewportOpsByTabId = new Map>() + // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation + // can re-issue it against the target URL's identity. + protected readonly viewportUaOverrideMobileByTabId = new Map() + // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations + // ordered without claiming a failed write was installed. + protected readonly authUserAgentOverrideStateByGuestId = new Map< + number, + AuthUserAgentOverrideState + >() + // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() + // still reports the outgoing page until then. See resolveTabNavigationUrl. + protected readonly pendingNavigationByGuestId = new Map() + protected readonly contextMenuCleanupByTabId = new Map void>() + protected readonly grabShortcutCleanupByTabId = new Map void>() + protected readonly shortcutForwardingCleanupByTabId = new Map void>() + protected readonly mouseWheelZoomCleanupByTabId = new Map void>() + protected readonly annotationViewportBridgeOpsByTabId = new Map>() + protected readonly worktreeIdByTabId = new Map() + protected readonly policyAttachedGuestIds = new Set() + protected readonly offscreenGuestIds = new Set() + protected readonly policyCleanupByGuestId = new Map void>() + protected readonly clickedLinkFrameNameByGuestId = new Map() + protected readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. + protected readonly clearedLoadErrorsByGuestId = new Map() + protected browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + protected certificateTrustController: BrowserCertificateTrustController | null = null + protected shouldForwardDictationShortcut: (() => boolean) | null = null + protected readonly pendingLoadFailuresByGuestId = new Map< + number, + { code: number; description: string; validatedUrl: string } + >() + protected readonly pendingPermissionEventsByGuestId = new Map() + protected readonly pendingPopupEventsByGuestId = new Map() + protected readonly pendingDownloadIdsByGuestId = new Map() + protected readonly downloadsById = new Map() + protected readonly grabSessionController = new BrowserGrabSessionController() + + setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { + this.shouldForwardDictationShortcut = predicate + } + + setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { + this.browserGuestStateChangedListener = listener + } + + setCertificateTrustController(controller: BrowserCertificateTrustController): void { + this.certificateTrustController = controller + } + + installCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.installSessionRequestGuard(session) + } + + removeCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.removeSessionRequestGuard(session) + } + + setSettingsResolver( + resolver: () => { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean + } + ): void { + this.settingsResolver = resolver + } + + // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. + protected injectAntiDetection(guest: Electron.WebContents): () => void { + let disposed = false + let reattachTimer: ReturnType | null = null + + const attach = (): void => { + if (disposed || guest.isDestroyed()) { + return + } + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + void guest.debugger + .sendCommand('Page.enable', {}) + .then(() => + guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { + source: ANTI_DETECTION_SCRIPT + }) + ) + .catch(() => {}) + } catch { + /* best-effort — debugger may be unavailable */ + } + } + + // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. + const onDetach = (): void => { + this.authUserAgentOverrideStateByGuestId.delete(guest.id) + if (!disposed && !guest.isDestroyed() && reattachTimer === null) { + reattachTimer = setTimeout(() => { + reattachTimer = null + attach() + }, 500) + } + } + + try { + attach() + guest.debugger.on('detach', onDetach) + } catch { + /* best-effort */ + } + + return () => { + disposed = true + if (reattachTimer !== null) { + clearTimeout(reattachTimer) + reattachTimer = null + } + try { + guest.debugger.off('detach', onDetach) + } catch { + /* guest may already be destroyed */ + } + } + } + + protected resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { + return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null + } + + protected resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (browserTabId) { + return { browserTabId, rootGuestWebContentsId: guestWebContentsId } + } + // Route popups live in an Orca-built window, so they never pass through did-create-window and + // have no inherited context; their owning page comes from the route popup registry instead. + const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) + if (routeOpenerWebContentsId !== null) { + const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) + return openerTabId + ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } + : null + } + const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) + if ( + inherited && + this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId + ) { + return inherited + } + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + return null + } + + /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ + protected tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { + let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) + if (!budget) { + budget = createPageInitiatedTabBudget() + this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) + } + return budget.tryConsume(Date.now()) + } +} diff --git a/src/main/browser/browser-manager-types.ts b/src/main/browser/browser-manager-types.ts new file mode 100644 index 00000000000..a1b832a65bc --- /dev/null +++ b/src/main/browser/browser-manager-types.ts @@ -0,0 +1,238 @@ +import { normalizeExternalBrowserUrl } from '../../shared/browser-url' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import type { + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot +} from '../../shared/browser-grab-types' +import type { BrowserClientDownloadRoute } from './browser-client-download-relay' +import type { PageInitiatedTabBudget } from './browser-page-initiated-tab-budget' +import type { + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride +} from '../../shared/browser-workspace-types' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import type { KeybindingOverrides } from '../../shared/keybindings' + +export const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 + +export function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + +export function resolveWithTimeout( + promise: Promise, + timeoutMs: number, + fallbackValue: T +): Promise<{ value: T; timedOut: boolean }> { + let timeoutId: ReturnType | null = null + const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { + timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) + }) + return Promise.race([ + promise.then((value) => ({ value, timedOut: false })), + timeoutPromise + ]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }) +} + +export function releaseAutomationVisibilityToken( + renderer: Electron.WebContents, + token: string +): void { + if (renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.release !== 'function') return false; + return bridge.release(${JSON.stringify(token)}); + })()` + ) + .catch(() => {}) +} + +export function cleanupLateAutomationVisibilityToken( + renderer: Electron.WebContents, + acquirePromise: Promise +): void { + acquirePromise + .then((lateToken) => { + if (typeof lateToken !== 'string' || lateToken.length === 0) { + return + } + // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. + releaseAutomationVisibilityToken(renderer, lateToken) + }) + .catch(() => {}) +} + +export function createNoopRestoreForTimedOutAutomationAcquire( + renderer: Electron.WebContents, + acquirePromise: Promise, + timedOut: boolean +): () => void { + if (timedOut) { + cleanupLateAutomationVisibilityToken(renderer, acquirePromise) + } + return () => {} +} + +export function isAutomationVisibilityToken(token: unknown): token is string { + return typeof token === 'string' && token.length > 0 +} + +export type BrowserGuestRegistration = { + browserPageId?: string + browserTabId?: string + workspaceId?: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + rendererWebContentsId: number +} + +export type PendingPermissionEvent = Omit +export type PendingPopupEvent = Omit +export type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' +export type PopupOwnerContext = { + browserTabId: string + rootGuestWebContentsId: number +} + +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } + +export const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } + +export type PendingMainFrameNavigation = { + currentUrl: string + supersededUrls: string[] +} + +export type AuthUserAgentOverrideOperation = { + sequence: number + userAgent: string +} + +export type AuthUserAgentOverrideState = { + confirmed: AuthUserAgentOverrideOperation | null + nextSequence: number + pending: AuthUserAgentOverrideOperation[] +} + +export const SAFE_POPUP_WINDOW_OPTIONS = { + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false, + // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false + } +} satisfies Electron.BrowserWindowConstructorOptions + +export type ActiveDownload = { + downloadId: string + guestWebContentsId: number + browserTabId: string | null + rendererWebContentsId: number | null + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + item: Electron.DownloadItem + savePath: string + reservationKey: string | null + clientRoute: BrowserClientDownloadRoute | null + remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] + receivedBytes: number + transientState: BrowserDownloadProgressEvent['state'] + terminalEvent: BrowserDownloadFinishedEvent | null + startedSent: boolean + cleanup: (() => void) | null +} + +export function safeOrigin(rawUrl: string): string { + const external = normalizeExternalBrowserUrl(rawUrl) + const urlToParse = external ?? rawUrl + try { + return new URL(urlToParse).origin + } catch { + return external ?? 'unknown' + } +} + +export type BrowserManagerSettings = { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean +} + +export type BrowserManagerLoadError = Pick< + BrowserLoadError, + 'code' | 'description' | 'validatedUrl' +> + +export type BrowserManagerGrabTypes = { + cancelReason: BrowserGrabCancelReason + payload: BrowserGrabPayload + rect: BrowserGrabRect + result: BrowserGrabResult + screenshot: BrowserGrabScreenshot +} + +export type { + BrowserAnnotationViewportBridgeOptions, + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride, + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent, + BrowserClientDownloadRoute, + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot, + KeybindingOverrides, + PageInitiatedTabBudget +} diff --git a/src/main/browser/browser-manager-viewport-scroll-state.ts b/src/main/browser/browser-manager-viewport-scroll-state.ts new file mode 100644 index 00000000000..1f83534c856 --- /dev/null +++ b/src/main/browser/browser-manager-viewport-scroll-state.ts @@ -0,0 +1,81 @@ +import { webContents } from 'electron' +import type { BrowserViewportScrollState } from '../../shared/browser-workspace-types' + +/** + * Renderer routing plus the host-side viewport-preset geometry the wheel path needs to decide + * whether a scroll belongs to the emulated viewport or to the guest page. + */ +export abstract class BrowserManagerViewportScrollState { + protected readonly rendererWebContentsIdByTabId = new Map() + // Why: host-side wheel panning follows the requested local viewport on the owning guest; + // replacement guests must not inherit a retired guest's state. + protected readonly viewportPresetActiveByTabId = new Map< + string, + { guestWebContentsId: number; active: boolean } + >() + protected readonly viewportScrollStateByTabId = new Map() + + setViewportScrollState( + browserTabId: string, + rendererWebContentsId: number, + state: BrowserViewportScrollState + ): void { + if (this.rendererWebContentsIdByTabId.get(browserTabId) !== rendererWebContentsId) { + return + } + if ( + ![state.scrollLeft, state.scrollTop, state.maxScrollLeft, state.maxScrollTop].every( + (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 + ) + ) { + return + } + this.viewportScrollStateByTabId.set(browserTabId, state) + } + + recordViewportScrollDelta(browserTabId: string, deltaX: number, deltaY: number): void { + const state = this.viewportScrollStateByTabId.get(browserTabId) + if (!state) { + return + } + this.viewportScrollStateByTabId.set(browserTabId, { + ...state, + scrollLeft: Math.min(state.maxScrollLeft, Math.max(0, state.scrollLeft + deltaX)), + scrollTop: Math.min(state.maxScrollTop, Math.max(0, state.scrollTop + deltaY)) + }) + } + + protected canViewportScroll(browserTabId: string, mouse: Electron.MouseWheelInputEvent): boolean { + const state = this.viewportScrollStateByTabId.get(browserTabId) + if (!state) { + return false + } + const deltaX = typeof mouse.deltaX === 'number' ? mouse.deltaX : 0 + const deltaY = typeof mouse.deltaY === 'number' ? mouse.deltaY : 0 + const canScrollAxis = (delta: number, position: number, maximum: number): boolean => { + if (delta < 0) { + return position > 0 + } + if (delta > 0) { + return position < maximum + } + return false + } + return ( + canScrollAxis(deltaX, state.scrollLeft, state.maxScrollLeft) || + canScrollAxis(deltaY, state.scrollTop, state.maxScrollTop) + ) + } + + protected resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { + const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) + if (!rendererWebContentsId) { + return null + } + const renderer = webContents.fromId(rendererWebContentsId) + if (!renderer || renderer.isDestroyed()) { + return null + } + return renderer + } +} diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts new file mode 100644 index 00000000000..1e79e760942 --- /dev/null +++ b/src/main/browser/browser-manager-viewport.ts @@ -0,0 +1,219 @@ +import { webContents } from 'electron' +import { + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + buildBrowserAnnotationViewportBridgeScript, + type BrowserAnnotationViewportBridgeOptions +} from '../../shared/browser-annotation-viewport-bridge' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' + +export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle { + // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. + async openDevTools(browserTabId: string): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + // Offscreen guests have no visible window on this desktop; detaching DevTools would open it + // on the host display with no route back to the remote client. + if (this.offscreenGuestIds.has(webContentsId)) { + return false + } + guest.openDevTools({ mode: 'detach' }) + return true + } + + // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. + async setViewportOverride( + browserTabId: string, + override: BrowserViewportOverride | null + ): Promise { + // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. + const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (expectedWebContentsId !== undefined) { + // Keep host panning available while CDP applies the requested dimensions. The guest id fence + // prevents this intent from leaking to a replacement guest; clearing the preset removes it. + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: expectedWebContentsId, + active: override !== null + }) + } + // The renderer resizes the host before CDP completes; discard the old geometry until it + // reports the new pane bounds so a pending preset cannot route wheel input using stale limits. + this.viewportScrollStateByTabId.delete(browserTabId) + const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId)) + this.viewportOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. + if (this.viewportOpsByTabId.get(browserTabId) === next) { + this.viewportOpsByTabId.delete(browserTabId) + } + } + } + + async setAnnotationViewportBridge( + browserTabId: string, + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) + this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + } + } + + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. + protected async doSetAnnotationViewportBridgeImpl( + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() + if (!guest || guest.isDestroyed()) { + return false + } + + try { + // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. + await guest.executeJavaScriptInIsolatedWorld( + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], + false + ) + return true + } catch { + return false + } + } + + protected async doSetViewportOverrideImpl( + browserTabId: string, + override: BrowserViewportOverride | null, + expectedWebContentsId: number | undefined + ): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId || webContentsId !== expectedWebContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + } catch (err) { + // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. + console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { + browserTabId, + webContentsId, + error: err instanceof Error ? err.message : String(err) + }) + return false + } + + const dbg = guest.debugger + try { + if (override) { + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width: override.width, + height: override.height, + deviceScaleFactor: override.deviceScaleFactor, + mobile: override.mobile + }) + if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: webContentsId, + active: true + }) + } + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: override.mobile, + maxTouchPoints: override.mobile ? 5 : 0 + }) + // Why: viewport sizing must not override a profile's explicit native-UA identity. + if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { + // Navigation must see the preset intent while the final CDP command is in flight. + this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + // Why: same sender as the navigation path, so both resolve the tab's host identically. + await this.sendViewportUserAgentOverride(guest, override.mobile) + } + } else { + await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) + if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: webContentsId, + active: false + }) + } + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: false, + maxTouchPoints: 0 + }) + const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + // A navigation after this point must not re-install the override behind the clear. + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + try { + if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { + const url = this.resolveTabNavigationUrl(guest) + const restored = await this.applyAuthUserAgentOverrideOverCdp( + guest, + false, + url, + isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() + ) + if (!restored) { + throw new Error('Failed to preserve auth user agent') + } + } else { + // Why: passing an empty string restores the session default UA. + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } + } catch (error) { + if (trackedMobile !== undefined) { + this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) + } + throw error + } + } + if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { + return false + } + return true + } catch { + return false + } + } +} diff --git a/src/main/browser/browser-manager-visibility.ts b/src/main/browser/browser-manager-visibility.ts new file mode 100644 index 00000000000..1da2a75638d --- /dev/null +++ b/src/main/browser/browser-manager-visibility.ts @@ -0,0 +1,256 @@ +import { + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + createNoopRestoreForTimedOutAutomationAcquire, + isAutomationVisibilityToken, + releaseAutomationVisibilityToken, + resolveWithTimeout +} from './browser-manager-types' +import { BrowserManagerState } from './browser-manager-state' + +export abstract class BrowserManagerVisibility extends BrowserManagerState { + // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. + async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId + const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + const prev = await renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return null; + var state = store.getState(); + var prevTabType = state.activeTabType; + var prevActiveWorktreeId = state.activeWorktreeId || null; + var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; + var prevActiveBrowserPageId = null; + var prevFocusedGroupTabId = null; + var targetWorktreeId = ${JSON.stringify(worktreeId)}; + var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; + var browserPageId = ${JSON.stringify(browserPageId)}; + var browserTabsByWorktree = state.browserTabsByWorktree || {}; + + if (prevActiveWorktreeId) { + var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; + var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; + for (var pg = 0; pg < prevGroups.length; pg++) { + if (prevGroups[pg].id === prevFocusedGroupId) { + prevFocusedGroupTabId = prevGroups[pg].activeTabId; + break; + } + } + } + + if (prevActiveBrowserWorkspaceId) { + for (var prevWtId in browserTabsByWorktree) { + var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; + for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { + if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { + prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; + break; + } + } + if (prevActiveBrowserPageId) break; + } + } + + if ( + targetWorktreeId && + prevActiveWorktreeId !== targetWorktreeId && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(targetWorktreeId); + state = store.getState(); + } + + var foundWorkspace = null; + for (var wtId in browserTabsByWorktree) { + var tabs = browserTabsByWorktree[wtId] || []; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id === browserWorkspaceId) { + foundWorkspace = tabs[i]; + if (!targetWorktreeId) { + targetWorktreeId = wtId; + } + break; + } + } + if (foundWorkspace) break; + } + + var hasTargetPage = false; + var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; + for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { + if (targetPages[pageIndex].id === browserPageId) { + hasTargetPage = true; + break; + } + } + + if (foundWorkspace) { + if (typeof state.setActiveBrowserTab === 'function') { + state.setActiveBrowserTab(browserWorkspaceId); + state = store.getState(); + } else { + var allTabs = state.unifiedTabsByWorktree || {}; + var found = null; + for (var unifiedWtId in allTabs) { + var unifiedTabs = allTabs[unifiedWtId] || []; + for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { + if ( + unifiedTabs[unifiedIndex].contentType === 'browser' && + unifiedTabs[unifiedIndex].entityId === browserWorkspaceId + ) { + found = unifiedTabs[unifiedIndex]; + break; + } + } + if (found) break; + } + if (found) { + state.activateTab(found.id); + } + state.setActiveTabType('browser'); + state = store.getState(); + } + // Why: activating the workspace alone is not enough for screenshot + // capture when a browser workspace contains multiple pages. The + // compositor only paints the currently mounted page guest. + if ( + hasTargetPage && + foundWorkspace.activePageId !== browserPageId && + typeof state.setActiveBrowserPage === 'function' + ) { + state.setActiveBrowserPage(browserWorkspaceId, browserPageId); + state = store.getState(); + } + } + + return { + prevTabType: prevTabType, + prevActiveWorktreeId: prevActiveWorktreeId, + prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, + prevActiveBrowserPageId: prevActiveBrowserPageId, + prevFocusedGroupTabId: prevFocusedGroupTabId, + targetWorktreeId: targetWorktreeId, + targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, + targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null + }; + })()` + ) + .catch(() => null) + + const needsRestore = + prev && + (prev.prevTabType !== 'browser' || + prev.prevActiveWorktreeId !== prev.targetWorktreeId || + prev.prevFocusedGroupTabId !== null || + prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || + prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) + + if (!needsRestore) { + return () => {} + } + + return () => { + if (!prev || !renderer || renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return; + var state = store.getState(); + if ( + ${JSON.stringify(prev?.prevActiveWorktreeId)} && + ${JSON.stringify(prev?.prevActiveWorktreeId)} !== + ${JSON.stringify(prev?.targetWorktreeId)} && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== + ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && + typeof state.setActiveBrowserTab === 'function' + ) { + state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== + ${JSON.stringify(prev?.targetBrowserPageId)} && + typeof state.setActiveBrowserPage === 'function' + ) { + // Why: Orca remembers the last browser workspace/page even when + // the user is currently in terminal/editor view. Screenshot prep + // temporarily switches that hidden browser selection state, so + // restore it independently of the visible tab type. + state.setActiveBrowserPage( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, + ${JSON.stringify(prev?.prevActiveBrowserPageId)} + ); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevTabType)} !== 'browser' && + ${JSON.stringify(prev?.prevFocusedGroupTabId)} + ) { + state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); + } + if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { + state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); + } + })()` + ) + .catch(() => {}) + } + } + + async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. + const acquirePromise = renderer + .executeJavaScript( + `(async function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.acquire !== 'function') return null; + return await bridge.acquire(${JSON.stringify(browserPageId)}); + })()` + ) + .catch(() => null) + const { value: token, timedOut } = await resolveWithTimeout( + acquirePromise, + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + null + ) + + if (!isAutomationVisibilityToken(token)) { + return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) + } + + return () => { + releaseAutomationVisibilityToken(renderer, token) + } + } +} diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 5764b45e5e8..bdac69dd906 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1,2705 +1,14 @@ -/* eslint-disable max-lines -- Why: single privileged facade for guest registration, authorization, and lifecycle cleanup; keeps the browser security boundary in one file. */ -import { randomUUID } from 'node:crypto' +import { webContents } from 'electron' +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { BrowserManagerFinal } from './browser-manager-final' -import { shell, webContents } from 'electron' -import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl, - redactKagiSessionToken, - toSecureCertificateEndpoint -} from '../../shared/browser-url' -import type { - BrowserDownloadFinishedEvent, - BrowserDownloadProgressEvent, - BrowserDownloadRequestedEvent, - BrowserPermissionDeniedEvent, - BrowserPopupEvent -} from '../../shared/browser-guest-events' -import type { - BrowserGrabCancelReason, - BrowserGrabPayload, - BrowserGrabRect, - BrowserGrabResult, - BrowserGrabScreenshot -} from '../../shared/browser-grab-types' -import { buildGuestOverlayScript } from './grab-guest-script' -import { clampGrabPayload } from './browser-grab-payload' -import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' -import { BrowserGrabSessionController } from './browser-grab-session-controller' -import { browserDownloadDestinationReservations } from './browser-download-destination' -import type { BrowserClientDownloadRoute } from './browser-client-download-relay' -import { routeBrowserClientDownload } from './browser-client-download-routing' -import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' -import { resolveRendererWebContents } from './browser-guest-renderer-target' -import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' -import { setupGuestContextMenu } from './browser-guest-context-menu' -import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' -import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' -import { ANTI_DETECTION_SCRIPT } from './anti-detection' -import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' -import { - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - buildBrowserClickedLinkRoutingScript, - buildBrowserIframeClickedLinkRoutingScript -} from './browser-clicked-link-routing' -import { - createPageInitiatedTabBudget, - type PageInitiatedTabBudget -} from './browser-page-initiated-tab-budget' -import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' -import { cleanElectronUserAgent } from './browser-session-ua' -import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' -import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' -import type { - BrowserCertificateFailure, - BrowserLoadError, - BrowserSessionUserAgentMode, - BrowserViewportOverride, - BrowserViewportScrollState -} from '../../shared/browser-workspace-types' -import { - type BrowserAnnotationViewportBridgeOptions, - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - buildBrowserAnnotationViewportBridgeScript -} from '../../shared/browser-annotation-viewport-bridge' -import { - getWorkspaceDocPageGuest, - installDocPreviewGuestPolicy, - isWorkspaceDocPageId -} from './doc-preview-guest-policy' -import type { KeybindingOverrides } from '../../shared/keybindings' -import { - BrowserCertificateTrustController, - type ManagedBrowserGuestContext -} from './browser-certificate-trust-controller' +export type { BrowserGuestPolicy, BrowserGuestRegistration } from './browser-manager-types' -const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 - -function isChromiumInternalErrorUrl(url: string): boolean { - return url.startsWith('chrome-error://') -} - -function resolveWithTimeout( - promise: Promise, - timeoutMs: number, - fallbackValue: T -): Promise<{ value: T; timedOut: boolean }> { - let timeoutId: ReturnType | null = null - const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { - timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) - }) - return Promise.race([ - promise.then((value) => ({ value, timedOut: false })), - timeoutPromise - ]).finally(() => { - if (timeoutId) { - clearTimeout(timeoutId) - } - }) -} - -function releaseAutomationVisibilityToken(renderer: Electron.WebContents, token: string): void { - if (renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.release !== 'function') return false; - return bridge.release(${JSON.stringify(token)}); - })()` - ) - .catch(() => {}) -} - -function cleanupLateAutomationVisibilityToken( - renderer: Electron.WebContents, - acquirePromise: Promise -): void { - acquirePromise - .then((lateToken) => { - if (typeof lateToken !== 'string' || lateToken.length === 0) { - return - } - // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. - releaseAutomationVisibilityToken(renderer, lateToken) - }) - .catch(() => {}) -} - -function createNoopRestoreForTimedOutAutomationAcquire( - renderer: Electron.WebContents, - acquirePromise: Promise, - timedOut: boolean -): () => void { - if (timedOut) { - cleanupLateAutomationVisibilityToken(renderer, acquirePromise) - } - return () => {} -} - -function isAutomationVisibilityToken(token: unknown): token is string { - return typeof token === 'string' && token.length > 0 -} - -export type BrowserGuestRegistration = { - browserPageId?: string - browserTabId?: string - workspaceId?: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - rendererWebContentsId: number -} - -type PendingPermissionEvent = Omit -type PendingPopupEvent = Omit -type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' -type PopupOwnerContext = { - browserTabId: string - rootGuestWebContentsId: number -} /** - * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and - * anti-detection all apply. A workspace-document guest renders one granted document and gets none - * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + * Privileged browser guest facade. Behavior is organized by lifecycle concern in the focused + * manager modules while this module keeps the stable import surface used by main and renderer code. */ -export type BrowserGuestPolicy = - | { profile: 'browsing' } - | { profile: 'workspace-doc'; host: Electron.WebContents } -const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } -type PendingMainFrameNavigation = { - currentUrl: string - supersededUrls: string[] -} -type AuthUserAgentOverrideOperation = { - sequence: number - userAgent: string -} -type AuthUserAgentOverrideState = { - confirmed: AuthUserAgentOverrideOperation | null - nextSequence: number - pending: AuthUserAgentOverrideOperation[] -} -const SAFE_POPUP_WINDOW_OPTIONS = { - alwaysOnTop: false, - closable: true, - focusable: true, - frame: true, - fullscreen: false, - kiosk: false, - modal: false, - movable: true, - opacity: 1, - show: true, - simpleFullscreen: false, - skipTaskbar: false, - titleBarStyle: 'default', - transparent: false, - // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. - webPreferences: { - allowRunningInsecureContent: false, - contextIsolation: true, - nodeIntegration: false, - nodeIntegrationInSubFrames: false, - sandbox: true, - webviewTag: false - } -} satisfies Electron.BrowserWindowConstructorOptions - -type ActiveDownload = { - downloadId: string - guestWebContentsId: number - browserTabId: string | null - rendererWebContentsId: number | null - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - item: Electron.DownloadItem - savePath: string - reservationKey: string | null - clientRoute: BrowserClientDownloadRoute | null - remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] - receivedBytes: number - transientState: BrowserDownloadProgressEvent['state'] - terminalEvent: BrowserDownloadFinishedEvent | null - startedSent: boolean - cleanup: (() => void) | null -} - -function safeOrigin(rawUrl: string): string { - const external = normalizeExternalBrowserUrl(rawUrl) - const urlToParse = external ?? rawUrl - try { - return new URL(urlToParse).origin - } catch { - return external ?? 'unknown' - } -} - -export class BrowserManager { - private settingsResolver: - | (() => { - keybindings?: KeybindingOverrides - mobileEmulatorEnabled?: boolean - }) - | null = null - private readonly webContentsIdByTabId = new Map() - // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. - private readonly tabIdByWebContentsId = new Map() - private readonly popupOwnerContextByGuestId = new Map() - // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. - private readonly pageInitiatedTabBudgetByRootGuestId = new Map() - // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. - private readonly workspaceIdByPageId = new Map() - private readonly sessionProfileIdByPageId = new Map() - private readonly userAgentModeByPageId = new Map() - private readonly rendererWebContentsIdByTabId = new Map() - // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. - private readonly viewportOpsByTabId = new Map>() - // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation - // can re-issue it against the target URL's identity. - private readonly viewportUaOverrideMobileByTabId = new Map() - // Why: host-side wheel panning follows the requested local viewport on the owning guest; - // replacement guests must not inherit a retired guest's state. - private readonly viewportPresetActiveByTabId = new Map< - string, - { guestWebContentsId: number; active: boolean } - >() - private readonly viewportScrollStateByTabId = new Map() - // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations - // ordered without claiming a failed write was installed. - private readonly authUserAgentOverrideStateByGuestId = new Map< - number, - AuthUserAgentOverrideState - >() - // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() - // still reports the outgoing page until then. See resolveTabNavigationUrl. - private readonly pendingNavigationByGuestId = new Map() - private readonly contextMenuCleanupByTabId = new Map void>() - private readonly grabShortcutCleanupByTabId = new Map void>() - private readonly shortcutForwardingCleanupByTabId = new Map void>() - private readonly mouseWheelZoomCleanupByTabId = new Map void>() - private readonly annotationViewportBridgeOpsByTabId = new Map>() - private readonly worktreeIdByTabId = new Map() - private readonly policyAttachedGuestIds = new Set() - private readonly offscreenGuestIds = new Set() - private readonly policyCleanupByGuestId = new Map void>() - private readonly clickedLinkFrameNameByGuestId = new Map() - private readonly loadErrorsByGuestId = new Map() - // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. - private readonly clearedLoadErrorsByGuestId = new Map() - private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null - private certificateTrustController: BrowserCertificateTrustController | null = null - private shouldForwardDictationShortcut: (() => boolean) | null = null - private readonly pendingLoadFailuresByGuestId = new Map< - number, - { code: number; description: string; validatedUrl: string } - >() - private readonly pendingPermissionEventsByGuestId = new Map() - private readonly pendingPopupEventsByGuestId = new Map() - private readonly pendingDownloadIdsByGuestId = new Map() - private readonly downloadsById = new Map() - private readonly grabSessionController = new BrowserGrabSessionController() - - setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { - this.shouldForwardDictationShortcut = predicate - } - - setViewportScrollState( - browserTabId: string, - rendererWebContentsId: number, - state: BrowserViewportScrollState - ): void { - if (this.rendererWebContentsIdByTabId.get(browserTabId) !== rendererWebContentsId) { - return - } - if ( - ![state.scrollLeft, state.scrollTop, state.maxScrollLeft, state.maxScrollTop].every( - (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 - ) - ) { - return - } - this.viewportScrollStateByTabId.set(browserTabId, state) - } - - recordViewportScrollDelta(browserTabId: string, deltaX: number, deltaY: number): void { - const state = this.viewportScrollStateByTabId.get(browserTabId) - if (!state) { - return - } - this.viewportScrollStateByTabId.set(browserTabId, { - ...state, - scrollLeft: Math.min(state.maxScrollLeft, Math.max(0, state.scrollLeft + deltaX)), - scrollTop: Math.min(state.maxScrollTop, Math.max(0, state.scrollTop + deltaY)) - }) - } - - setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { - this.browserGuestStateChangedListener = listener - } - - setCertificateTrustController(controller: BrowserCertificateTrustController): void { - this.certificateTrustController = controller - } - - installCertificateRequestGuard(session: Electron.Session): void { - this.certificateTrustController?.installSessionRequestGuard(session) - } - - removeCertificateRequestGuard(session: Electron.Session): void { - this.certificateTrustController?.removeSessionRequestGuard(session) - } - - setSettingsResolver( - resolver: () => { - keybindings?: KeybindingOverrides - mobileEmulatorEnabled?: boolean - } - ): void { - this.settingsResolver = resolver - } - - // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. - private injectAntiDetection(guest: Electron.WebContents): () => void { - let disposed = false - let reattachTimer: ReturnType | null = null - - const attach = (): void => { - if (disposed || guest.isDestroyed()) { - return - } - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - void guest.debugger - .sendCommand('Page.enable', {}) - .then(() => - guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { - source: ANTI_DETECTION_SCRIPT - }) - ) - .catch(() => {}) - } catch { - /* best-effort — debugger may be unavailable */ - } - } - - // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. - const onDetach = (): void => { - this.authUserAgentOverrideStateByGuestId.delete(guest.id) - if (!disposed && !guest.isDestroyed() && reattachTimer === null) { - reattachTimer = setTimeout(() => { - reattachTimer = null - attach() - }, 500) - } - } - - try { - attach() - guest.debugger.on('detach', onDetach) - } catch { - /* best-effort */ - } - - return () => { - disposed = true - if (reattachTimer !== null) { - clearTimeout(reattachTimer) - reattachTimer = null - } - try { - guest.debugger.off('detach', onDetach) - } catch { - /* guest may already be destroyed */ - } - } - } - - private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { - return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null - } - - private resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (browserTabId) { - return { browserTabId, rootGuestWebContentsId: guestWebContentsId } - } - // Route popups live in an Orca-built window, so they never pass through did-create-window and - // have no inherited context; their owning page comes from the route popup registry instead. - const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) - if (routeOpenerWebContentsId !== null) { - const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) - return openerTabId - ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } - : null - } - const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) - if ( - inherited && - this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId - ) { - return inherited - } - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - return null - } - - /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ - private tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { - let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) - if (!budget) { - budget = createPageInitiatedTabBudget() - this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) - } - return budget.tryConsume(Date.now()) - } - - private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { - const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) - if (!rendererWebContentsId) { - return null - } - const renderer = webContents.fromId(rendererWebContentsId) - if (!renderer || renderer.isDestroyed()) { - return null - } - return renderer - } - - // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. - async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId - const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - const prev = await renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return null; - var state = store.getState(); - var prevTabType = state.activeTabType; - var prevActiveWorktreeId = state.activeWorktreeId || null; - var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; - var prevActiveBrowserPageId = null; - var prevFocusedGroupTabId = null; - var targetWorktreeId = ${JSON.stringify(worktreeId)}; - var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; - var browserPageId = ${JSON.stringify(browserPageId)}; - var browserTabsByWorktree = state.browserTabsByWorktree || {}; - - if (prevActiveWorktreeId) { - var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; - var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; - for (var pg = 0; pg < prevGroups.length; pg++) { - if (prevGroups[pg].id === prevFocusedGroupId) { - prevFocusedGroupTabId = prevGroups[pg].activeTabId; - break; - } - } - } - - if (prevActiveBrowserWorkspaceId) { - for (var prevWtId in browserTabsByWorktree) { - var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; - for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { - if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { - prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; - break; - } - } - if (prevActiveBrowserPageId) break; - } - } - - if ( - targetWorktreeId && - prevActiveWorktreeId !== targetWorktreeId && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(targetWorktreeId); - state = store.getState(); - } - - var foundWorkspace = null; - for (var wtId in browserTabsByWorktree) { - var tabs = browserTabsByWorktree[wtId] || []; - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].id === browserWorkspaceId) { - foundWorkspace = tabs[i]; - if (!targetWorktreeId) { - targetWorktreeId = wtId; - } - break; - } - } - if (foundWorkspace) break; - } - - var hasTargetPage = false; - var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; - for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { - if (targetPages[pageIndex].id === browserPageId) { - hasTargetPage = true; - break; - } - } - - if (foundWorkspace) { - if (typeof state.setActiveBrowserTab === 'function') { - state.setActiveBrowserTab(browserWorkspaceId); - state = store.getState(); - } else { - var allTabs = state.unifiedTabsByWorktree || {}; - var found = null; - for (var unifiedWtId in allTabs) { - var unifiedTabs = allTabs[unifiedWtId] || []; - for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { - if ( - unifiedTabs[unifiedIndex].contentType === 'browser' && - unifiedTabs[unifiedIndex].entityId === browserWorkspaceId - ) { - found = unifiedTabs[unifiedIndex]; - break; - } - } - if (found) break; - } - if (found) { - state.activateTab(found.id); - } - state.setActiveTabType('browser'); - state = store.getState(); - } - // Why: activating the workspace alone is not enough for screenshot - // capture when a browser workspace contains multiple pages. The - // compositor only paints the currently mounted page guest. - if ( - hasTargetPage && - foundWorkspace.activePageId !== browserPageId && - typeof state.setActiveBrowserPage === 'function' - ) { - state.setActiveBrowserPage(browserWorkspaceId, browserPageId); - state = store.getState(); - } - } - - return { - prevTabType: prevTabType, - prevActiveWorktreeId: prevActiveWorktreeId, - prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, - prevActiveBrowserPageId: prevActiveBrowserPageId, - prevFocusedGroupTabId: prevFocusedGroupTabId, - targetWorktreeId: targetWorktreeId, - targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, - targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null - }; - })()` - ) - .catch(() => null) - - const needsRestore = - prev && - (prev.prevTabType !== 'browser' || - prev.prevActiveWorktreeId !== prev.targetWorktreeId || - prev.prevFocusedGroupTabId !== null || - prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || - prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) - - if (!needsRestore) { - return () => {} - } - - return () => { - if (!prev || !renderer || renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return; - var state = store.getState(); - if ( - ${JSON.stringify(prev?.prevActiveWorktreeId)} && - ${JSON.stringify(prev?.prevActiveWorktreeId)} !== - ${JSON.stringify(prev?.targetWorktreeId)} && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== - ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && - typeof state.setActiveBrowserTab === 'function' - ) { - state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== - ${JSON.stringify(prev?.targetBrowserPageId)} && - typeof state.setActiveBrowserPage === 'function' - ) { - // Why: Orca remembers the last browser workspace/page even when - // the user is currently in terminal/editor view. Screenshot prep - // temporarily switches that hidden browser selection state, so - // restore it independently of the visible tab type. - state.setActiveBrowserPage( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, - ${JSON.stringify(prev?.prevActiveBrowserPageId)} - ); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevTabType)} !== 'browser' && - ${JSON.stringify(prev?.prevFocusedGroupTabId)} - ) { - state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); - } - if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { - state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); - } - })()` - ) - .catch(() => {}) - } - } - - async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. - const acquirePromise = renderer - .executeJavaScript( - `(async function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.acquire !== 'function') return null; - return await bridge.acquire(${JSON.stringify(browserPageId)}); - })()` - ) - .catch(() => null) - const { value: token, timedOut } = await resolveWithTimeout( - acquirePromise, - AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, - null - ) - - if (!isAutomationVisibilityToken(token)) { - return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) - } - - return () => { - releaseAutomationVisibilityToken(renderer, token) - } - } - - attachGuestPolicies( - guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null, - policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY - ): void { - if (this.policyAttachedGuestIds.has(guest.id)) { - return - } - this.policyAttachedGuestIds.add(guest.id) - // Why one door with a profile rather than a second installer beside it: whether a guest was - // policy-attached at all is what registration and teardown both key on, so a guest that took - // another path into the app is invisible to both. - if (policy.profile === 'workspace-doc') { - this.attachWorkspaceDocGuestPolicies(guest, policy.host) - return - } - if (inheritedOwnerContext) { - this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) - } - // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. - const clickedLinkFrameName = inheritedOwnerContext - ? null - : `__orca_clicked_link_foreground_${randomUUID()}` - if (clickedLinkFrameName) { - this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) - } - let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) - - // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. - const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. - guest.setBackgroundThrottling(false) - const installClickedLinkRouting = (): void => { - if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { - return - } - // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. - void guest - .executeJavaScriptInIsolatedWorld( - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - [ - { - // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. - code: buildBrowserClickedLinkRoutingScript( - clickedLinkFrameName, - process.platform === 'darwin' - ) - } - ], - false - ) - .catch(() => {}) - } - if (clickedLinkFrameName) { - guest.on('dom-ready', installClickedLinkRouting) - } - const pendingIframeRoutingInstalls = new Map void>() - const iframeFrameNameByFrame = new Map() - const iframeFrameByFrameName = new Map() - const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { - const name = iframeFrameNameByFrame.get(frame) - if (!name) { - return - } - iframeFrameNameByFrame.delete(frame) - iframeFrameByFrameName.delete(name) - } - const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { - clearIframeFrameName(frame) - if (!clickedLinkRoutingActive || frame.isDestroyed()) { - return - } - const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` - iframeFrameNameByFrame.set(frame, name) - iframeFrameByFrameName.set(name, frame) - // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. - void frame - .executeJavaScript( - buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), - false - ) - .catch(() => { - if (iframeFrameNameByFrame.get(frame) === name) { - clearIframeFrameName(frame) - } - }) - } - const handleFrameCreated = ( - _event: Electron.Event, - { frame }: Electron.FrameCreatedDetails - ): void => { - if (!clickedLinkFrameName || !frame || frame.parent === null) { - return - } - for (const knownFrame of iframeFrameNameByFrame.keys()) { - if (knownFrame.isDestroyed()) { - clearIframeFrameName(knownFrame) - } - } - const installAfterDomReady = (): void => { - pendingIframeRoutingInstalls.delete(frame) - installIframeClickedLinkRouting(frame) - } - pendingIframeRoutingInstalls.set(frame, installAfterDomReady) - frame.once('dom-ready', installAfterDomReady) - } - if (clickedLinkFrameName) { - guest.on('frame-created', handleFrameCreated) - } - const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { - // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. - this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) - } - guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { - const ownerContext = this.resolvePopupOwnerContext(guest.id) - const browserTabId = ownerContext?.browserTabId ?? null - const browserUrl = normalizeBrowserNavigationUrl(url) - const externalUrl = normalizeExternalBrowserUrl(url) - const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) - const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined - let isClickedLink = Boolean( - expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName - ) - if (!isClickedLink && iframeFrame) { - isClickedLink = true - clearIframeFrameName(iframeFrame) - queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) - } - - if (isClickedLink) { - if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(browserUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. - return { action: 'deny' } - } - - // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is - // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are - // excluded by isNewBrowserTabPopupIntent and still get a real child window below. - if ( - ownerContext && - externalUrl && - isNewBrowserTabPopupIntent({ frameName, disposition, features }) - ) { - // Why: one activation lets a page loop window.open, and each routed tab persists into - // workspace session state, so it survives the quit that used to clear popup windows. - if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'blocked' - }) - return { action: 'deny' } - } - if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. - return { action: 'deny' } - } - - // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. - const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) - if (browserTabId && canOpenAsChild) { - // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. - return { - action: 'allow', - overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, - // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. - createWindow: (options: PopupChildWindowOptions) => - this.createPopupChildWindowWithOriginBar(guest, url, options) - } - } else if (externalUrl) { - // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. - void shell.openExternal(redactKagiSessionToken(externalUrl)) - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-external' - }) - } else { - // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(url), - action: 'blocked' - }) - } - return { action: 'deny' } - }) - - const navigationGuard = (event: Electron.Event, url: string): boolean => { - // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. - if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { - return true - } - // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. - if (url.startsWith('file:')) { - event.preventDefault() - return false - } - if (!normalizeBrowserNavigationUrl(url)) { - // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. - event.preventDefault() - return false - } - return true - } - - const willRedirectHandler = ( - event: Electron.Event, - url: string, - _isInPlace: boolean, - isMainFrame: boolean - ): void => { - if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { - return - } - this.updatePendingNavigationForRedirect(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) - } - - const didFailLoadHandler = ( - _event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ): void => { - if (!isMainFrame) { - return - } - // Why: a nav that never committed must not leave its target standing as the tab's host. - const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) - if (failedNavigationWasCurrent) { - // The attempted host never committed, so restore every UA layer to the document that remains. - this.applyGoogleAuthUserAgent(guest, guest.getURL()) - } - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - const certificateFailure = browserPageId - ? this.certificateTrustController?.getFailure(browserPageId) - : null - if ( - certificateFailure && - toSecureCertificateEndpoint(validatedURL || guest.getURL()) === - toSecureCertificateEndpoint(certificateFailure.origin) - ) { - // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. - return - } - if (errorCode === -3) { - // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. - const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) - if (clearedError !== undefined) { - this.clearedLoadErrorsByGuestId.delete(guest.id) - this.loadErrorsByGuestId.set(guest.id, clearedError) - this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) - this.notifyBrowserGuestStateChanged(guest.id) - } - return - } - this.clearedLoadErrorsByGuestId.delete(guest.id) - const loadError = this.buildLoadError( - errorCode, - errorDescription || 'This site could not be reached.', - validatedURL || guest.getURL() || 'about:blank' - ) - this.loadErrorsByGuestId.set(guest.id, loadError) - this.forwardOrQueueGuestLoadFailure(guest.id, loadError) - this.notifyBrowserGuestStateChanged(guest.id) - } - - const didStartNavigationHandler = ( - _event: Electron.Event, - url: string, - _isInPlace: boolean, - isMainFrame: boolean - ): void => { - if (!isMainFrame || isChromiumInternalErrorUrl(url)) { - return - } - // Why: getURL() still reports the previous committed URL until this navigation commits, so - // every UA writer must read the in-flight target or they disagree about the tab's host. - this.startPendingNavigation(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url) - this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) - // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. - this.pendingLoadFailuresByGuestId.delete(guest.id) - const activeError = this.loadErrorsByGuestId.get(guest.id) - if (activeError === undefined) { - // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure. - this.clearedLoadErrorsByGuestId.delete(guest.id) - return - } - this.clearedLoadErrorsByGuestId.set(guest.id, activeError) - this.loadErrorsByGuestId.delete(guest.id) - this.notifyBrowserGuestStateChanged(guest.id) - } - - const didNavigateHandler = (_event: Electron.Event, url: string): void => { - // Why: once committed, getURL() reports this url, so the pending target is redundant. - this.pendingNavigationByGuestId.delete(guest.id) - // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. - this.clearedLoadErrorsByGuestId.delete(guest.id) - this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) - } - - guest.on('will-navigate', navigationGuard) - guest.on('will-redirect', willRedirectHandler) - guest.on('did-start-navigation', didStartNavigationHandler) - guest.on('did-navigate', didNavigateHandler) - guest.on('did-fail-load', didFailLoadHandler) - const handleDestroyed = (): void => { - // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - - // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. - this.policyCleanupByGuestId.set(guest.id, () => { - disposeAntiDetection() - try { - guest.off('destroyed', handleDestroyed) - guest.off('did-create-window', handleDidCreateWindow) - if (clickedLinkFrameName) { - clickedLinkRoutingActive = false - guest.off('dom-ready', installClickedLinkRouting) - guest.off('frame-created', handleFrameCreated) - for (const [frame, install] of pendingIframeRoutingInstalls) { - if (!frame.isDestroyed()) { - frame.off('dom-ready', install) - } - } - pendingIframeRoutingInstalls.clear() - iframeFrameNameByFrame.clear() - iframeFrameByFrameName.clear() - } - } catch { - // guest may already be destroyed - } - if (!guest.isDestroyed()) { - guest.off('will-navigate', navigationGuard) - guest.off('will-redirect', willRedirectHandler) - guest.off('did-start-navigation', didStartNavigationHandler) - guest.off('did-navigate', didNavigateHandler) - guest.off('did-fail-load', didFailLoadHandler) - } - }) - } - - /** - * A workspace document is not the web: no popups, no link routing, no anti-detection, and no - * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is - * this method's teardown, so a retired preview drops its listeners on the same path. - */ - private attachWorkspaceDocGuestPolicies( - guest: Electron.WebContents, - host: Electron.WebContents - ): void { - const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) - const handleDestroyed = (): void => { - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - this.policyCleanupByGuestId.set(guest.id, () => { - disposeDocPolicy() - try { - guest.off('destroyed', handleDestroyed) - } catch { - // guest may already be destroyed - } - }) - } - - // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, - // not the request header, so the header-level Firefox switch in setupClientHintsOverride - // must be matched here per navigation or the two layers disagree — itself a bot tell. - // Restores the session's base identity off the auth hosts. Native-UA profiles opt out - // of the whole clean-UA path, so they keep their untouched identity everywhere. - private applyGoogleAuthUserAgent( - guest: Electron.WebContents, - url: string, - options: { duringRedirect?: boolean } = {} - ): void { - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct - // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. - // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so - // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. - const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) - // Session state is authoritative before renderer registration and after a native profile imports a source UA. - const mode = - getBrowserSessionUserAgentMode(guest.session) ?? - (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) - if (mode === 'native') { - return - } - const firefoxUa = googleAuthUserAgent() - const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) - const latestPendingOverride = overrideState?.pending.at(-1) - const confirmedOverride = overrideState?.confirmed - const currentOverride = - latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) - ? latestPendingOverride - : confirmedOverride - const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() - const nextUa = isGoogleAuthUrl(url) - ? firefoxUa - : // Only restore when the auth-host override is actually in place, so normal - // navigation never touches the session UA. - currentUa === firefoxUa - ? guest.session.getUserAgent() - : null - let authOverrideIssuedOverCdp = false - if (nextUa !== null && nextUa !== currentUa) { - // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight - // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain - // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without - // touching the navigation, and it outranks the WebContents UA from then on, so a guest that - // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride - // rewrites User-Agent per request for auth-host URLs on its own. - if (options.duringRedirect === true || overrideState !== undefined) { - if (this.canOverrideUserAgentOverCdp(guest)) { - authOverrideIssuedOverCdp = true - // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers - // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off - // them, any mobile preset preserved. Writing the session UA directly would put the - // unlaundered Electron token back on the wire. - void this.applyAuthUserAgentOverrideOverCdp( - guest, - (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? - false, - url, - nextUa - ) - } - // Why: with no debugger there is no way to retarget the identity without cancelling the - // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. - } else { - guest.setUserAgent(nextUa) - } - } - // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of - // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. - if (browserPageId && !authOverrideIssuedOverCdp) { - this.reapplyViewportUserAgentOverride(guest, browserPageId, url) - } - } - - private canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { - try { - return !guest.isDestroyed() && guest.debugger.isAttached() - } catch { - return false - } - } - - private applyAuthUserAgentOverrideOverCdp( - guest: Electron.WebContents, - mobile: boolean, - url: string, - userAgent: string - ): Promise { - if (!this.canOverrideUserAgentOverCdp(guest)) { - return Promise.resolve(false) - } - const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { - confirmed: null, - nextSequence: 0, - pending: [] - } - const operation = { sequence: ++state.nextSequence, userAgent } - state.pending.push(operation) - this.authUserAgentOverrideStateByGuestId.set(guest.id, state) - return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( - () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), - () => { - this.settleAuthUserAgentOverride(guest.id, state, operation, false) - return false - } - ) - } - - private settleAuthUserAgentOverride( - guestId: number, - state: AuthUserAgentOverrideState, - operation: AuthUserAgentOverrideOperation, - succeeded: boolean - ): boolean { - if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { - return false - } - if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { - state.confirmed = operation - } - const pendingIndex = state.pending.indexOf(operation) - if (pendingIndex !== -1) { - state.pending.splice(pendingIndex, 1) - } - if (state.confirmed === null && state.pending.length === 0) { - this.authUserAgentOverrideStateByGuestId.delete(guestId) - } - return true - } - - private startPendingNavigation(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] - }) - } - - private updatePendingNavigationForRedirect(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: [] - }) - return - } - pending.currentUrl = url - } - - private failPendingNavigation(guestId: number, failedUrl: string): boolean { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - return false - } - const supersededIndex = pending.supersededUrls.indexOf(failedUrl) - if (supersededIndex !== -1) { - pending.supersededUrls.splice(supersededIndex, 1) - return false - } - if (pending.currentUrl !== failedUrl) { - return false - } - this.pendingNavigationByGuestId.delete(guestId) - return true - } - - // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host - // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through - // here or two writers racing the same navigation will pick opposite identities. - private resolveTabNavigationUrl(guest: Electron.WebContents): string { - return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() - } - - // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, - // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an - // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the - // request header says Firefox — the two-layer disagreement this scope exists to remove. - private reapplyViewportUserAgentOverride( - guest: Electron.WebContents, - browserTabId: string, - url: string - ): void { - const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - if (mobile === undefined) { - return - } - // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the - // later-issued write wins. What matters is that both writers resolve the SAME host, which they - // now do via the navigation target rather than the stale committed URL. - void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) - } - - private async sendViewportUserAgentOverride( - guest: Electron.WebContents, - mobile: boolean, - url?: string, - baseUserAgent?: string - ): Promise { - if (guest.isDestroyed() || !guest.debugger.isAttached()) { - return - } - await guest.debugger.sendCommand( - 'Emulation.setUserAgentOverride', - buildViewportUserAgentOverride({ - url: url ?? this.resolveTabNavigationUrl(guest), - mobile, - // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: - // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to - // the CDP override, so reading it back here would republish that identity on ordinary hosts. - baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) - }) - ) - } - - /** Route guests own their own popup handler, so their denials arrive here instead. */ - reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { - this.forwardOrQueuePopupEvent(input.openerWebContentsId, { - origin: safeOrigin(input.url), - action: 'blocked' - }) - } - - private createPopupChildWindowWithOriginBar( - openerGuest: Electron.WebContents, - targetUrl: string, - options: PopupChildWindowOptions - ): Electron.WebContents { - const popup = openPopupWithOriginBar(options, targetUrl) - // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. - this.attachGuestPolicies( - popup.contentWebContents, - this.resolvePopupOwnerContext(openerGuest.id) - ) - this.forwardOrQueuePopupEvent(openerGuest.id, { - origin: safeOrigin(targetUrl), - action: 'opened-in-orca' - }) - // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. - const closePopupWithOpener = (): void => popup.close() - openerGuest.once('destroyed', closePopupWithOpener) - popup.onClosed(() => { - if (!openerGuest.isDestroyed()) { - openerGuest.off('destroyed', closePopupWithOpener) - } - }) - return popup.contentWebContents - } - - private retireStaleGuestWebContents(previousWebContentsId: number): void { - // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. - this.cleanupGuestPolicyAttachment(previousWebContentsId) - } - - private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - const isPrimaryGuest = browserTabId !== undefined - if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { - this.webContentsIdByTabId.delete(browserTabId) - } - this.tabIdByWebContentsId.delete(guestWebContentsId) - this.certificateTrustController?.onGuestRetired(guestWebContentsId) - const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) - if (policyCleanup) { - policyCleanup() - this.policyCleanupByGuestId.delete(guestWebContentsId) - } - this.policyAttachedGuestIds.delete(guestWebContentsId) - this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) - this.offscreenGuestIds.delete(guestWebContentsId) - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) - this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) - this.pendingNavigationByGuestId.delete(guestWebContentsId) - // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. - if (isPrimaryGuest) { - for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { - if (owner.rootGuestWebContentsId === guestWebContentsId) { - this.popupOwnerContextByGuestId.delete(popupGuestId) - } - } - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.loadErrorsByGuestId.delete(guestWebContentsId) - this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - this.cancelPendingDownloadsForGuest(guestWebContentsId) - } - - registerGuest({ - browserPageId, - browserTabId: legacyBrowserTabId, - workspaceId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId, - rendererWebContentsId - }: BrowserGuestRegistration): boolean { - const browserTabId = browserPageId ?? legacyBrowserTabId - // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one - // id resolves in both and the tool door silently prefers the document guest over the page. - if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { - return false - } - // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. - this.cancelGrabOp(browserTabId, 'evicted') - - const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - - // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. - if (guest.getType() !== 'webview') { - return false - } - if (!this.policyAttachedGuestIds.has(webContentsId)) { - // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. - return false - } - - const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserTabId) - this.viewportScrollStateByTabId.delete(browserTabId) - } - this.webContentsIdByTabId.set(browserTabId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserTabId) - if (workspaceId) { - this.workspaceIdByPageId.set(browserTabId, workspaceId) - } - this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserTabId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserTabId) - } - this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) - if (worktreeId) { - this.worktreeIdByTabId.set(browserTabId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) - - this.setupContextMenu(browserTabId, guest) - this.setupGrabShortcut(browserTabId, guest) - this.setupShortcutForwarding(browserTabId, guest) - this.setupMouseWheelZoomForwarding(browserTabId, guest) - this.flushPendingLoadFailure(browserTabId, webContentsId) - this.flushPendingPermissionEvents(browserTabId, webContentsId) - this.flushPendingPopupEvents(browserTabId, webContentsId) - this.flushPendingDownloadRequests(browserTabId, webContentsId) - return true - } - - unregisterGuest(browserTabId: string): void { - // Why the check on the exit door too: a document page withdraws by revoking its grant, never - // through here, so its id arriving is misaddressed — and the cancel below would evict that - // preview's live grab on the strength of it. - if (isWorkspaceDocPageId(browserTabId)) { - return - } - // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. - this.cancelGrabOp(browserTabId, 'evicted') - - // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. - const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (guestWebContentsId !== undefined) { - this.cleanupGuestPolicyAttachment(guestWebContentsId) - } - - const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (cleanup) { - cleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (shortcutCleanup) { - shortcutCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (fwdCleanup) { - fwdCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (mouseWheelZoomCleanup) { - mouseWheelZoomCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. - for (const [downloadId, download] of this.downloadsById.entries()) { - if (download.browserTabId === browserTabId && !download.terminalEvent) { - this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') - } - } - const wcId = this.webContentsIdByTabId.get(browserTabId) - if (wcId !== undefined) { - this.tabIdByWebContentsId.delete(wcId) - } - this.webContentsIdByTabId.delete(browserTabId) - this.rendererWebContentsIdByTabId.delete(browserTabId) - this.workspaceIdByPageId.delete(browserTabId) - this.sessionProfileIdByPageId.delete(browserTabId) - this.userAgentModeByPageId.delete(browserTabId) - this.worktreeIdByTabId.delete(browserTabId) - // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. - this.viewportOpsByTabId.delete(browserTabId) - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - this.viewportPresetActiveByTabId.delete(browserTabId) - this.viewportScrollStateByTabId.delete(browserTabId) - if (wcId !== undefined) { - this.pendingNavigationByGuestId.delete(wcId) - } - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - - // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. - registerOffscreenGuest({ - browserPageId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId - }: { - browserPageId: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - }): boolean { - // Why the same check on both registration doors: one id resolving in both halves is the exact - // confusion the split registries exist to prevent. - if (isWorkspaceDocPageId(browserPageId)) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. - this.offscreenGuestIds.add(webContentsId) - this.attachGuestPolicies(guest) - const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserPageId) - this.viewportScrollStateByTabId.delete(browserPageId) - } - this.webContentsIdByTabId.set(browserPageId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserPageId) - this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserPageId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserPageId) - } - if (worktreeId) { - this.worktreeIdByTabId.set(browserPageId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) - return true - } - - unregisterAll(): void { - // Cancel all active grab ops before tearing down registrations - this.grabSessionController.cancelAll('evicted') - for (const downloadId of this.downloadsById.keys()) { - this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') - } - browserDownloadDestinationReservations.clear() - for (const browserTabId of this.webContentsIdByTabId.keys()) { - this.unregisterGuest(browserTabId) - } - this.policyAttachedGuestIds.clear() - this.offscreenGuestIds.clear() - // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. - for (const cleanup of this.policyCleanupByGuestId.values()) { - cleanup() - } - this.policyCleanupByGuestId.clear() - this.clickedLinkFrameNameByGuestId.clear() - this.tabIdByWebContentsId.clear() - this.popupOwnerContextByGuestId.clear() - this.pageInitiatedTabBudgetByRootGuestId.clear() - this.worktreeIdByTabId.clear() - this.sessionProfileIdByPageId.clear() - this.userAgentModeByPageId.clear() - this.viewportUaOverrideMobileByTabId.clear() - this.viewportPresetActiveByTabId.clear() - this.viewportScrollStateByTabId.clear() - this.authUserAgentOverrideStateByGuestId.clear() - this.pendingNavigationByGuestId.clear() - this.pendingLoadFailuresByGuestId.clear() - this.loadErrorsByGuestId.clear() - this.clearedLoadErrorsByGuestId.clear() - this.pendingPermissionEventsByGuestId.clear() - this.pendingPopupEventsByGuestId.clear() - this.pendingDownloadIdsByGuestId.clear() - this.mouseWheelZoomCleanupByTabId.clear() - this.annotationViewportBridgeOpsByTabId.clear() - } - - getGuestWebContentsId(browserTabId: string): number | null { - return this.webContentsIdByTabId.get(browserTabId) ?? null - } - - getWebContentsIdByTabId(): Map { - return this.webContentsIdByTabId - } - - getTabIdForWebContentsId(webContentsId: number): string | null { - return this.tabIdByWebContentsId.get(webContentsId) ?? null - } - - getWorktreeIdForTab(browserTabId: string): string | undefined { - return this.worktreeIdByTabId.get(browserTabId) - } - - getRendererContextForGuest( - guestWebContentsId: number - ): { browserPageId: string; renderer: Electron.WebContents } | null { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return null - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - return renderer ? { browserPageId, renderer } : null - } - - getSessionProfileIdForTab(browserTabId: string): string | null { - return this.sessionProfileIdByPageId.get(browserTabId) ?? null - } - - getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { - const webContentsId = this.webContentsIdByTabId.get(browserPageId) - return webContentsId === undefined - ? null - : (this.loadErrorsByGuestId.get(webContentsId) ?? null) - } - - getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { - return this.certificateTrustController?.getFailure(browserPageId) ?? null - } - - getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { - if (this.popupOwnerContextByGuestId.has(webContentsId)) { - return null - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null - const offscreen = this.offscreenGuestIds.has(webContentsId) - if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { - return null - } - if (!offscreen) { - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { - return null - } - } - return { - browserPageId, - worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, - sessionProfileId: browserPageId - ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) - : null, - owner: offscreen ? 'offscreen' : 'desktop-webview' - } - } - - // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. - private buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { - return { - code, - description, - validatedUrl: redactKagiSessionToken(rawUrl) - } - } - - notifyCertificateFailureChanged( - webContentsId: number, - failure: BrowserCertificateFailure | null, - navigationUrl?: string - ): void { - if (failure && navigationUrl) { - const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, navigationUrl) - this.loadErrorsByGuestId.set(webContentsId, loadError) - this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) - if (!browserPageId) { - return - } - if (this.offscreenGuestIds.has(webContentsId)) { - this.notifyBrowserGuestStateChanged(webContentsId) - return - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) - } - - private notifyBrowserGuestStateChanged(webContentsId: number): void { - if (!this.offscreenGuestIds.has(webContentsId)) { - return - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) - const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null - if (worktreeId) { - // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. - try { - this.browserGuestStateChangedListener?.(worktreeId) - } catch (error) { - console.error('[browser-manager] browserGuestStateChanged listener failed', error) - } - } - } - - notifyPermissionDenied(args: { - guestWebContentsId: number - permission: string - rawUrl: string - }): void { - this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { - permission: args.permission, - origin: safeOrigin(args.rawUrl) - }) - } - - handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { - const { guestWebContentsId, item } = args - const downloadId = randomUUID() - const requestedFilename = (() => { - try { - return item.getFilename() || 'download' - } catch { - return 'download' - } - })() - const totalBytes = (() => { - try { - const total = item.getTotalBytes() - return total > 0 ? total : null - } catch { - return null - } - })() - const mimeType = (() => { - try { - const mime = item.getMimeType() - return mime || null - } catch { - return null - } - })() - const origin = (() => { - try { - return safeOrigin(item.getURL()) - } catch { - return 'unknown' - } - })() - - // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself - // instead of reserving a name in the desktop Downloads folder. A popup downloads to its - // opener's page: the popup itself is a client-local transient with no logical page of its own. - const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) - const decision = routeBrowserClientDownload({ - guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId - }) - const clientRoute = decision.kind === 'remote' ? decision.route : null - const destination = (() => { - if (clientRoute) { - return { - filename: requestedFilename, - savePath: clientRoute.stagingPath, - reservationKey: null - } - } - // Why: a client-hosted download with no resolvable remote destination is canceled rather than - // written to this desktop's Downloads folder. - if (decision.kind === 'blocked') { - return null - } - try { - return browserDownloadDestinationReservations.reserve(requestedFilename) - } catch (error) { - console.error('[browser-download] Failed to choose download destination:', error) - return null - } - })() - - const fallbackSavePath = destination?.savePath ?? '' - - const download: ActiveDownload = { - downloadId, - guestWebContentsId, - browserTabId: null, - rendererWebContentsId: null, - origin, - filename: destination?.filename ?? requestedFilename, - totalBytes, - mimeType, - item, - savePath: fallbackSavePath, - reservationKey: destination?.reservationKey ?? null, - clientRoute, - remoteDestination: undefined, - receivedBytes: 0, - transientState: null, - terminalEvent: null, - startedSent: false, - cleanup: null - } - this.downloadsById.set(downloadId, download) - - const browserTabId = ownerContext?.browserTabId ?? null - if (browserTabId) { - this.bindDownloadToTab(downloadId, browserTabId) - } else { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] - pending.push(downloadId) - this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) - } - - if (!destination) { - this.finishDownloadInternal( - downloadId, - 'failed', - decision.kind === 'blocked' - ? 'Could not save the download to the remote workspace.' - : 'Could not choose a Downloads file name.' - ) - try { - item.cancel() - } catch { - // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. - } - return - } - - try { - item.setSavePath(destination.savePath) - } catch (error) { - console.error('[browser-download] Failed to set download destination:', error) - this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') - try { - item.cancel() - } catch { - // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. - } - return - } - - const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { - download.receivedBytes = this.getDownloadReceivedBytes(download.item) - download.transientState = state - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state - }) - } - const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { - const status: BrowserDownloadFinishedEvent['status'] = - state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' - const failure = - status === 'failed' - ? state === 'interrupted' - ? 'Download was interrupted.' - : 'Download failed.' - : null - if (download.clientRoute) { - void this.settleClientHostedDownload(download, status, failure) - return - } - this.finishDownloadInternal(download.downloadId, status, failure) - } - download.cleanup = (): void => { - try { - download.item.off('updated', updatedHandler) - download.item.off('done', doneHandler) - } catch { - // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. - } - } - item.on('updated', updatedHandler) - item.once('done', doneHandler) - - if (browserTabId) { - this.sendDownloadStarted(downloadId) - } - } - - cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { - const download = this.downloadsById.get(args.downloadId) - if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { - return false - } - this.cancelDownloadInternal(args.downloadId, 'Canceled.') - return true - } - - // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. - async openDevTools(browserTabId: string): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - // Offscreen guests have no visible window on this desktop; detaching DevTools would open it - // on the host display with no route back to the remote client. - if (this.offscreenGuestIds.has(webContentsId)) { - return false - } - guest.openDevTools({ mode: 'detach' }) - return true - } - - // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. - async setViewportOverride( - browserTabId: string, - override: BrowserViewportOverride | null - ): Promise { - // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. - const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (expectedWebContentsId !== undefined) { - // Keep host panning available while CDP applies the requested dimensions. The guest id fence - // prevents this intent from leaking to a replacement guest; clearing the preset removes it. - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: expectedWebContentsId, - active: override !== null - }) - } - // The renderer resizes the host before CDP completes; discard the old geometry until it - // reports the new pane bounds so a pending preset cannot route wheel input using stale limits. - this.viewportScrollStateByTabId.delete(browserTabId) - const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId)) - this.viewportOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. - if (this.viewportOpsByTabId.get(browserTabId) === next) { - this.viewportOpsByTabId.delete(browserTabId) - } - } - } - - async setAnnotationViewportBridge( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) - this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - } - } - - // Why the caller resolves the guest: the same bridge serves browsing pages and workspace - // documents, which live in different halves of the page registry. - // Why a resolver and not the guest itself: this op may have waited behind another one, and a - // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — - // injecting into the guest the request named would bridge a page nobody is looking at. - // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and - // taking an id it cannot act on would invite the next reader to act on it. - private async doSetAnnotationViewportBridgeImpl( - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - // Why no teardown here: the resolver already unregisters a page whose guest died, and the only - // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would - // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. - const guest = resolveGuest() - if (!guest || guest.isDestroyed()) { - return false - } - - try { - // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. - await guest.executeJavaScriptInIsolatedWorld( - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], - false - ) - return true - } catch { - return false - } - } - - private async doSetViewportOverrideImpl( - browserTabId: string, - override: BrowserViewportOverride | null, - expectedWebContentsId: number | undefined - ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId || webContentsId !== expectedWebContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - } catch (err) { - // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. - console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { - browserTabId, - webContentsId, - error: err instanceof Error ? err.message : String(err) - }) - return false - } - - const dbg = guest.debugger - try { - if (override) { - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width: override.width, - height: override.height, - deviceScaleFactor: override.deviceScaleFactor, - mobile: override.mobile - }) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: true - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: override.mobile, - maxTouchPoints: override.mobile ? 5 : 0 - }) - // Why: viewport sizing must not override a profile's explicit native-UA identity. - if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - // Navigation must see the preset intent while the final CDP command is in flight. - this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) - // Why: same sender as the navigation path, so both resolve the tab's host identically. - await this.sendViewportUserAgentOverride(guest, override.mobile) - } - } else { - await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: false - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: false, - maxTouchPoints: 0 - }) - const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - // A navigation after this point must not re-install the override behind the clear. - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - try { - if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { - const url = this.resolveTabNavigationUrl(guest) - const restored = await this.applyAuthUserAgentOverrideOverCdp( - guest, - false, - url, - isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() - ) - if (!restored) { - throw new Error('Failed to preserve auth user agent') - } - } else { - // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) - } - } catch (error) { - if (trackedMobile !== undefined) { - this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) - } - throw error - } - } - if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { - return false - } - return true - } catch { - return false - } - } - - // --- Browser Context Grab — main-owned operations --- - - /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ - /** - * The guest a request from `senderWebContentsId` may act on, across both halves of the page - * registry. This is the only door taught about workspace-document guests: they are kept out of - * the browsing maps entirely, so page management, agent commands, download routing and - * certificate attribution all miss them without a guard of their own — and a reader who opens a - * tool on the document in front of them still gets an answer. - */ - getAuthorizedGuest( - browserTabId: string, - senderWebContentsId: number - ): Electron.WebContents | null { - const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) - if (docGuest) { - return docGuest - } - const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) - if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { - return null - } - const guestId = this.webContentsIdByTabId.get(browserTabId) - if (guestId == null) { - return null - } - const guest = webContents.fromId(guestId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return null - } - return guest - } - - /** Returns true if a grab operation is currently active for this tab. */ - hasActiveGrabOp(browserTabId: string): boolean { - return this.grabSessionController.hasActiveGrabOp(browserTabId) - } - - /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ - async setGrabMode( - browserTabId: string, - enabled: boolean, - guest: Electron.WebContents - ): Promise { - if (!enabled) { - const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) - this.cancelGrabOp(browserTabId, 'user') - if (hadActiveGrabOp) { - return true - } - try { - await guest.executeJavaScript(buildGuestOverlayScript('teardown')) - return true - } catch { - return false - } - } - // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. - try { - await guest.executeJavaScript(buildGuestOverlayScript('arm')) - return true - } catch { - return false - } - } - - /** - * Await a single grab selection on the given tab; resolves once on click, cancel, or error. - * - * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. - */ - awaitGrabSelection( - browserTabId: string, - opId: string, - guest: Electron.WebContents - ): Promise { - return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) - } - - /** Cancel an active grab operation for the given tab. */ - cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { - this.grabSessionController.cancelGrabOp(browserTabId, reason) - } - - /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ - async captureSelectionScreenshot( - _browserTabId: string, - rect: BrowserGrabRect, - guest: Electron.WebContents - ): Promise { - return captureGrabSelectionScreenshot(rect, guest) - } - - /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ - async extractHoverPayload( - _browserTabId: string, - guest: Electron.WebContents - ): Promise { - try { - const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) - if (!rawPayload || typeof rawPayload !== 'object') { - return null - } - return clampGrabPayload(rawPayload) - } catch { - return null - } - } - - private setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { - this.contextMenuCleanupByTabId.set( - browserTabId, - setupGuestContextMenu({ - browserTabId, - guest, - resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) - }) - ) - } - - // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. - private setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - - this.grabShortcutCleanupByTabId.set( - browserTabId, - setupGrabShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), - getKeybindings: () => this.settingsResolver?.().keybindings - }) - ) - } - - // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. - private setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - - this.shortcutForwardingCleanupByTabId.set( - browserTabId, - setupGuestShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, - isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, - getKeybindings: () => this.settingsResolver?.().keybindings, - resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, - resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null - }) - ) - } - - private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - - this.mouseWheelZoomCleanupByTabId.set( - browserTabId, - setupGuestMouseWheelZoomForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - isViewportPresetActive: () => { - const state = this.viewportPresetActiveByTabId.get(browserTabId) - return state?.guestWebContentsId === guest.id && state.active - }, - canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), - onViewportWheelConsumed: (deltaX, deltaY) => - this.recordViewportScrollDelta(browserTabId, deltaX, deltaY) - }) - ) - } - - private canViewportScroll(browserTabId: string, mouse: Electron.MouseWheelInputEvent): boolean { - const state = this.viewportScrollStateByTabId.get(browserTabId) - if (!state) { - return false - } - const deltaX = typeof mouse.deltaX === 'number' ? mouse.deltaX : 0 - const deltaY = typeof mouse.deltaY === 'number' ? mouse.deltaY : 0 - const canScrollAxis = (delta: number, position: number, maximum: number): boolean => { - if (delta < 0) { - return position > 0 - } - if (delta > 0) { - return position < maximum - } - return false - } - return ( - canScrollAxis(deltaX, state.scrollLeft, state.maxScrollLeft) || - canScrollAxis(deltaY, state.scrollTop, state.maxScrollTop) - ) - } - - private forwardOrQueueGuestLoadFailure( - guestWebContentsId: number, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (!browserTabId) { - // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. - this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) - return - } - this.sendGuestLoadFailure(browserTabId, loadError) - } - - private forwardOrQueuePermissionDenied( - guestWebContentsId: number, - event: PendingPermissionEvent - ): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPermissionDenied(browserTabId, event) - } - - private flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPermissionDenied(browserTabId, event) - } - } - - private sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:permission-denied', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPermissionDeniedEvent) - } - - private forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPopupEvent(browserTabId, event) - } - - private flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPopupEvent(browserTabId, event) - } - } - - private sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:popup', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPopupEvent) - } - - private bindDownloadToTab(downloadId: string, browserTabId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - download.browserTabId = browserTabId - download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null - } - - private flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - for (const downloadId of pending) { - this.bindDownloadToTab(downloadId, browserTabId) - this.flushDownloadSnapshot(downloadId) - } - } - - private flushDownloadSnapshot(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - this.sendDownloadStarted(downloadId) - if (download.receivedBytes > 0 || download.transientState) { - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state: download.transientState - }) - } - if (download.terminalEvent) { - this.sendDownloadFinished(download.browserTabId, { - ...download.terminalEvent, - browserPageId: download.browserTabId ?? undefined - }) - this.downloadsById.delete(downloadId) - } - } - - private sendDownloadStarted(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download?.browserTabId) { - return - } - if (download.startedSent) { - return - } - const renderer = this.resolveRendererForBrowserTab(download.browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-requested', { - browserPageId: download.browserTabId, - downloadId: download.downloadId, - origin: download.origin, - filename: download.filename, - totalBytes: download.totalBytes, - mimeType: download.mimeType, - savePath: download.savePath, - status: 'downloading' - } satisfies BrowserDownloadRequestedEvent) - download.startedSent = true - } - - private sendDownloadProgress( - browserTabId: string | null, - payload: BrowserDownloadProgressEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-progress', payload) - } - - private sendDownloadFinished( - browserTabId: string | null, - payload: BrowserDownloadFinishedEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-finished', payload) - } - - private async settleClientHostedDownload( - download: ActiveDownload, - status: BrowserDownloadFinishedEvent['status'], - failure: string | null - ): Promise { - const route = download.clientRoute - if (!route) { - return - } - if (status !== 'completed') { - download.clientRoute = null - await route.abort().catch(() => undefined) - this.finishDownloadInternal(download.downloadId, status, failure) - return - } - try { - // Why: the route stays on the record for the whole commit, which spans many round trips -- a - // cancel arriving mid-stream has to find something to abort or the bytes land anyway. - const remoteDestination = await route.complete(download.filename) - download.clientRoute = null - download.remoteDestination = remoteDestination - // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. - download.savePath = '' - this.finishDownloadInternal(download.downloadId, 'completed', null) - } catch (error) { - download.clientRoute = null - if (download.terminalEvent) { - // A cancel already reported the outcome; this rejection is that cancel taking effect. - return - } - console.error('[browser-download] Failed to save download to the remote workspace:', error) - this.finishDownloadInternal( - download.downloadId, - 'failed', - 'Could not save the download to the remote workspace.' - ) - } - } - - private cancelDownloadInternal(downloadId: string, reason: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - const shouldSendCancel = !download.terminalEvent - - try { - download.item.cancel() - } catch { - // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. - } - - if (shouldSendCancel) { - this.finishDownloadInternal(downloadId, 'canceled', reason || null) - return - } - - this.downloadsById.delete(downloadId) - } - - private finishDownloadInternal( - downloadId: string, - status: BrowserDownloadFinishedEvent['status'], - error: string | null - ): void { - const download = this.downloadsById.get(downloadId) - if (!download || download.terminalEvent) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - browserDownloadDestinationReservations.release(download.reservationKey) - download.reservationKey = null - if (download.clientRoute) { - // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. - void download.clientRoute.abort().catch(() => undefined) - download.clientRoute = null - } - const event: BrowserDownloadFinishedEvent = { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - status, - savePath: download.savePath || null, - ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), - error - } - download.terminalEvent = event - if (download.browserTabId) { - this.sendDownloadStarted(downloadId) - this.sendDownloadFinished(download.browserTabId, event) - this.downloadsById.delete(downloadId) - } - } - - private cancelPendingDownloadsForGuest(guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - if (!pending?.length) { - return - } - for (const downloadId of pending) { - const download = this.downloadsById.get(downloadId) - if (!download) { - continue - } - if (download.terminalEvent) { - this.downloadsById.delete(downloadId) - continue - } - this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') - const afterCancel = this.downloadsById.get(downloadId) - if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { - this.downloadsById.delete(downloadId) - } - } - } - - private getDownloadReceivedBytes(item: Electron.DownloadItem): number { - try { - return Math.max(0, item.getReceivedBytes()) - } catch { - return 0 - } - } - - private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) - if (!pending) { - return - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.sendGuestLoadFailure(browserTabId, pending) - } - - private sendGuestLoadFailure( - browserTabId: string, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - - // Why: redact Kagi session tokens before the renderer persists validatedUrl to disk. - renderer.send('browser:guest-load-failed', { - browserPageId: browserTabId, - loadError: { - ...loadError, - validatedUrl: redactKagiSessionToken(loadError.validatedUrl) - } - }) - } - - private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return false - } - const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) - if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { - return false - } - // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. - renderer.send('browser:open-link-in-orca-tab', { - browserPageId: browserTabId, - url: normalizedUrl - }) - return true - } -} +export class BrowserManager extends BrowserManagerFinal {} export const browserManager = new BrowserManager() export const browserCertificateTrustController = new BrowserCertificateTrustController({ diff --git a/src/main/codex-accounts/runtime-home-service-auth-core.ts b/src/main/codex-accounts/runtime-home-service-auth-core.ts new file mode 100644 index 00000000000..090c82626cd --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-core.ts @@ -0,0 +1,174 @@ +import { existsSync, chmodSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from './fs-utils' +import type { + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthProvenance +} from './runtime-home-service-types' +import { CodexRuntimeHomeLegacyMigration } from './runtime-home-service-legacy-migration' + +export abstract class CodexRuntimeHomeAuthCore extends CodexRuntimeHomeLegacyMigration { + protected readSystemDefaultAuth(): string | null { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null + } + + protected writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean { + // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. + const runtimeAuthPath = this.getRuntimeAuthPath() + if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { + return false + } + const provenance: CodexSharedRuntimeAuthProvenance = + owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner + const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) + if (runtimeAuthComparison === null) { + // Why: an unreadable runtime auth.json may hold a token Codex rotated a + // moment ago. Treating "could not read" as "differs" sent execution to the + // unconditional write below, consuming that rotation and logging the user + // out for good. Refuse; the next sync retries. + return false + } + const runtimeAuthAlreadyMatches = runtimeAuthComparison + if ( + runtimeAuthAlreadyMatches && + this.sharedRuntimeAuthProvenanceMatches( + this.resolveSharedRuntimeAuthProvenanceStatus(), + provenance + ) + ) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.clearRuntimeLogoutMarker() + return true + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson: contents + }) + if (runtimeAuthAlreadyMatches) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + const replaced = options + ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { + mode: 0o600 + }) + : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) + if (!replaced) { + return false + } + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + + /** + * `true`/`false` only when the bytes were actually read; `null` when the file + * could not be read at all. The old `catch { return false }` reported "these + * differ" for a file nobody could open, and every caller reads that as + * permission to write. + */ + protected compareFileContents(targetPath: string, contents: string): boolean | null { + try { + return readFileSync(targetPath, 'utf-8') === contents + } catch (error) { + return isDefinitiveAbsence(error) ? false : null + } + } + + protected fileContentsEqual(targetPath: string, contents: string): boolean { + return this.compareFileContents(targetPath, contents) === true + } + + protected fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean { + if (expectedContents === null) { + // Why: `!existsSync` does report `true` for a locked file, but this branch + // is not where that matters — the write it guards is + // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the + // real file and refuses on its own. Classifying here would be a guard no + // test can drive. + return !existsSync(targetPath) + } + return this.fileContentsEqual(targetPath, expectedContents) + } + + protected ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + + protected getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { + const marker = this.readRuntimeLogoutMarker() + if (!marker) { + return { kind: 'missing' } + } + const systemDefaultAuthJson = this.readSystemDefaultAuth() + if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { + return { kind: 'applies' } + } + this.clearRuntimeLogoutMarker() + return { kind: 'system-default-changed', systemDefaultAuthJson } + } + + protected persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { + const marker: CodexRuntimeLogoutMarker = { + systemDefaultAuthJson, + loggedOutAt: Date.now() + } + writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { + mode: 0o600 + }) + } + + protected readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown + } catch { + return null + } + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !('systemDefaultAuthJson' in parsed) || + !('loggedOutAt' in parsed) + ) { + return null + } + const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } + if ( + (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || + typeof marker.loggedOutAt !== 'number' + ) { + return null + } + return marker as CodexRuntimeLogoutMarker + } + + protected clearRuntimeLogoutMarker(): void { + rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-provenance.ts b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts new file mode 100644 index 00000000000..d4018741405 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { writeFileAtomically } from './fs-utils' +import type { + CodexSharedRuntimeAuthPendingProvenance, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import { CodexRuntimeHomeAuthCore } from './runtime-home-service-auth-core' + +export abstract class CodexRuntimeHomeAuthProvenance extends CodexRuntimeHomeAuthCore { + protected persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void { + writeFileAtomically( + this.getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify(provenance, null, 2)}\n`, + { mode: 0o600 } + ) + } + + protected markSharedRuntimeAuthManaged(accountId: string): void { + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + status.kind === 'committed' && + status.provenance.owner === 'managed' && + status.provenance.accountId === accountId + ) { + return + } + const runtimeAuthJson = this.readRuntimeAuthForProvenance() + const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) + const provenance: CodexSharedRuntimeAuthProvenance = { + owner: 'managed', + accountId, + ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson + }) + if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { + this.persistSharedRuntimeAuthProvenance(provenance) + } + } + + protected getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null { + if (status.kind !== 'committed') { + return null + } + const baseline = + status.provenance.owner === 'system-default' + ? { authJson: status.provenance.authJson } + : status.provenance.systemDefaultBaseline + return baseline && runtimeAuthJson === baseline.authJson ? baseline : null + } + + protected restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null { + const baseline = provenance.systemDefaultBaseline + if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + const restored = { owner: 'system-default' as const, authJson: baseline.authJson } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: restored, + runtimeAuthJson: baseline.authJson + }) + if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + this.persistSharedRuntimeAuthProvenance(restored) + return restored + } + + protected sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean { + if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { + return false + } + return expected.owner === 'system-default' + ? status.provenance.owner === 'system-default' && + status.provenance.authJson === expected.authJson + : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId + } + + protected resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { + const provenancePath = this.getSharedRuntimeAuthProvenancePath() + if (!existsSync(provenancePath)) { + return { kind: 'missing' } + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown + } catch { + return { kind: 'fenced' } + } + const committed = this.parseSharedRuntimeAuthProvenance(parsed) + if (committed) { + return { kind: 'committed', provenance: committed } + } + const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) + if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { + return { kind: 'fenced' } + } + try { + this.persistSharedRuntimeAuthProvenance(pending.next) + return { kind: 'committed', provenance: pending.next } + } catch { + return { kind: 'fenced' } + } + } + + protected parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const provenance = value as Record + if ( + provenance.owner === 'system-default' && + (typeof provenance.authJson === 'string' || provenance.authJson === null) + ) { + return { owner: 'system-default', authJson: provenance.authJson } + } + if ( + provenance.owner !== 'managed' || + typeof provenance.accountId !== 'string' || + provenance.accountId.length === 0 + ) { + return null + } + const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) + if ('systemDefaultBaseline' in provenance && !baseline) { + return null + } + return { + owner: 'managed', + accountId: provenance.accountId, + ...(baseline ? { systemDefaultBaseline: baseline } : {}) + } + } + + protected parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const baseline = value as Record + return typeof baseline.authJson === 'string' || baseline.authJson === null + ? { authJson: baseline.authJson } + : null + } + + protected parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const pending = value as Record + const next = this.parseSharedRuntimeAuthProvenance(pending.next) + return pending.owner === 'pending' && + next && + (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) + ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } + : null + } + + protected readRuntimeAuthForProvenance(): string | null { + try { + return readFileSync(this.getRuntimeAuthPath(), 'utf-8') + } catch { + return null + } + } + + protected readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { + let rawContents: string + try { + rawContents = readFileSync(snapshotPath, 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(rawContents) as unknown + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + 'authJson' in parsed && + (typeof (parsed as { authJson: unknown }).authJson === 'string' || + (parsed as { authJson: unknown }).authJson === null) + ) { + return parsed as CodexSystemDefaultSnapshot + } + // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + !('authJson' in parsed) + ) { + return { authJson: rawContents } + } + } catch { + return null + } + return null + } + + clearSystemDefaultSnapshot(): void { + rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts new file mode 100644 index 00000000000..d0fa50fa5ec --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts @@ -0,0 +1,37 @@ +import { codexAuthIsFresher } from './codex-auth-identity' + +function readCodexLastRefresh(authJson: string): number | null { + try { + const parsed = JSON.parse(authJson) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const value = (parsed as Record).last_refresh + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) ? timestamp : null + } catch { + return null + } +} + +export function codexAuthIsMonotonicallyFresher( + candidateAuthJson: string, + baselineAuthJson: string +): boolean { + const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) + const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) + if (candidateLastRefresh !== null || baselineLastRefresh !== null) { + return ( + candidateLastRefresh !== null && + baselineLastRefresh !== null && + candidateLastRefresh > baselineLastRefresh + ) + } + return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync.ts b/src/main/codex-accounts/runtime-home-service-auth-sync.ts new file mode 100644 index 00000000000..3cb06cac9e7 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync.ts @@ -0,0 +1,292 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { removeFileAtomicallyIfUnchanged, writeFileAtomically } from './fs-utils' +import { CodexRuntimeHomeLaunch } from './runtime-home-service-launch' +import type { CodexSystemDefaultSnapshot } from './runtime-home-service-types' + +export abstract class CodexRuntimeHomeAuthSync extends CodexRuntimeHomeLaunch { + protected captureSystemDefaultSnapshot(options: { force: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + if (!options.force && existsSync(snapshotPath)) { + return + } + + const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') + const snapshot: CodexSystemDefaultSnapshot = { + authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null + } + writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) + } + + protected syncRuntimeAuthWithSystemDefault(): void { + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (!existsSync(runtimeAuthPath)) { + return + } + + try { + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + if (provenance?.owner === 'managed') { + this.captureSystemDefaultSnapshot({ force: true }) + if (!existsSync(systemDefaultAuthPath)) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { + owner: 'system-default' + }) + return + } + const { + ownershipProven: systemDefaultOwnershipProven, + mirroredAuthJson: mirroredSystemDefaultAuth + } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) + if (!existsSync(systemDefaultAuthPath)) { + if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + } + return + } + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + if (runtimeAuth === systemDefaultAuth) { + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) + return + } + // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) + } + } + + protected syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { + if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = false + return + } + const runtimeAuthPath = this.getRuntimeAuthPath() + try { + let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'managed' + ) { + const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( + provenanceStatus.provenance + ) + if (restoredProvenance) { + provenanceStatus = { kind: 'committed', provenance: restoredProvenance } + } + } + if ( + provenanceStatus.kind === 'fenced' || + (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') + ) { + return + } + const systemAuth = this.readSystemDefaultAuth() + if (!existsSync(runtimeAuthPath)) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const knownSystemAuthBaseline = + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' + ? provenanceStatus.provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson) + : undefined + if (systemAuth === null) { + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' && + provenanceStatus.provenance.authJson === null && + logoutMarkerStatus.kind === 'applies' && + snapshot?.authJson === null + ) { + this.lastWrittenAuthJson = null + return + } + // Why: commit a crashed logout before a managed transition can discard its recovery baseline. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if ( + logoutMarkerStatus.kind === 'system-default-changed' || + (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) + ) { + const replaced = this.writeRuntimeAuth( + systemAuth, + { + owner: 'system-default' + }, + { expectedContents: null } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } + return + } + const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const knownSharedAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) + : null + // Why: only bytes Orca can prove it wrote belong to the compatibility + // mirror; retained Codex or a managed transition owns every other value. + if (knownSharedAuth === null) { + return + } + const sharedAuthOwnedBySystemDefault = + runtimeAuthBeforeSync === knownSharedAuth || + (provenance?.owner === 'system-default' && + systemAuth === null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) + if (!sharedAuthOwnedBySystemDefault) { + return + } + if (systemAuth === null) { + removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) + if (existsSync(runtimeAuthPath)) { + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + return + } + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + return + } + if (runtimeAuthBeforeSync !== knownSharedAuth) { + return + } + const replaced = this.writeRuntimeAuth( + systemAuth, + { owner: 'system-default' }, + { expectedContents: runtimeAuthBeforeSync } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) + } + } + + protected restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (existsSync(systemDefaultAuthPath)) { + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + + if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { + // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (options.detectExternalLogin) { + // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (!existsSync(snapshotPath)) { + this.captureSystemDefaultSnapshot({ force: true }) + } + + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!snapshot) { + console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') + rmSync(snapshotPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!refreshedSnapshot) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if (refreshedSnapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) + return + } + if (snapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) + } + + protected writeSystemDefaultAuth(contents: string): void { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) + writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) + this.ensureOwnerOnlyMode(systemDefaultAuthPath) + } + + protected clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { + // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts new file mode 100644 index 00000000000..35573e7f045 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -0,0 +1,297 @@ +import { posix as pathPosix } from 'node:path' +import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { + getSystemCodexHomePath, + syncCodexGlobalInstructionsIntoManagedHome, + syncSystemCodexResourcesIntoManagedHome +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { + getWslSelectionKey, + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { + hasRecordedLegacySharedCodexPane, + getCodexPaneAccount, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' +import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexRateLimitHomeResolution } from './runtime-home-service-types' +import { CodexRuntimeHomeManagedHome } from './runtime-home-service-managed-home' + +export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHome { + getHostCodexHomePathsForSessionDiscovery(): string[] { + const homes = [this.getRuntimeHomePath()] + if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { + // Why: nested Orca processes can retain an ambient managed CODEX_HOME. + // Per-account lanes no longer bridge real-home history into the shared + // mirror, so include the real root for both directly-routed host lanes. + homes.push(getSystemCodexHomePath()) + } + // Why: account-scoped rollouts live in each account's own home, including WSL. + for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { + homes.push(perAccountHome) + } + return homes.filter((home, index) => homes.indexOf(home) === index) + } + + /** + * The account-owned CODEX_HOME the current HOST selection runs against, or + * null when the selection is not routed to one (system default, or a WSL + * account, whose home lives inside the distro). + * + * Read-only on purpose: session discovery ranks homes with this before any + * launch prep, so it must create no directories and sync no auth. + */ + getSelectedHostAccountCodexHomePath(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + return selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null + } + + /** + * Same selection, but an unreadable home refuses instead of collapsing to + * `null`. Session resume must not read "no managed selection" out of a failed + * marker stat: another account's readable alias would then win the legacy + * rescan and the pane would resume under that account's credentials while the + * UI still shows this one (#STA-4422). + */ + resolveSelectedHostAccountCodexHomePathForResume(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (!selfContainedAccount) { + return null + } + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(selfContainedAccount) + return null + } + return resolved.homePath + } + + /** Trust-gates host previews without changing WSL routing or durable account state. */ + resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { + if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { + return { kind: 'ready', homePath: account.managedHomePath } + } + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' + ? { kind: 'ready', homePath: resolved.homePath } + : { kind: 'skip' } + } + + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { + if (this.getSelfContainedManagedHostAccount()) { + return 'account-home' + } + return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' + } + + getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { + const settings = this.store.getSettings() + const homes = new Map() + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (!record || record.selectionKey !== 'host') { + continue + } + if ( + record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' + ) { + const homePath = this.getRuntimeHomePath() + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + continue + } + if (record.homeRoute !== 'account-home' || !record.accountId) { + continue + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === record.accountId + ) + if (!account || this.getWslManagedHomePath(account)) { + continue + } + const homePath = this.getTrustedSelfContainedManagedHomePath(account) + if (homePath) { + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + } + } + return [...homes.values()] + } + + // Why: the real-home hook installer flips this gate off when the trust-grant + // client reports the host incapable, keeping that host byte-identical to the + // managed lane instead of shipping status-blind panes. + protected realHomeLaneGate: () => boolean = () => true + + setRealHomeLaneGate(gate: () => boolean): void { + this.realHomeLaneGate = gate + } + + // Why: real-home routing applies only to the host system-default selection. + // Managed accounts run in their own homes; Windows (no shell-startup probe) + // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked + // across old homes. + isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + !isShellStartupEnvProbeSupported() + ) { + return false + } + return !hasCustomCodexHomeOverrideForLaunch(launchEnv) + } + + isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { + return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() + } + + reconcileLegacySharedHomeForRetainedPanes(): void { + if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { + return + } + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + syncLegacySharedCodexConfigForRetainedPanes() + } + + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { + return + } + const settings = this.store.getSettings() + const drains: Promise[] = [] + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() || null + : selectedDistroKey.trim() || null + if (distro) { + drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + } + } + await Promise.all(drains) + } + + protected getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return null + } + const home = getWslHome(distro) + if (home && /^[A-Za-z]:[\\/]/.test(home)) { + const linuxHome = toLinuxPath(home).trim() + return linuxHome.startsWith('/') + ? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro) + : null + } + return home ? this.joinWslPath(home, '.codex') : null + } + + protected finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + homePath: string | null + ): void { + this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) + this.startWslSessionBridgeForLaunch(target, homePath) + } + + protected syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (!runtimeHomePath) { + return + } + const distro = + parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemHomePath || systemHomePath === runtimeHomePath) { + return + } + // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath: runtimeHomePath + }) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath, + systemConfigDir: toLinuxPath(systemHomePath) + }) + } + + // Why: `null` is a real value here — it means "use the system-default lane". + // A skipped poll needs its own channel or the fetcher silently retargets the + // user's real ~/.codex (#STA-4422). + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return { + kind: 'ready', + codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) + } + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'owned') { + // Why: the quota fetch reads the account's own auth.json in place; no + // shared-home hot-swap or per-poll resource relink (that is launch prep). + return { kind: 'ready', codexHomePath: resolved.homePath } + } + if (resolved.kind === 'indeterminate') { + // Why: returning null here would NOT skip — the fetcher maps null to + // ~/.codex and would probe the user's real home with a token-refreshing + // app-server. Skip the poll outright and keep the selection. + return { kind: 'skip' } + } + this.clearSelfContainedManagedSelection(selfContainedAccount) + } + if (this.isHostSystemDefaultRealHome()) { + // Why: null lets the fetcher fall back to the main process's inherited + // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the + // managed home, restarting the background OAuth conflict (#5370), so + // pin this non-interactive lane to the native home explicitly. + if (hasRecordedLegacySharedCodexPane()) { + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + } + return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } + } + this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts new file mode 100644 index 00000000000..e25be92abe1 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -0,0 +1,154 @@ +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' +import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { markCodexSessionBackfillMarkerPending } from '../codex/codex-session-backfill-marker' +import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import { CodexRuntimeHomeRouting } from './runtime-home-service-home-routing' + +export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { + protected initializeLastSyncedState(): void { + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. + this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) + ? null + : normalizeCodexRuntimeSelection(settings).host + } + + /** + * Materializes the runtime home needed before launching the CLI. + * + * Historical session bridging is requested in the background so launch setup + * returns as soon as the active runtime home is ready. + */ + prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + this.startLegacyWslAuthDrain(wslTarget) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( + selfContainedAccount, + options?.unavailableManagedHomePath + ) + if (perAccountHome) { + return perAccountHome + } + // Why: only an untrusted home clears the selection; fall through to the + // system default without injecting a path Orca cannot prove it owns. + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: the system default runs Codex on the user's own ~/.codex. + // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; + // the retired mirror is refreshed only for pre-rollout PTYs. + this.reconcileLegacySharedHomeForRetainedPanes() + return null + } + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) + this.syncForCurrentSelection(target, launchEnv) + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. + void startSystemCodexSessionBridgeInBackground( + {}, + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + return this.getRuntimeHomePath() + } + + async prepareForCodexLaunchAsync( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): Promise { + if (target?.runtime !== 'wsl') { + return this.prepareForCodexLaunch(target, launchEnv, options) + } + const wslTarget = this.resolveWslDefaultTarget(target) + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + // Why: the retired home may hold the freshest credential, so the first + // direct-home Codex spawn must wait for its bounded guest transaction. + await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath + } + + beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} + ): boolean | null { + if ( + !this.isHostSystemDefaultSessionMigrationEligible() || + (!codexHomePath && !options.reattached) || + (codexHomePath && + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath())) + ) { + return null + } + // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. + return this.invalidateBackfillAfterManagedSystemDefaultLaunch( + options.reattached && !codexHomePath ? undefined : options.launchEnv + ) + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + + prepareHostSystemDefaultSessionMigrationPass( + scanDates: readonly CodexSessionBackfillDate[] = [] + ): boolean { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) + if ( + this.hostSystemDefaultSessionMigrationPending && + this.pendingHostSystemDefaultSessionMigrationTarget !== target + ) { + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true + this.pendingHostSystemDefaultSessionMigrationTarget = target + } + // Why: the launch creates rollouts for these dates; record them durably so a + // force-quit recovers a bounded window instead of re-walking all history. + const markerOwesFullScan = markCodexSessionBackfillMarkerPending( + paths.markerPath, + paths.systemSessionsRoot, + scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] + ) + // Why: the marker is the only place an overflowed pending window survives a + // restart, so its demand has to reach this pass rather than die in the file. + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan + } + + finishHostSystemDefaultSessionMigrationPass(): void { + this.hostSystemDefaultSessionMigrationPending = false + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + this.pendingHostSystemDefaultSessionMigrationTarget = null + } +} diff --git a/src/main/codex-accounts/runtime-home-service-legacy-migration.ts b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts new file mode 100644 index 00000000000..dcb337cac29 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts @@ -0,0 +1,204 @@ +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync +} from 'node:fs' +import { dirname, extname, join, parse, relative } from 'node:path' +import { writeFileAtomically } from './fs-utils' +import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { CodexRuntimeHomePaths } from './runtime-home-service-paths' + +export abstract class CodexRuntimeHomeLegacyMigration extends CodexRuntimeHomePaths { + protected safeMigrateLegacySharedAuth(): void { + const settings = this.store.getSettings() + try { + migrateLegacySharedAuthToPerAccountHome({ + activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, + hostAccounts: settings.codexManagedAccounts.filter( + (account) => !this.getWslManagedHomePath(account) + ), + managedAccountsRoot: this.getManagedAccountsRoot(), + metadataDir: this.getRuntimeMetadataDir(), + sharedRuntimeHome: this.getRuntimeHomePath(), + systemCodexHome: getSystemCodexHomePath() + }) + } catch (error) { + // Why: an inconclusive identity, ownership, or filesystem result must + // leave the marker absent so the next startup can retry safely. + console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) + } + } + + protected safeMigrateLegacyManagedState(): void { + try { + this.migrateLegacyManagedStateIfNeeded() + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) + } + } + + protected safeMigrateLegacyActiveHomePointer(): void { + try { + const activeHomePath = this.getLegacyHostActiveHomePath() + if (!this.legacyActiveHomePathExists(activeHomePath)) { + return + } + this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) + } + } + + protected migrateLegacyManagedStateIfNeeded(): void { + if (existsSync(this.getMigrationMarkerPath())) { + return + } + + const managedHomes = this.getLegacyManagedHomes() + for (const managedHomePath of managedHomes) { + const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( + /[\\/]/ + )[0] + if (!accountId) { + continue + } + this.migrateLegacyHistory(managedHomePath) + this.migrateLegacySessions(managedHomePath, accountId) + } + + // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. + writeFileAtomically( + this.getMigrationMarkerPath(), + `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` + ) + } + + protected getLegacyManagedHomes(): string[] { + const managedAccountsRoot = this.getManagedAccountsRoot() + if (!existsSync(managedAccountsRoot)) { + return [] + } + + const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) + const managedHomes: string[] = [] + for (const entry of accountEntries) { + if (!entry.isDirectory()) { + continue + } + const managedHomePath = join(managedAccountsRoot, entry.name, 'home') + if (existsSync(join(managedHomePath, '.orca-managed-home'))) { + managedHomes.push(managedHomePath) + } + } + return managedHomes.sort() + } + + protected migrateLegacyHistory(managedHomePath: string): void { + const legacyHistoryPath = join(managedHomePath, 'history.jsonl') + if (!existsSync(legacyHistoryPath)) { + return + } + + const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') + const existingLines = existsSync(runtimeHistoryPath) + ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) + : [] + const mergedLines = [...existingLines] + const seenLines = new Set(existingLines) + for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { + if (!line || seenLines.has(line)) { + continue + } + seenLines.add(line) + mergedLines.push(line) + } + + if (mergedLines.length === 0) { + return + } + writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) + } + + protected migrateLegacySessions(managedHomePath: string, accountId: string): void { + const legacySessionsRoot = join(managedHomePath, 'sessions') + if (!existsSync(legacySessionsRoot)) { + return + } + + const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') + mkdirSync(runtimeSessionsRoot, { recursive: true }) + for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { + const relativePath = relative(legacySessionsRoot, legacyFilePath) + const runtimeFilePath = join(runtimeSessionsRoot, relativePath) + mkdirSync(dirname(runtimeFilePath), { recursive: true }) + if (!existsSync(runtimeFilePath)) { + copyFileSync(legacyFilePath, runtimeFilePath) + continue + } + + const legacyContents = readFileSync(legacyFilePath) + const runtimeContents = readFileSync(runtimeFilePath) + if (runtimeContents.equals(legacyContents)) { + continue + } + + const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) + copyFileSync(legacyFilePath, preservedPath) + this.appendMigrationDiagnostic({ + type: 'session-conflict', + accountId, + runtimeFilePath, + preservedPath + }) + } + } + + protected listFilesRecursively(rootPath: string): string[] { + const stat = statSync(rootPath) + if (!stat.isDirectory()) { + return [rootPath] + } + + const files: string[] = [] + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + this.appendListedFiles(files, this.listFilesRecursively(childPath)) + continue + } + if (entry.isFile()) { + files.push(childPath) + } + } + return files.sort() + } + + protected appendListedFiles(target: string[], source: readonly string[]): void { + // Why: tolerate directories larger than V8's argument limit for spread calls. + for (const filePath of source) { + target.push(filePath) + } + } + + protected getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { + const extension = extname(runtimeFilePath) + const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) + return `${basename}.orca-legacy-${accountId}${extension}` + } + + protected appendMigrationDiagnostic(record: Record): void { + const diagnosticsPath = this.getMigrationDiagnosticsPath() + try { + appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) + } catch (error) { + // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. + console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts new file mode 100644 index 00000000000..6324c0e2307 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -0,0 +1,276 @@ +import { join } from 'node:path' +import { + syncSystemCodexResourcesIntoManagedHome, + getSystemCodexHomePath, + resolveOrcaManagedCodexHomePath +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { + resolveHostCodexManagedHomeVerdict, + ManagedCodexHomeTemporarilyUnavailableError +} from './host-codex-managed-home-ownership' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { getDefaultWslDistro } from '../wsl' +import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeSync } from './runtime-home-service-sync' + +export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { + // Why: a managed HOST account runs against its own self-contained CODEX_HOME + // (codex-accounts//home) rather than the shared runtime mirror. Its + // auth.json lives there and codex refreshes it in place, so two accounts never + // race one auth.json. WSL accounts keep their per-distro lane. + protected getSelfContainedManagedHostAccount(): CodexManagedAccount | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (!account || this.getWslManagedHomePath(account)) { + return null + } + return account + } + + // Why: session discovery must surface every account's own rollouts wherever they live. + protected getManagedAccountHomesForSessionDiscovery(): string[] { + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + const wslHome = this.getWslManagedHomePath(account) + if (wslHome) { + homes.push(wslHome) + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes + } + + protected getManagedHostAccountHomesForSessionDiscovery(): string[] { + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + if (this.getWslManagedHomePath(account)) { + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes + } + + protected prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: refuse the launch rather than silently falling through to the + // system default, which would run a different account behind a UI still + // showing this one. The selection stays put; a later read may succeed. + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(account) + return null + } + const perAccountHome = resolved.homePath + if ( + unavailableManagedHomePath && + normalizeRuntimePathForComparison(unavailableManagedHomePath) === + normalizeRuntimePathForComparison(perAccountHome) + ) { + const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) + if (absence.state !== 'present' && absence.durable) { + this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') + return null + } + // Why: a transient missing/unreadable auth.json is usually codex rotating + // it; keep the selection and launch — the CLI re-reads the settled file. + } + // Why: link the user's real ~/.codex resources and mirror config into THIS + // home (never symlinking into or mutating ~/.codex), so the per-account home + // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + syncSystemCodexResourcesIntoManagedHome(perAccountHome) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: perAccountHome, + systemHomePath: getSystemCodexHomePath() + }) + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return perAccountHome + } + + // Why: Codex's own `/resume` picker only lists rollouts under the launch + // CODEX_HOME, so a self-contained account home starts out with no history at + // all. Hardlink every other Orca-visible home's rollouts in — after launch, + // since history trees can be large — so switching accounts no longer hides + // the user's conversations. + protected startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { + void startCodexAccountSessionBridgeInBackground({ + targetCodexHomePath: perAccountHome, + sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() + }) + } + + protected getSelfContainedSessionBridgeSourceHomes(): string[] { + return [ + // Why: history-only override lets custom-CODEX_HOME users bridge from the + // home they actually record sessions in; falls back to the real ~/.codex. + resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), + // Why: path only — a per-account install must not materialize the mirror. + resolveOrcaManagedCodexHomePath(), + ...this.getManagedHostAccountHomesForSessionDiscovery() + ] + } + + // Why: the per-account home is both the launch CODEX_HOME and the credential + // store, so codex reads/refreshes auth.json in place — there is no shared-home + // hot-swap or token read-back to reconcile. A trusted home remains selected + // while Codex atomically replaces auth.json. + protected syncSelfContainedManagedSelection(account: CodexManagedAccount): void { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: a sync runs on every app start, exactly when antivirus is busiest. + // An unreadable home must not deselect the account (#STA-4422). + return + } + const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null + if (perAccountHome) { + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + // Why: selection runs well before the user restarts a pane, so history is + // already linked in by the time the newly launched Codex opens /resume. + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return + } + this.clearSelfContainedManagedSelection(account) + } + + /** + * Why: an unreadable home and an untrustworthy one demand opposite responses. + * Only `untrusted` may clear the user's selection; `indeterminate` means we + * could not tell, so callers refuse the operation and leave durable state + * alone (#STA-4422). + */ + protected resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { + const verdict = resolveHostCodexManagedHomeVerdict({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + if (verdict.kind === 'owned') { + // Preserve the persisted path spelling (notably /var vs /private/var on + // macOS) so injected CODEX_HOME stays stable across the rollout. + return { kind: 'owned', homePath: account.managedHomePath } + } + if (verdict.kind === 'untrusted') { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) + return { kind: 'untrusted' } + } + console.warn( + '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', + verdict.error + ) + return { kind: 'indeterminate' } + } + + /** Read-only callers that mutate nothing and simply skip an unusable home. */ + protected getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' ? resolved.homePath : null + } + + protected clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason = 'home is invalid' + ): void { + console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) + const settings = this.store.getSettings() + if (normalizeCodexRuntimeSelection(settings).host !== account.id) { + return + } + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + this.lastSyncedAccountId = null + this.lastHostAccountUsedSelfContainedHome = false + } + + protected invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return null + } + if (!this.hostSystemDefaultSessionMigrationPending) { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = + !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( + paths.systemSessionsRoot + ) + this.hostSystemDefaultSessionMigrationPending = true + } + return this.prepareHostSystemDefaultSessionMigrationPass() + } + + protected startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (process.platform !== 'win32' || !runtimeHomePath) { + return + } + const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) + const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() + if (!distro) { + return + } + // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. + const systemCodexHomePath = + resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { + // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. + void startWslCodexSessionBridgeInBackground({ + distro, + systemCodexHomePath, + managedCodexHomePath: runtimeHomePath + }) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-paths.ts b/src/main/codex-accounts/runtime-home-service-paths.ts new file mode 100644 index 00000000000..87ef3cedeaa --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-paths.ts @@ -0,0 +1,187 @@ +import { + lstatSync, + mkdirSync, + readlinkSync, + renameSync, + rmdirSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { app } from 'electron' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { getOrcaManagedCodexHomePath, getOrcaUserDataPath } from '../codex/codex-home-paths' +import type { CodexMirroredHomeStatus } from './runtime-home-service-types' +import { CodexRuntimeHomeState } from './runtime-home-service-state' + +export abstract class CodexRuntimeHomePaths extends CodexRuntimeHomeState { + protected getRuntimeHomePath(): string { + return getOrcaManagedCodexHomePath() + } + + /** + * Resolves the managed home the config mirror actually targets for the + * current HOST selection, or null when no mirror runs for it. + * + * Read-only on purpose: unlike the launch and quota-fetch paths this prepares + * nothing and creates no directories, so surfacing sync health cannot alter + * the state it is reporting on. Returns null for the system default on the + * real-home lane, which runs Codex directly against ~/.codex — there is no + * mirror there, so there is nothing that can fall behind. + */ + getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + // Why: `null` here is a positive claim that no mirror exists, which the + // status channel reports as healthy. An unreadable home is not that. + return { kind: 'unavailable' } + } + return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } + } + if (this.isHostSystemDefaultRealHome()) { + return { kind: 'ready', homePath: null } + } + return { + kind: 'ready', + homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') + } + } + + protected getRuntimeAuthPath(): string { + return join(this.getRuntimeHomePath(), 'auth.json') + } + + protected getSystemDefaultSnapshotPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') + } + + protected getRuntimeLogoutMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') + } + + protected getSharedRuntimeAuthProvenancePath(): string { + return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') + } + + protected getRuntimeMetadataDir(): string { + const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') + mkdirSync(metadataDir, { recursive: true }) + return metadataDir + } + + protected getLegacyHostActiveHomePath(): string { + return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') + } + + protected getMigrationMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-v1.json') + } + + protected getMigrationDiagnosticsPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') + } + + protected getManagedAccountsRoot(): string { + return join(app.getPath('userData'), 'codex-accounts') + } + + protected repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { + if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { + return + } + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + return + } + + mkdirSync(runtimeHomePath, { recursive: true }) + mkdirSync(dirname(activeHomePath), { recursive: true }) + const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + try { + symlinkSync( + runtimeHomePath, + nextLinkPath, + process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() + ? 'junction' + : undefined + ) + try { + renameSync(nextLinkPath, activeHomePath) + } catch (error) { + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + throw error + } + this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) + renameSync(nextLinkPath, activeHomePath) + } + } finally { + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + } + } + + protected activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean { + try { + return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) + } catch { + return false + } + } + + protected linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean { + const resolvedLinkTarget = isAbsolute(linkTarget) + ? resolve(linkTarget) + : resolve(dirname(linkPath), linkTarget) + return resolvedLinkTarget === resolve(expectedTargetPath) + } + + protected legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { + try { + const stat = lstatSync(activeHomePath) + return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) + } catch { + return true + } + } + + protected legacyActiveHomePathExists(activeHomePath: string): boolean { + try { + lstatSync(activeHomePath) + return true + } catch { + return false + } + } + + protected removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { + try { + const stat = lstatSync(activeHomePath) + if (stat.isSymbolicLink()) { + unlinkSync(activeHomePath) + } else if (this.isWindowsReadableLink(activeHomePath)) { + rmdirSync(activeHomePath) + } + } catch { + // Missing or inaccessible temporary links are handled by the caller. + } + } + + protected isWindowsReadableLink(targetPath: string): boolean { + if (process.platform !== 'win32') { + return false + } + try { + readlinkSync(targetPath) + return true + } catch { + return false + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts new file mode 100644 index 00000000000..d6a6691fbb4 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -0,0 +1,264 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' +import type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution, + CodexReadBackMatch, + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthPendingProvenance, + CodexSelfContainedManagedHomeResolution, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import type { LegacyWslRuntimeAuthDestination } from './legacy-wsl-runtime-auth-drain' +import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' + +/** Shared state and method contracts for the focused runtime-home layers. */ +export abstract class CodexRuntimeHomeState { + // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. + protected lastSyncedAccountId: string | null = null + // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). + protected lastWrittenAuthJson: string | null = null + // Why: a managed host account refreshes auth in its own home. Remember that provenance so a later deselect never adopts stale shared bytes. + protected lastHostAccountUsedSelfContainedHome = false + protected sharedAuthRefreshBlockedByManagedTransition = false + // Why: transient auth.json read/parse failures must not deselect an account. + protected readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() + protected hostSystemDefaultSessionMigrationPending = false + protected pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + protected pendingHostSystemDefaultSessionMigrationTarget: string | null = null + + protected constructor(protected readonly store: Store) {} + + protected abstract initializeLastSyncedState(): void + abstract prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null + abstract prepareForCodexLaunchAsync( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): Promise + abstract beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options?: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } + ): boolean | null + abstract isHostSystemDefaultSessionMigrationEligible(): boolean + abstract prepareHostSystemDefaultSessionMigrationPass( + scanDates?: readonly CodexSessionBackfillDate[] + ): boolean + abstract finishHostSystemDefaultSessionMigrationPass(): void + + protected abstract getSelfContainedManagedHostAccount(): CodexManagedAccount | null + protected abstract getManagedAccountHomesForSessionDiscovery(): string[] + protected abstract getManagedHostAccountHomesForSessionDiscovery(): string[] + protected abstract prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null + protected abstract startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void + protected abstract getSelfContainedSessionBridgeSourceHomes(): string[] + protected abstract syncSelfContainedManagedSelection(account: CodexManagedAccount): void + protected abstract resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): CodexSelfContainedManagedHomeResolution + protected abstract getTrustedSelfContainedManagedHomePath( + account: CodexManagedAccount + ): string | null + protected abstract clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason?: string + ): void + protected abstract invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null + protected abstract startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + + abstract getHostCodexHomePathsForSessionDiscovery(): string[] + abstract getSelectedHostAccountCodexHomePath(): string | null + abstract resolveSelectedHostAccountCodexHomePathForResume(): string | null + abstract resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } + abstract getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute + abstract getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] + abstract setRealHomeLaneGate(gate: () => boolean): void + abstract isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean + abstract isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean + abstract reconcileLegacySharedHomeForRetainedPanes(): void + abstract syncActiveWslSelectionsBeforeRestart(): Promise + + protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null + protected abstract finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + homePath: string | null + ): void + protected abstract syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + abstract prepareForRateLimitFetch( + target?: CodexAccountSelectionTarget + ): CodexRateLimitHomeResolution + abstract syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void + abstract clearLastWrittenAuthJson(accountId?: string | null): void + + protected abstract resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } + protected abstract safeSyncForCurrentSelection(): void + protected abstract safeRecoverInterruptedRuntimeAuthOperation(): void + protected abstract getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null + protected abstract getWslManagedHomePath(account: CodexManagedAccount | null): string | null + protected abstract getWslManagedHomeIdentity( + account: CodexManagedAccount | null + ): { distro: string; linuxHomePath: string } | null + protected abstract getPreparedWslRateLimitHomePath( + target: CodexAccountSelectionTarget + ): string | null + protected abstract getWslCodexHomePathForSelection( + target: CodexAccountSelectionTarget + ): string | null + protected abstract getWslLaunchCodexHomePath( + account: CodexManagedAccount, + targetDistro: string | undefined + ): string | null + protected abstract startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget, + options?: { throwOnFailure?: boolean } + ): Promise + protected abstract resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise + protected abstract joinWslPath(basePath: string, ...segments: string[]): string + protected abstract resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget + protected abstract findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } + ): CodexReadBackMatch + protected abstract runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean + + protected abstract safeMigrateLegacySharedAuth(): void + protected abstract safeMigrateLegacyManagedState(): void + protected abstract safeMigrateLegacyActiveHomePointer(): void + protected abstract getRuntimeHomePath(): string + abstract getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus + protected abstract getRuntimeAuthPath(): string + protected abstract getSystemDefaultSnapshotPath(): string + protected abstract getRuntimeLogoutMarkerPath(): string + protected abstract getSharedRuntimeAuthProvenancePath(): string + protected abstract getRuntimeMetadataDir(): string + protected abstract getLegacyHostActiveHomePath(): string + protected abstract getMigrationMarkerPath(): string + protected abstract getMigrationDiagnosticsPath(): string + protected abstract getManagedAccountsRoot(): string + protected abstract repointLegacyActiveHomePointer( + activeHomePath: string, + runtimeHomePath: string + ): void + protected abstract activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean + protected abstract linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean + protected abstract legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean + protected abstract legacyActiveHomePathExists(activeHomePath: string): boolean + protected abstract removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void + protected abstract isWindowsReadableLink(targetPath: string): boolean + protected abstract migrateLegacyManagedStateIfNeeded(): void + protected abstract getLegacyManagedHomes(): string[] + protected abstract migrateLegacyHistory(managedHomePath: string): void + protected abstract migrateLegacySessions(managedHomePath: string, accountId: string): void + protected abstract listFilesRecursively(rootPath: string): string[] + protected abstract appendListedFiles(target: string[], source: readonly string[]): void + protected abstract getPreservedLegacySessionPath( + runtimeFilePath: string, + accountId: string + ): string + protected abstract appendMigrationDiagnostic(record: Record): void + + protected abstract captureSystemDefaultSnapshot(options: { force: boolean }): void + protected abstract syncRuntimeAuthWithSystemDefault(): void + protected abstract syncLegacySharedSystemDefaultAuthForRetainedPanes(): void + protected abstract restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void + protected abstract writeSystemDefaultAuth(contents: string): void + protected abstract clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void + protected abstract readSystemDefaultAuth(): string | null + protected abstract writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean + protected abstract compareFileContents(targetPath: string, contents: string): boolean | null + protected abstract fileContentsEqual(targetPath: string, contents: string): boolean + protected abstract fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean + protected abstract ensureOwnerOnlyMode(targetPath: string): void + protected abstract getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus + protected abstract persistRuntimeLogoutMarker(systemDefaultAuthJson?: string | null): void + protected abstract readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null + protected abstract clearRuntimeLogoutMarker(): void + protected abstract persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void + protected abstract markSharedRuntimeAuthManaged(accountId: string): void + protected abstract getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null + protected abstract restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null + protected abstract sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean + protected abstract resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus + protected abstract parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null + protected abstract parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null + protected abstract parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null + protected abstract readRuntimeAuthForProvenance(): string | null + protected abstract readSystemDefaultSnapshot( + snapshotPath: string + ): CodexSystemDefaultSnapshot | null + abstract clearSystemDefaultSnapshot(): void +} diff --git a/src/main/codex-accounts/runtime-home-service-sync.ts b/src/main/codex-accounts/runtime-home-service-sync.ts new file mode 100644 index 00000000000..184faeab241 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-sync.ts @@ -0,0 +1,154 @@ +import { existsSync } from 'node:fs' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { recoverInterruptedGuardedFileOperation } from './fs-utils' +import type { CodexSharedRuntimeAuthProvenanceStatus } from './runtime-home-service-types' +import { codexAuthIsMonotonicallyFresher } from './runtime-home-service-auth-sync-identity' +import { CodexRuntimeHomeWsl } from './runtime-home-service-wsl' + +export abstract class CodexRuntimeHomeSync extends CodexRuntimeHomeWsl { + syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void { + if (target?.runtime === 'wsl') { + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) + return + } + + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + // Why: self-contained managed homes hold their own auth, so the shared + // runtime home's snapshot/hot-swap/read-back machinery below must not run. + this.syncSelfContainedManagedSelection(selfContainedAccount) + return + } + const settings = this.store.getSettings() + if (this.lastHostAccountUsedSelfContainedHome) { + // Why: the account's auth is already canonical in its own home. Reset the + // legacy mirror baseline without reading it; a real-home deselect needs no + // further sync, and the mirror lane below re-seeds from canonical storage. + this.lastHostAccountUsedSelfContainedHome = false + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + if (this.isHostSystemDefaultRealHome(launchEnv)) { + return + } + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: retained daemon panes may own shared auth from a managed launch; + // compatibility reconciliation runs later with durable provenance. + if (this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = true + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + } + return + } + const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: false }) + } + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (activeAccount) { + // Why: only a WSL-managed account can reach here — every host account was + // routed to its own self-contained home above. Its auth lives in the + // distro-local runtime home, so the host mirror only drops its baseline. + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + return + } + if (normalizeCodexRuntimeSelection(settings).host) { + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + } + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else { + this.persistRuntimeLogoutMarker() + } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() + } + } + + // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. + clearLastWrittenAuthJson( + accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + ): void { + if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { + this.lastWrittenAuthJson = null + } + } + + // Why: which ~/.codex bytes the mirror was seeded from, and whether the system + // default can be proven to own the mirror at all. + protected resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } { + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const snapshotAuth = + this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null + const preProvenanceRuntimeRefreshProven = + provenanceStatus.kind === 'missing' && + snapshotAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && + codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) + return { + ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, + mirroredAuthJson: + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshotAuth) + : null + } + } + + protected safeSyncForCurrentSelection(): void { + try { + this.syncForCurrentSelection() + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) + } + } + + protected safeRecoverInterruptedRuntimeAuthOperation(): void { + try { + recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-types.ts b/src/main/codex-accounts/runtime-home-service-types.ts new file mode 100644 index 00000000000..1cf9c773fd2 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-types.ts @@ -0,0 +1,62 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' + +export type CodexSystemDefaultSnapshot = { + authJson: string | null +} + +export type CodexRuntimeLogoutMarker = { + systemDefaultAuthJson: string | null + loggedOutAt: number +} + +export type CodexSharedRuntimeAuthProvenance = + | { owner: 'system-default'; authJson: string | null } + | { + owner: 'managed' + accountId: string + systemDefaultBaseline?: { authJson: string | null } + } + +export type CodexSharedRuntimeAuthPendingProvenance = { + owner: 'pending' + next: CodexSharedRuntimeAuthProvenance + runtimeAuthJson: string | null +} + +export type CodexSharedRuntimeAuthProvenanceFile = + | CodexSharedRuntimeAuthProvenance + | CodexSharedRuntimeAuthPendingProvenance + | { owner: 'fenced' } + +export type CodexSharedRuntimeAuthProvenanceStatus = + | { kind: 'missing' | 'fenced' } + | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } + +export type CodexRuntimeLogoutMarkerStatus = + | { kind: 'missing' } + | { kind: 'applies' } + | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } + +export type CodexReadBackMatch = + | { + kind: 'matched' + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + } + | { kind: 'none' | 'ambiguous' } + +export type CodexSelfContainedManagedHomeResolution = + | { kind: 'owned'; homePath: string } + | { kind: 'untrusted' } + | { kind: 'indeterminate' } + +/** Status used by the config-sync surface; `unavailable` is not a healthy null lane. */ +export type CodexMirroredHomeStatus = + | { kind: 'ready'; homePath: string | null } + | { kind: 'unavailable' } + +/** Result used by quota polling, where `skip` means no process should be spawned. */ +export type CodexRateLimitHomeResolution = + | { kind: 'ready'; codexHomePath: string | null } + | { kind: 'skip' } diff --git a/src/main/codex-accounts/runtime-home-service-wsl-core.ts b/src/main/codex-accounts/runtime-home-service-wsl-core.ts new file mode 100644 index 00000000000..4ace9d8dd33 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl-core.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + codexAuthCouldBelongToManagedAccount, + codexAuthMatchesManagedAccount, + codexAuthMatchesSystemDefaultIdentity +} from './codex-auth-identity' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexReadBackMatch } from './runtime-home-service-types' +import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import { CodexRuntimeHomeAuthProvenance } from './runtime-home-service-auth-provenance' + +export abstract class CodexRuntimeHomeWslCore extends CodexRuntimeHomeAuthProvenance { + protected getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null { + if (!activeAccountId) { + return null + } + return accounts.find((account) => account.id === activeAccountId) ?? null + } + + protected getWslManagedHomePath(account: CodexManagedAccount | null): string | null { + return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null + } + + protected getWslManagedHomeIdentity( + account: CodexManagedAccount | null + ): { distro: string; linuxHomePath: string } | null { + if (!account) { + return null + } + const distro = account.wslDistro?.trim() + const linuxHomePath = account.wslLinuxHomePath?.trim() + if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) { + return { distro, linuxHomePath } + } + const legacyHome = parseWslUncPath(account.managedHomePath) + return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null + } + + protected findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } + ): CodexReadBackMatch { + const matches: { + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + }[] = [] + let unreadableHomeCouldOwnRuntimeAuth = false + for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { + if (expectedAccountId && account.id !== expectedAccountId) { + continue + } + const managedAuthPath = join(account.managedHomePath, 'auth.json') + let managedAuthContents: string + const suppliedRead = options?.authReads.get(account.id) + if (suppliedRead?.kind === 'missing') { + continue + } + if (suppliedRead?.kind === 'unreadable') { + // Why: an unreadable home can never be compared, but letting the read + // throw abandons the scan for every other account — dropping a refresh + // the runtime home holds for one of them. Only its record can rule it + // out as the owner; when it cannot, the scan is no longer unambiguous. + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + if (suppliedRead?.kind === 'present') { + managedAuthContents = suppliedRead.contents + } else { + if (!existsSync(managedAuthPath)) { + continue + } + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + } + if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { + matches.push({ account, managedAuthPath, managedAuthContents }) + } + } + + if (unreadableHomeCouldOwnRuntimeAuth) { + return { kind: 'ambiguous' } + } + if (matches.length === 1) { + return { kind: 'matched', ...matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + protected runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean { + return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-wsl.ts b/src/main/codex-accounts/runtime-home-service-wsl.ts new file mode 100644 index 00000000000..ea39e40d730 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl.ts @@ -0,0 +1,167 @@ +import { join } from 'node:path' +import { win32 as pathWin32 } from 'node:path' +import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { + getCodexSelectionLaneKey, + getSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { hasRecordedLegacyWslCodexPane } from '../codex/codex-pane-account-registry' +import { + startLegacyWslRuntimeAuthDrain, + type LegacyWslRuntimeAuthDestination +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeWslCore } from './runtime-home-service-wsl-core' + +export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { + protected getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + return this.getWslCodexHomePathForSelection(target) + } + + protected getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, target) + ) + if (account) { + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) + if (accountHome) { + return accountHome + } + } + return this.getWslSystemCodexHomePath(target) + } + + protected getWslLaunchCodexHomePath( + account: CodexManagedAccount, + targetDistro: string | undefined + ): string | null { + const wslHome = this.getWslManagedHomeIdentity(account) + if (!wslHome) { + return null + } + const accountDistro = wslHome.distro + if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { + return null + } + if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { + return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) + } + return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) + } + + protected startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget, + options: { throwOnFailure?: boolean } = {} + ): Promise { + if (process.platform !== 'win32') { + return Promise.resolve() + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return Promise.resolve() + } + const guestHome = getWslHome(distro) + const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' + if (!guestHomeLinuxPath.startsWith('/')) { + return Promise.resolve() + } + let legacyPanePresent = true + try { + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) + } catch (error) { + // Why: unknown pane liveness must preserve the source, but promotion can + // still keep the direct home from launching stale auth. + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error) + } + return startLegacyWslRuntimeAuthDrain( + { + distro, + guestHomeLinuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }, + options + ) + } + + protected async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { + const wslHome = this.getWslManagedHomeIdentity(account) + return wslHome?.distro.toLowerCase() === distro.toLowerCase() + ? [{ account, linuxPath: wslHome.linuxHomePath }] + : [] + }) + const accounts = accountHomes.map(({ account }) => account) + const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null + let reads: WslCodexAuthRead[] + try { + reads = await readWslCodexAuths(distro, [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ]) + } catch { + reads = accountHomes.map(() => ({ kind: 'unreadable' })) + if (parsedSystemHome) { + reads.push({ kind: 'unreadable' }) + } + } + const authReads = new Map( + accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) + ) + const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { + accounts, + authReads + }) + if (match.kind === 'ambiguous') { + return null + } + if (match.kind === 'matched') { + const accountHome = accountHomes.find(({ account }) => account.id === match.account.id) + if (!accountHome) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: accountHome.linuxPath + } + } + + if (!systemHome || !parsedSystemHome) { + return null + } + const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } + if (systemAuth.kind !== 'present') { + return null + } + return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) + ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } + : null + } + + protected joinWslPath(basePath: string, ...segments: string[]): string { + return parseWslUncPath(basePath) + ? pathWin32.join(basePath, ...segments) + : join(basePath, ...segments) + } + + protected resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget { + if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2d246bf480d..4f08fb0a64c 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,210 +1,14 @@ -/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { - appendFileSync, - copyFileSync, - existsSync, - chmodSync, - lstatSync, - mkdirSync, - readlinkSync, - readdirSync, - readFileSync, - renameSync, - rmdirSync, - rmSync, - statSync, - symlinkSync, - unlinkSync -} from 'node:fs' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { - dirname, - extname, - isAbsolute, - join, - parse, - posix as pathPosix, - relative, - resolve, - win32 as pathWin32 -} from 'node:path' -import { app } from 'electron' -import type { CodexManagedAccount } from '../../shared/managed-account-types' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { - recoverInterruptedGuardedFileOperation, - removeFileAtomicallyIfUnchanged, - writeFileAtomically, - writeFileAtomicallyIfUnchanged -} from './fs-utils' -import { - getOrcaManagedCodexHomePath, - getOrcaUserDataPath, - getSystemCodexHomePath, - resolveOrcaManagedCodexHomePath, - syncCodexGlobalInstructionsIntoManagedHome, - syncSystemCodexResourcesIntoManagedHome -} from '../codex/codex-home-paths' -import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' -import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' -import { - resolveHostCodexSessionSourceHome, - resolveWslCodexSessionSourceHome -} from '../codex/codex-session-source-home' -import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' -import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' -import { - getCodexSelectionLaneKey, - getWslSelectionKey, - getSelectedCodexAccountIdForTarget, - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './runtime-selection' -import { getDefaultWslDistro, getWslHome } from '../wsl' -import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' -import { - hasCompletedCodexSessionBackfillMarker, - markCodexSessionBackfillMarkerPending -} from '../codex/codex-session-backfill-marker' -import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' -import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' -import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' -import { - ManagedCodexHomeTemporarilyUnavailableError, - resolveHostCodexManagedHomeVerdict -} from './host-codex-managed-home-ownership' -import { - codexAuthCouldBelongToManagedAccount, - codexAuthIsFresher, - codexAuthMatchesManagedAccount, - codexAuthMatchesSystemDefaultIdentity -} from './codex-auth-identity' -import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' -import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' -import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' -import { - getCodexPaneAccount, - hasRecordedLegacyWslCodexPane, - hasRecordedLegacySharedCodexPane, - type CodexPaneHomeRoute -} from '../codex/codex-pane-account-registry' -import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' -import { - startLegacyWslRuntimeAuthDrain, - type LegacyWslRuntimeAuthDestination -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import { CodexRuntimeHomeAuthSync } from './runtime-home-service-auth-sync' -type CodexSystemDefaultSnapshot = { - authJson: string | null -} +export type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution +} from './runtime-home-service-types' -type CodexRuntimeLogoutMarker = { - systemDefaultAuthJson: string | null - loggedOutAt: number -} - -type CodexSharedRuntimeAuthProvenance = - | { owner: 'system-default'; authJson: string | null } - | { - owner: 'managed' - accountId: string - systemDefaultBaseline?: { authJson: string | null } - } -type CodexSharedRuntimeAuthPendingProvenance = { - owner: 'pending' - next: CodexSharedRuntimeAuthProvenance - runtimeAuthJson: string | null -} -type CodexSharedRuntimeAuthProvenanceFile = - | CodexSharedRuntimeAuthProvenance - | CodexSharedRuntimeAuthPendingProvenance - | { owner: 'fenced' } -type CodexSharedRuntimeAuthProvenanceStatus = - | { kind: 'missing' | 'fenced' } - | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } - -type CodexRuntimeLogoutMarkerStatus = - | { kind: 'missing' } - | { kind: 'applies' } - | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } - -type CodexReadBackMatch = - | { - kind: 'matched' - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - } - | { kind: 'none' | 'ambiguous' } - -function readCodexLastRefresh(authJson: string): number | null { - try { - const parsed = JSON.parse(authJson) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null - } - const value = (parsed as Record).last_refresh - if (typeof value === 'number') { - return Number.isFinite(value) ? value : null - } - if (typeof value !== 'string' || !value.trim()) { - return null - } - const timestamp = Date.parse(value) - return Number.isFinite(timestamp) ? timestamp : null - } catch { - return null - } -} - -function codexAuthIsMonotonicallyFresher( - candidateAuthJson: string, - baselineAuthJson: string -): boolean { - const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) - const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) - if (candidateLastRefresh !== null || baselineLastRefresh !== null) { - return ( - candidateLastRefresh !== null && - baselineLastRefresh !== null && - candidateLastRefresh > baselineLastRefresh - ) - } - return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) -} - -/** - * Why: a skipped Codex quota poll must be distinguishable from "use the - * system-default home". `null` inside `ready` still means the system lane; - * `skip` means do not fetch at all this cycle (#STA-4422). - */ -/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */ -export type CodexMirroredHomeStatus = - | { kind: 'ready'; homePath: string | null } - | { kind: 'unavailable' } - -export type CodexRateLimitHomeResolution = - | { kind: 'ready'; codexHomePath: string | null } - | { kind: 'skip' } - -export class CodexRuntimeHomeService { - // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. - private lastSyncedAccountId: string | null = null - // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). - private lastWrittenAuthJson: string | null = null - // Why: a managed host account refreshes auth in its own home. Remember that - // provenance so a later deselect never adopts stale shared bytes. - private lastHostAccountUsedSelfContainedHome = false - private sharedAuthRefreshBlockedByManagedTransition = false - // Why: transient auth.json read/parse failures must not deselect an account. - private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() - private hostSystemDefaultSessionMigrationPending = false - private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - private pendingHostSystemDefaultSessionMigrationTarget: string | null = null - constructor(private readonly store: Store) { +export class CodexRuntimeHomeService extends CodexRuntimeHomeAuthSync { + constructor(store: Store) { + super(store) this.safeRecoverInterruptedRuntimeAuthOperation() this.safeMigrateLegacySharedAuth() this.safeMigrateLegacyManagedState() @@ -212,2066 +16,4 @@ export class CodexRuntimeHomeService { this.initializeLastSyncedState() this.safeSyncForCurrentSelection() } - - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. - this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) - ? null - : normalizeCodexRuntimeSelection(settings).host - } - - /** - * Materializes the runtime home needed before launching the CLI. - * - * Historical session bridging is requested in the background so launch setup - * returns as soon as the active runtime home is ready. - */ - prepareForCodexLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } - ): string | null { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - this.startLegacyWslAuthDrain(wslTarget) - this.finishWslLaunchPreparation(wslTarget, homePath) - return homePath - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( - selfContainedAccount, - options?.unavailableManagedHomePath - ) - if (perAccountHome) { - return perAccountHome - } - // Why: only an untrusted home clears the selection; fall through to the - // system default without injecting a path Orca cannot prove it owns. - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: the system default runs Codex on the user's own ~/.codex. - // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; - // the retired mirror is refreshed only for pre-rollout PTYs. - this.reconcileLegacySharedHomeForRetainedPanes() - return null - } - this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) - this.syncForCurrentSelection(target, launchEnv) - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. - void startSystemCodexSessionBridgeInBackground( - {}, - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - return this.getRuntimeHomePath() - } - - async prepareForCodexLaunchAsync( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } - ): Promise { - if (target?.runtime !== 'wsl') { - return this.prepareForCodexLaunch(target, launchEnv, options) - } - const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - // Why: the retired home may hold the freshest credential, so the first - // direct-home Codex spawn must wait for its bounded guest transaction. - await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) - this.finishWslLaunchPreparation(wslTarget, homePath) - return homePath - } - - beginHostSystemDefaultSessionMigrationLaunch( - codexHomePath: string | null, - options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} - ): boolean | null { - if ( - !this.isHostSystemDefaultSessionMigrationEligible() || - (!codexHomePath && !options.reattached) || - (codexHomePath && - normalizeRuntimePathForComparison(codexHomePath) !== - normalizeRuntimePathForComparison(this.getRuntimeHomePath())) - ) { - return null - } - // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. - return this.invalidateBackfillAfterManagedSystemDefaultLaunch( - options.reattached && !codexHomePath ? undefined : options.launchEnv - ) - } - - isHostSystemDefaultSessionMigrationEligible(): boolean { - return ( - normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && - !hasCustomCodexHomeOverrideForLaunch() - ) - } - - prepareHostSystemDefaultSessionMigrationPass( - scanDates: readonly CodexSessionBackfillDate[] = [] - ): boolean { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) - if ( - this.hostSystemDefaultSessionMigrationPending && - this.pendingHostSystemDefaultSessionMigrationTarget !== target - ) { - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true - this.pendingHostSystemDefaultSessionMigrationTarget = target - } - // Why: the launch creates rollouts for these dates; record them durably so a - // force-quit recovers a bounded window instead of re-walking all history. - const markerOwesFullScan = markCodexSessionBackfillMarkerPending( - paths.markerPath, - paths.systemSessionsRoot, - scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] - ) - // Why: the marker is the only place an overflowed pending window survives a - // restart, so its demand has to reach this pass rather than die in the file. - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan - return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan - } - - finishHostSystemDefaultSessionMigrationPass(): void { - this.hostSystemDefaultSessionMigrationPending = false - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - this.pendingHostSystemDefaultSessionMigrationTarget = null - } - - // Why: a managed HOST account runs against its own self-contained CODEX_HOME - // (codex-accounts//home) rather than the shared runtime mirror. Its - // auth.json lives there and codex refreshes it in place, so two accounts never - // race one auth.json. WSL accounts keep their per-distro lane. - private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (!account || this.getWslManagedHomePath(account)) { - return null - } - return account - } - - // Why: session discovery must surface every account's own rollouts wherever they live. - private getManagedAccountHomesForSessionDiscovery(): string[] { - const settings = this.store.getSettings() - const homes: string[] = [] - for (const account of settings.codexManagedAccounts) { - const wslHome = this.getWslManagedHomePath(account) - if (wslHome) { - homes.push(wslHome) - continue - } - const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome) { - homes.push(trustedHome) - } - } - return homes - } - - private getManagedHostAccountHomesForSessionDiscovery(): string[] { - const settings = this.store.getSettings() - const homes: string[] = [] - for (const account of settings.codexManagedAccounts) { - if (this.getWslManagedHomePath(account)) { - continue - } - const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome) { - homes.push(trustedHome) - } - } - return homes - } - - private prepareSelfContainedManagedHomeForLaunch( - account: CodexManagedAccount, - unavailableManagedHomePath?: string - ): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: refuse the launch rather than silently falling through to the - // system default, which would run a different account behind a UI still - // showing this one. The selection stays put; a later read may succeed. - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(account) - return null - } - const perAccountHome = resolved.homePath - if ( - unavailableManagedHomePath && - normalizeRuntimePathForComparison(unavailableManagedHomePath) === - normalizeRuntimePathForComparison(perAccountHome) - ) { - const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) - if (absence.state !== 'present' && absence.durable) { - this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') - return null - } - // Why: a transient missing/unreadable auth.json is usually codex rotating - // it; keep the selection and launch — the CLI re-reads the settled file. - } - // Why: link the user's real ~/.codex resources and mirror config into THIS - // home (never symlinking into or mutating ~/.codex), so the per-account home - // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - syncSystemCodexResourcesIntoManagedHome(perAccountHome) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath: perAccountHome, - systemHomePath: getSystemCodexHomePath() - }) - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return perAccountHome - } - - // Why: Codex's own `/resume` picker only lists rollouts under the launch - // CODEX_HOME, so a self-contained account home starts out with no history at - // all. Hardlink every other Orca-visible home's rollouts in — after launch, - // since history trees can be large — so switching accounts no longer hides - // the user's conversations. - private startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { - void startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: perAccountHome, - sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() - }) - } - - private getSelfContainedSessionBridgeSourceHomes(): string[] { - return [ - // Why: history-only override lets custom-CODEX_HOME users bridge from the - // home they actually record sessions in; falls back to the real ~/.codex. - resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), - // Why: path only — a per-account install must not materialize the mirror. - resolveOrcaManagedCodexHomePath(), - ...this.getManagedHostAccountHomesForSessionDiscovery() - ] - } - - // Why: the per-account home is both the launch CODEX_HOME and the credential - // store, so codex reads/refreshes auth.json in place — there is no shared-home - // hot-swap or token read-back to reconcile. A trusted home remains selected - // while Codex atomically replaces auth.json. - private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: a sync runs on every app start, exactly when antivirus is busiest. - // An unreadable home must not deselect the account (#STA-4422). - return - } - const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null - if (perAccountHome) { - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - // Why: selection runs well before the user restarts a pane, so history is - // already linked in by the time the newly launched Codex opens /resume. - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return - } - this.clearSelfContainedManagedSelection(account) - } - - /** - * Why: an unreadable home and an untrustworthy one demand opposite responses. - * Only `untrusted` may clear the user's selection; `indeterminate` means we - * could not tell, so callers refuse the operation and leave durable state - * alone (#STA-4422). - */ - private resolveSelfContainedManagedHome( - account: CodexManagedAccount - ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { - const verdict = resolveHostCodexManagedHomeVerdict({ - candidatePath: account.managedHomePath, - managedAccountsRoot: this.getManagedAccountsRoot(), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: account.id - }) - if (verdict.kind === 'owned') { - // Preserve the persisted path spelling (notably /var vs /private/var on - // macOS) so injected CODEX_HOME stays stable across the rollout. - return { kind: 'owned', homePath: account.managedHomePath } - } - if (verdict.kind === 'untrusted') { - console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) - return { kind: 'untrusted' } - } - console.warn( - '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', - verdict.error - ) - return { kind: 'indeterminate' } - } - - /** Read-only callers that mutate nothing and simply skip an unusable home. */ - private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' ? resolved.homePath : null - } - - private clearSelfContainedManagedSelection( - account: CodexManagedAccount, - reason = 'home is invalid' - ): void { - console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) - const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== account.id) { - return - } - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - this.lastSyncedAccountId = null - this.lastHostAccountUsedSelfContainedHome = false - } - - private invalidateBackfillAfterManagedSystemDefaultLaunch( - launchEnv?: NodeJS.ProcessEnv - ): boolean | null { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - hasCustomCodexHomeOverrideForLaunch(launchEnv) - ) { - return null - } - if (!this.hostSystemDefaultSessionMigrationPending) { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = - !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) - this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( - paths.systemSessionsRoot - ) - this.hostSystemDefaultSessionMigrationPending = true - } - return this.prepareHostSystemDefaultSessionMigrationPass() - } - - private startWslSessionBridgeForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (process.platform !== 'win32' || !runtimeHomePath) { - return - } - const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) - const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() - if (!distro) { - return - } - // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. - const systemCodexHomePath = - resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { - // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. - void startWslCodexSessionBridgeInBackground({ - distro, - systemCodexHomePath, - managedCodexHomePath: runtimeHomePath - }) - } - } - - getHostCodexHomePathsForSessionDiscovery(): string[] { - const homes = [this.getRuntimeHomePath()] - if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { - // Why: nested Orca processes can retain an ambient managed CODEX_HOME. - // Per-account lanes no longer bridge real-home history into the shared - // mirror, so include the real root for both directly-routed host lanes. - homes.push(getSystemCodexHomePath()) - } - // Why: account-scoped rollouts live in each account's own home, including WSL. - for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { - homes.push(perAccountHome) - } - return homes.filter((home, index) => homes.indexOf(home) === index) - } - - /** - * The account-owned CODEX_HOME the current HOST selection runs against, or - * null when the selection is not routed to one (system default, or a WSL - * account, whose home lives inside the distro). - * - * Read-only on purpose: session discovery ranks homes with this before any - * launch prep, so it must create no directories and sync no auth. - */ - getSelectedHostAccountCodexHomePath(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - return selfContainedAccount - ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) - : null - } - - /** - * Same selection, but an unreadable home refuses instead of collapsing to - * `null`. Session resume must not read "no managed selection" out of a failed - * marker stat: another account's readable alias would then win the legacy - * rescan and the pane would resume under that account's credentials while the - * UI still shows this one (#STA-4422). - */ - resolveSelectedHostAccountCodexHomePathForResume(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (!selfContainedAccount) { - return null - } - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(selfContainedAccount) - return null - } - return resolved.homePath - } - - /** Trust-gates host previews without changing WSL routing or durable account state. */ - resolveCodexManagedAccountHomeForInactiveFetch( - account: CodexManagedAccount - ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { - if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { - return { kind: 'ready', homePath: account.managedHomePath } - } - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' - ? { kind: 'ready', homePath: resolved.homePath } - : { kind: 'skip' } - } - - getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { - if (this.getSelfContainedManagedHostAccount()) { - return 'account-home' - } - return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' - } - - getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { - const settings = this.store.getSettings() - const homes = new Map() - for (const ptyId of ptyIds) { - const record = getCodexPaneAccount(ptyId) - if (!record || record.selectionKey !== 'host') { - continue - } - if ( - record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' - ) { - const homePath = this.getRuntimeHomePath() - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - continue - } - if (record.homeRoute !== 'account-home' || !record.accountId) { - continue - } - const account = settings.codexManagedAccounts.find( - (candidate) => candidate.id === record.accountId - ) - if (!account || this.getWslManagedHomePath(account)) { - continue - } - const homePath = this.getTrustedSelfContainedManagedHomePath(account) - if (homePath) { - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - } - } - return [...homes.values()] - } - - // Why: the real-home hook installer flips this gate off when the trust-grant - // client reports the host incapable, keeping that host byte-identical to the - // managed lane instead of shipping status-blind panes. - private realHomeLaneGate: () => boolean = () => true - - setRealHomeLaneGate(gate: () => boolean): void { - this.realHomeLaneGate = gate - } - - // Why: real-home routing applies only to the host system-default selection. - // Managed accounts run in their own homes; Windows (no shell-startup probe) - // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked - // across old homes. - isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - !isShellStartupEnvProbeSupported() - ) { - return false - } - return !hasCustomCodexHomeOverrideForLaunch(launchEnv) - } - - isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { - return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() - } - - reconcileLegacySharedHomeForRetainedPanes(): void { - if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { - return - } - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - syncLegacySharedCodexConfigForRetainedPanes() - } - - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return null - } - const home = getWslHome(distro) - if (home && /^[A-Za-z]:[\\/]/.test(home)) { - const linuxHome = toLinuxPath(home).trim() - return linuxHome.startsWith('/') - ? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro) - : null - } - return home ? this.joinWslPath(home, '.codex') : null - } - - private finishWslLaunchPreparation( - target: CodexAccountSelectionTarget, - homePath: string | null - ): void { - this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) - this.startWslSessionBridgeForLaunch(target, homePath) - } - - private syncWslConfigAndGlobalInstructionsForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (!runtimeHomePath) { - return - } - const distro = - parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return - } - const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (!systemHomePath || systemHomePath === runtimeHomePath) { - return - } - // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. - syncCodexGlobalInstructionsIntoManagedHome({ - systemHomePath, - managedHomePath: runtimeHomePath - }) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath, - systemHomePath, - systemConfigDir: toLinuxPath(systemHomePath) - }) - } - - // Why: `null` is a real value here — it means "use the system-default lane". - // A skipped poll needs its own channel or the fetcher silently retargets the - // user's real ~/.codex (#STA-4422). - prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - return { - kind: 'ready', - codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) - } - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'owned') { - // Why: the quota fetch reads the account's own auth.json in place; no - // shared-home hot-swap or per-poll resource relink (that is launch prep). - return { kind: 'ready', codexHomePath: resolved.homePath } - } - if (resolved.kind === 'indeterminate') { - // Why: returning null here would NOT skip — the fetcher maps null to - // ~/.codex and would probe the user's real home with a token-refreshing - // app-server. Skip the poll outright and keep the selection. - return { kind: 'skip' } - } - this.clearSelfContainedManagedSelection(selfContainedAccount) - } - if (this.isHostSystemDefaultRealHome()) { - // Why: null lets the fetcher fall back to the main process's inherited - // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the - // managed home, restarting the background OAuth conflict (#5370), so - // pin this non-interactive lane to the native home explicitly. - if (hasRecordedLegacySharedCodexPane()) { - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - } - return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } - } - this.syncForCurrentSelection() - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } - } - - syncForCurrentSelection( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv - ): void { - if (target?.runtime === 'wsl') { - this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) - return - } - - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - // Why: self-contained managed homes hold their own auth, so the shared - // runtime home's snapshot/hot-swap/read-back machinery below must not run. - this.syncSelfContainedManagedSelection(selfContainedAccount) - return - } - const settings = this.store.getSettings() - if (this.lastHostAccountUsedSelfContainedHome) { - // Why: the account's auth is already canonical in its own home. Reset the - // legacy mirror baseline without reading it; a real-home deselect needs no - // further sync, and the mirror lane below re-seeds from canonical storage. - this.lastHostAccountUsedSelfContainedHome = false - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - if (this.isHostSystemDefaultRealHome(launchEnv)) { - return - } - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: retained daemon panes may own shared auth from a managed launch; - // compatibility reconciliation runs later with durable provenance. - if (this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = true - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - } - return - } - const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) - if (this.lastSyncedAccountId === null) { - this.captureSystemDefaultSnapshot({ force: false }) - } - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (activeAccount) { - // Why: only a WSL-managed account can reach here — every host account was - // routed to its own self-contained home above. Its auth lives in the - // distro-local runtime home, so the host mirror only drops its baseline. - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - return - } - if (normalizeCodexRuntimeSelection(settings).host) { - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - } - // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) - this.lastSyncedAccountId = null - } else if (!runtimeAuthExistedBeforeSync) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - if (logoutMarkerStatus.kind === 'applies') { - this.lastWrittenAuthJson = null - } else if ( - logoutMarkerStatus.kind === 'system-default-changed' && - logoutMarkerStatus.systemDefaultAuthJson !== null - ) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - } else if (this.lastWrittenAuthJson === null) { - // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else { - this.persistRuntimeLogoutMarker() - } - } else { - this.clearRuntimeLogoutMarker() - this.syncRuntimeAuthWithSystemDefault() - } - } - - // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. - clearLastWrittenAuthJson( - accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - ): void { - if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { - this.lastWrittenAuthJson = null - } - } - - // Why: which ~/.codex bytes the mirror was seeded from, and whether the system - // default can be proven to own the mirror at all. - private resolveSystemDefaultMirrorClaim( - runtimeAuth: string, - provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus - ): { ownershipProven: boolean; mirroredAuthJson: string | null } { - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const snapshotAuth = - this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null - const preProvenanceRuntimeRefreshProven = - provenanceStatus.kind === 'missing' && - snapshotAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && - codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) - return { - ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, - mirroredAuthJson: - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshotAuth) - : null - } - } - - private safeSyncForCurrentSelection(): void { - try { - this.syncForCurrentSelection() - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) - } - } - - private safeRecoverInterruptedRuntimeAuthOperation(): void { - try { - recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) - } - } - - private getActiveAccount( - accounts: CodexManagedAccount[], - activeAccountId: string | null - ): CodexManagedAccount | null { - if (!activeAccountId) { - return null - } - return accounts.find((account) => account.id === activeAccountId) ?? null - } - - private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { - return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null - } - - private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - return this.getWslCodexHomePathForSelection(target) - } - - private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, target) - ) - if (account) { - const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() - const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) - if (accountHome) { - return accountHome - } - } - return this.getWslSystemCodexHomePath(target) - } - - private getWslLaunchCodexHomePath( - account: CodexManagedAccount, - targetDistro: string | undefined - ): string | null { - const wslHome = this.getWslManagedHomeIdentity(account) - if (!wslHome) { - return null - } - const accountDistro = wslHome.distro - if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { - return null - } - if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { - return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) - } - return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) - } - - private getWslManagedHomeIdentity( - account: CodexManagedAccount | null - ): { distro: string; linuxHomePath: string } | null { - if (!account) { - return null - } - const distro = account.wslDistro?.trim() - const linuxHomePath = account.wslLinuxHomePath?.trim() - if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) { - return { distro, linuxHomePath } - } - const legacyHome = parseWslUncPath(account.managedHomePath) - return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null - } - - private startLegacyWslAuthDrain( - target: CodexAccountSelectionTarget, - options: { throwOnFailure?: boolean } = {} - ): Promise { - if (process.platform !== 'win32') { - return Promise.resolve() - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return Promise.resolve() - } - const guestHome = getWslHome(distro) - const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' - if (!guestHomeLinuxPath.startsWith('/')) { - return Promise.resolve() - } - let legacyPanePresent = true - try { - legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) - } catch (error) { - // Why: unknown pane liveness must preserve the source, but promotion can - // still keep the direct home from launching stale auth. - console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error) - } - return startLegacyWslRuntimeAuthDrain( - { - distro, - guestHomeLinuxPath, - legacyPanePresent, - resolveDestination: (runtimeAuthContents) => - this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) - }, - options - ) - } - - /** Preserve refreshed auth from retained legacy WSL panes before restart. */ - async syncActiveWslSelectionsBeforeRestart(): Promise { - if (process.platform !== 'win32') { - return - } - const settings = this.store.getSettings() - const drains: Promise[] = [] - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() || null - : selectedDistroKey.trim() || null - if (distro) { - drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) - } - } - await Promise.all(drains) - } - - private async resolveLegacyWslAuthDestination( - distro: string, - runtimeAuthContents: string - ): Promise { - const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { - const wslHome = this.getWslManagedHomeIdentity(account) - return wslHome?.distro.toLowerCase() === distro.toLowerCase() - ? [{ account, linuxPath: wslHome.linuxHomePath }] - : [] - }) - const accounts = accountHomes.map(({ account }) => account) - const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null - let reads: WslCodexAuthRead[] - try { - reads = await readWslCodexAuths(distro, [ - ...accountHomes.map(({ linuxPath }) => linuxPath), - ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) - ]) - } catch { - reads = accountHomes.map(() => ({ kind: 'unreadable' })) - if (parsedSystemHome) { - reads.push({ kind: 'unreadable' }) - } - } - const authReads = new Map( - accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) - ) - const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { - accounts, - authReads - }) - if (match.kind === 'ambiguous') { - return null - } - if (match.kind === 'matched') { - const accountHome = accountHomes.find(({ account }) => account.id === match.account.id) - if (!accountHome) { - return null - } - return { - authContents: match.managedAuthContents, - linuxHomePath: accountHome.linuxPath - } - } - - if (!systemHome || !parsedSystemHome) { - return null - } - const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } - if (systemAuth.kind !== 'present') { - return null - } - return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) - ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } - : null - } - - private joinWslPath(basePath: string, ...segments: string[]): string { - return parseWslUncPath(basePath) - ? pathWin32.join(basePath, ...segments) - : join(basePath, ...segments) - } - - private resolveWslDefaultTarget( - target: CodexAccountSelectionTarget - ): CodexAccountSelectionTarget { - if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { - return target - } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target - } - - private findManagedAccountForRuntimeAuth( - runtimeAuthContents: string, - expectedAccountId?: string, - options?: { - accounts: readonly CodexManagedAccount[] - authReads: ReadonlyMap - } - ): CodexReadBackMatch { - const matches: { - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - }[] = [] - let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { - if (expectedAccountId && account.id !== expectedAccountId) { - continue - } - const managedAuthPath = join(account.managedHomePath, 'auth.json') - let managedAuthContents: string - const suppliedRead = options?.authReads.get(account.id) - if (suppliedRead?.kind === 'missing') { - continue - } - if (suppliedRead?.kind === 'unreadable') { - // Why: an unreadable home can never be compared, but letting the read - // throw abandons the scan for every other account — dropping a refresh - // the runtime home holds for one of them. Only its record can rule it - // out as the owner; when it cannot, the scan is no longer unambiguous. - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - if (suppliedRead?.kind === 'present') { - managedAuthContents = suppliedRead.contents - } else { - if (!existsSync(managedAuthPath)) { - continue - } - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - } - if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { - matches.push({ account, managedAuthPath, managedAuthContents }) - } - } - - if (unreadableHomeCouldOwnRuntimeAuth) { - return { kind: 'ambiguous' } - } - if (matches.length === 1) { - return { kind: 'matched', ...matches[0] } - } - return { kind: matches.length === 0 ? 'none' : 'ambiguous' } - } - - private runtimeAuthMatchesSystemDefaultIdentity( - runtimeAuthContents: string, - systemDefaultAuthContents: string - ): boolean { - return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) - } - - private safeMigrateLegacySharedAuth(): void { - const settings = this.store.getSettings() - try { - migrateLegacySharedAuthToPerAccountHome({ - activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, - hostAccounts: settings.codexManagedAccounts.filter( - (account) => !this.getWslManagedHomePath(account) - ), - managedAccountsRoot: this.getManagedAccountsRoot(), - metadataDir: this.getRuntimeMetadataDir(), - sharedRuntimeHome: this.getRuntimeHomePath(), - systemCodexHome: getSystemCodexHomePath() - }) - } catch (error) { - // Why: an inconclusive identity, ownership, or filesystem result must - // leave the marker absent so the next startup can retry safely. - console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) - } - } - - private safeMigrateLegacyManagedState(): void { - try { - this.migrateLegacyManagedStateIfNeeded() - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) - } - } - - private safeMigrateLegacyActiveHomePointer(): void { - try { - const activeHomePath = this.getLegacyHostActiveHomePath() - if (!this.legacyActiveHomePathExists(activeHomePath)) { - return - } - this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) - } - } - - private getRuntimeHomePath(): string { - return getOrcaManagedCodexHomePath() - } - - /** - * Resolves the managed home the config mirror actually targets for the - * current HOST selection, or null when no mirror runs for it. - * - * Read-only on purpose: unlike the launch and quota-fetch paths this prepares - * nothing and creates no directories, so surfacing sync health cannot alter - * the state it is reporting on. Returns null for the system default on the - * real-home lane, which runs Codex directly against ~/.codex — there is no - * mirror there, so there is nothing that can fall behind. - */ - getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - // Why: `null` here is a positive claim that no mirror exists, which the - // status channel reports as healthy. An unreadable home is not that. - return { kind: 'unavailable' } - } - return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } - } - if (this.isHostSystemDefaultRealHome()) { - return { kind: 'ready', homePath: null } - } - return { - kind: 'ready', - homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') - } - } - - private getRuntimeAuthPath(): string { - return join(this.getRuntimeHomePath(), 'auth.json') - } - - private getSystemDefaultSnapshotPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') - } - - private getRuntimeLogoutMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') - } - - private getSharedRuntimeAuthProvenancePath(): string { - return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') - } - - private getRuntimeMetadataDir(): string { - const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') - mkdirSync(metadataDir, { recursive: true }) - return metadataDir - } - - private getLegacyHostActiveHomePath(): string { - return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') - } - - private getMigrationMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-v1.json') - } - - private getMigrationDiagnosticsPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') - } - - private getManagedAccountsRoot(): string { - return join(app.getPath('userData'), 'codex-accounts') - } - - private repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { - if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { - return - } - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - return - } - - mkdirSync(runtimeHomePath, { recursive: true }) - mkdirSync(dirname(activeHomePath), { recursive: true }) - const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - try { - symlinkSync( - runtimeHomePath, - nextLinkPath, - process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() - ? 'junction' - : undefined - ) - try { - renameSync(nextLinkPath, activeHomePath) - } catch (error) { - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - throw error - } - this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) - renameSync(nextLinkPath, activeHomePath) - } - } finally { - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - } - } - - private activeHomeAlreadyPointsToRuntimeHome( - activeHomePath: string, - runtimeHomePath: string - ): boolean { - try { - return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) - } catch { - return false - } - } - - private linkTargetsMatch( - linkTarget: string, - linkPath: string, - expectedTargetPath: string - ): boolean { - const resolvedLinkTarget = isAbsolute(linkTarget) - ? resolve(linkTarget) - : resolve(dirname(linkPath), linkTarget) - return resolvedLinkTarget === resolve(expectedTargetPath) - } - - private legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { - try { - const stat = lstatSync(activeHomePath) - return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) - } catch { - return true - } - } - - private legacyActiveHomePathExists(activeHomePath: string): boolean { - try { - lstatSync(activeHomePath) - return true - } catch { - return false - } - } - - private removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { - try { - const stat = lstatSync(activeHomePath) - if (stat.isSymbolicLink()) { - unlinkSync(activeHomePath) - } else if (this.isWindowsReadableLink(activeHomePath)) { - rmdirSync(activeHomePath) - } - } catch { - // Missing or inaccessible temporary links are handled by the caller. - } - } - - private isWindowsReadableLink(targetPath: string): boolean { - if (process.platform !== 'win32') { - return false - } - try { - readlinkSync(targetPath) - return true - } catch { - return false - } - } - - private migrateLegacyManagedStateIfNeeded(): void { - if (existsSync(this.getMigrationMarkerPath())) { - return - } - - const managedHomes = this.getLegacyManagedHomes() - for (const managedHomePath of managedHomes) { - const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( - /[\\/]/ - )[0] - if (!accountId) { - continue - } - this.migrateLegacyHistory(managedHomePath) - this.migrateLegacySessions(managedHomePath, accountId) - } - - // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. - writeFileAtomically( - this.getMigrationMarkerPath(), - `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` - ) - } - - private getLegacyManagedHomes(): string[] { - const managedAccountsRoot = this.getManagedAccountsRoot() - if (!existsSync(managedAccountsRoot)) { - return [] - } - - const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) - const managedHomes: string[] = [] - for (const entry of accountEntries) { - if (!entry.isDirectory()) { - continue - } - const managedHomePath = join(managedAccountsRoot, entry.name, 'home') - if (existsSync(join(managedHomePath, '.orca-managed-home'))) { - managedHomes.push(managedHomePath) - } - } - return managedHomes.sort() - } - - private migrateLegacyHistory(managedHomePath: string): void { - const legacyHistoryPath = join(managedHomePath, 'history.jsonl') - if (!existsSync(legacyHistoryPath)) { - return - } - - const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') - const existingLines = existsSync(runtimeHistoryPath) - ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) - : [] - const mergedLines = [...existingLines] - const seenLines = new Set(existingLines) - for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { - if (!line || seenLines.has(line)) { - continue - } - seenLines.add(line) - mergedLines.push(line) - } - - if (mergedLines.length === 0) { - return - } - writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) - } - - private migrateLegacySessions(managedHomePath: string, accountId: string): void { - const legacySessionsRoot = join(managedHomePath, 'sessions') - if (!existsSync(legacySessionsRoot)) { - return - } - - const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') - mkdirSync(runtimeSessionsRoot, { recursive: true }) - for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { - const relativePath = relative(legacySessionsRoot, legacyFilePath) - const runtimeFilePath = join(runtimeSessionsRoot, relativePath) - mkdirSync(dirname(runtimeFilePath), { recursive: true }) - if (!existsSync(runtimeFilePath)) { - copyFileSync(legacyFilePath, runtimeFilePath) - continue - } - - const legacyContents = readFileSync(legacyFilePath) - const runtimeContents = readFileSync(runtimeFilePath) - if (runtimeContents.equals(legacyContents)) { - continue - } - - const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) - copyFileSync(legacyFilePath, preservedPath) - this.appendMigrationDiagnostic({ - type: 'session-conflict', - accountId, - runtimeFilePath, - preservedPath - }) - } - } - - private listFilesRecursively(rootPath: string): string[] { - const stat = statSync(rootPath) - if (!stat.isDirectory()) { - return [rootPath] - } - - const files: string[] = [] - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - this.appendListedFiles(files, this.listFilesRecursively(childPath)) - continue - } - if (entry.isFile()) { - files.push(childPath) - } - } - return files.sort() - } - - private appendListedFiles(target: string[], source: readonly string[]): void { - // Why: tolerate directories larger than V8's argument limit for spread calls. - for (const filePath of source) { - target.push(filePath) - } - } - - private getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { - const extension = extname(runtimeFilePath) - const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) - return `${basename}.orca-legacy-${accountId}${extension}` - } - - private appendMigrationDiagnostic(record: Record): void { - const diagnosticsPath = this.getMigrationDiagnosticsPath() - try { - appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) - } catch (error) { - // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. - console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) - } - } - - private captureSystemDefaultSnapshot(options: { force: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!options.force && existsSync(snapshotPath)) { - return - } - - const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') - const snapshot: CodexSystemDefaultSnapshot = { - authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null - } - writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) - } - - private syncRuntimeAuthWithSystemDefault(): void { - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (!existsSync(runtimeAuthPath)) { - return - } - - try { - const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') - const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - if (provenance?.owner === 'managed') { - this.captureSystemDefaultSnapshot({ force: true }) - if (!existsSync(systemDefaultAuthPath)) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { - owner: 'system-default' - }) - return - } - const { - ownershipProven: systemDefaultOwnershipProven, - mirroredAuthJson: mirroredSystemDefaultAuth - } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) - if (!existsSync(systemDefaultAuthPath)) { - if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - } - return - } - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - if (runtimeAuth === systemDefaultAuth) { - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - systemDefaultAuth === mirroredSystemDefaultAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) - return - } - // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) - } - } - - private syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { - if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = false - return - } - const runtimeAuthPath = this.getRuntimeAuthPath() - try { - let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'managed' - ) { - const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( - provenanceStatus.provenance - ) - if (restoredProvenance) { - provenanceStatus = { kind: 'committed', provenance: restoredProvenance } - } - } - if ( - provenanceStatus.kind === 'fenced' || - (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') - ) { - return - } - const systemAuth = this.readSystemDefaultAuth() - if (!existsSync(runtimeAuthPath)) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const knownSystemAuthBaseline = - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' - ? provenanceStatus.provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson) - : undefined - if (systemAuth === null) { - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' && - provenanceStatus.provenance.authJson === null && - logoutMarkerStatus.kind === 'applies' && - snapshot?.authJson === null - ) { - this.lastWrittenAuthJson = null - return - } - // Why: commit a crashed logout before a managed transition can discard its recovery baseline. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if ( - logoutMarkerStatus.kind === 'system-default-changed' || - (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) - ) { - const replaced = this.writeRuntimeAuth( - systemAuth, - { - owner: 'system-default' - }, - { expectedContents: null } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } - return - } - const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const knownSharedAuth = - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) - : null - // Why: only bytes Orca can prove it wrote belong to the compatibility - // mirror; retained Codex or a managed transition owns every other value. - if (knownSharedAuth === null) { - return - } - const sharedAuthOwnedBySystemDefault = - runtimeAuthBeforeSync === knownSharedAuth || - (provenance?.owner === 'system-default' && - systemAuth === null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) - if (!sharedAuthOwnedBySystemDefault) { - return - } - if (systemAuth === null) { - removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) - if (existsSync(runtimeAuthPath)) { - this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) - return - } - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - return - } - if (runtimeAuthBeforeSync !== knownSharedAuth) { - return - } - const replaced = this.writeRuntimeAuth( - systemAuth, - { owner: 'system-default' }, - { expectedContents: runtimeAuthBeforeSync } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } catch (error) { - console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) - } - } - - private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (existsSync(systemDefaultAuthPath)) { - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - - if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { - // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (options.detectExternalLogin) { - // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (!existsSync(snapshotPath)) { - this.captureSystemDefaultSnapshot({ force: true }) - } - - const snapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!snapshot) { - console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') - rmSync(snapshotPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!refreshedSnapshot) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if (refreshedSnapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) - return - } - if (snapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) - } - - private writeSystemDefaultAuth(contents: string): void { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) - writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) - this.ensureOwnerOnlyMode(systemDefaultAuthPath) - } - - private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { - // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - } - - private readSystemDefaultAuth(): string | null { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null - } - - private writeRuntimeAuth( - contents: string, - owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, - options?: { expectedContents: string | null } - ): boolean { - // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. - const runtimeAuthPath = this.getRuntimeAuthPath() - if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { - return false - } - const provenance: CodexSharedRuntimeAuthProvenance = - owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner - const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) - if (runtimeAuthComparison === null) { - // Why: an unreadable runtime auth.json may hold a token Codex rotated a - // moment ago. Treating "could not read" as "differs" sent execution to the - // unconditional write below, consuming that rotation and logging the user - // out for good. Refuse; the next sync retries. - return false - } - const runtimeAuthAlreadyMatches = runtimeAuthComparison - if ( - runtimeAuthAlreadyMatches && - this.sharedRuntimeAuthProvenanceMatches( - this.resolveSharedRuntimeAuthProvenanceStatus(), - provenance - ) - ) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.clearRuntimeLogoutMarker() - return true - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson: contents - }) - if (runtimeAuthAlreadyMatches) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - const replaced = options - ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { - mode: 0o600 - }) - : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) - if (!replaced) { - return false - } - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - - /** - * `true`/`false` only when the bytes were actually read; `null` when the file - * could not be read at all. The old `catch { return false }` reported "these - * differ" for a file nobody could open, and every caller reads that as - * permission to write. - */ - private compareFileContents(targetPath: string, contents: string): boolean | null { - try { - return readFileSync(targetPath, 'utf-8') === contents - } catch (error) { - return isDefinitiveAbsence(error) ? false : null - } - } - - private fileContentsEqual(targetPath: string, contents: string): boolean { - return this.compareFileContents(targetPath, contents) === true - } - - private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean { - if (expectedContents === null) { - // Why: `!existsSync` does report `true` for a locked file, but this branch - // is not where that matters — the write it guards is - // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the - // real file and refuses on its own. Classifying here would be a guard no - // test can drive. - return !existsSync(targetPath) - } - return this.fileContentsEqual(targetPath, expectedContents) - } - - private ensureOwnerOnlyMode(targetPath: string): void { - if (process.platform === 'win32') { - return - } - try { - chmodSync(targetPath, 0o600) - } catch { - /* Best effort: the next atomic write will set the restrictive mode. */ - } - } - - private getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { - const marker = this.readRuntimeLogoutMarker() - if (!marker) { - return { kind: 'missing' } - } - const systemDefaultAuthJson = this.readSystemDefaultAuth() - if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { - return { kind: 'applies' } - } - this.clearRuntimeLogoutMarker() - return { kind: 'system-default-changed', systemDefaultAuthJson } - } - - private persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { - const marker: CodexRuntimeLogoutMarker = { - systemDefaultAuthJson, - loggedOutAt: Date.now() - } - writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { - mode: 0o600 - }) - } - - private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown - } catch { - return null - } - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) || - !('systemDefaultAuthJson' in parsed) || - !('loggedOutAt' in parsed) - ) { - return null - } - const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } - if ( - (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || - typeof marker.loggedOutAt !== 'number' - ) { - return null - } - return marker as CodexRuntimeLogoutMarker - } - - private clearRuntimeLogoutMarker(): void { - rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) - } - - private persistSharedRuntimeAuthProvenance( - provenance: CodexSharedRuntimeAuthProvenanceFile - ): void { - writeFileAtomically( - this.getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify(provenance, null, 2)}\n`, - { mode: 0o600 } - ) - } - - private markSharedRuntimeAuthManaged(accountId: string): void { - const status = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - status.kind === 'committed' && - status.provenance.owner === 'managed' && - status.provenance.accountId === accountId - ) { - return - } - const runtimeAuthJson = this.readRuntimeAuthForProvenance() - const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) - const provenance: CodexSharedRuntimeAuthProvenance = { - owner: 'managed', - accountId, - ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson - }) - if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { - this.persistSharedRuntimeAuthProvenance(provenance) - } - } - - private getUntouchedSystemDefaultBaseline( - status: CodexSharedRuntimeAuthProvenanceStatus, - runtimeAuthJson: string | null - ): { authJson: string | null } | null { - if (status.kind !== 'committed') { - return null - } - const baseline = - status.provenance.owner === 'system-default' - ? { authJson: status.provenance.authJson } - : status.provenance.systemDefaultBaseline - return baseline && runtimeAuthJson === baseline.authJson ? baseline : null - } - - private restoreUntouchedSystemDefaultProvenance( - provenance: Extract - ): Extract | null { - const baseline = provenance.systemDefaultBaseline - if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - const restored = { owner: 'system-default' as const, authJson: baseline.authJson } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: restored, - runtimeAuthJson: baseline.authJson - }) - if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - this.persistSharedRuntimeAuthProvenance(restored) - return restored - } - - private sharedRuntimeAuthProvenanceMatches( - status: CodexSharedRuntimeAuthProvenanceStatus, - expected: CodexSharedRuntimeAuthProvenance - ): boolean { - if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { - return false - } - return expected.owner === 'system-default' - ? status.provenance.owner === 'system-default' && - status.provenance.authJson === expected.authJson - : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId - } - - private resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { - const provenancePath = this.getSharedRuntimeAuthProvenancePath() - if (!existsSync(provenancePath)) { - return { kind: 'missing' } - } - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown - } catch { - return { kind: 'fenced' } - } - const committed = this.parseSharedRuntimeAuthProvenance(parsed) - if (committed) { - return { kind: 'committed', provenance: committed } - } - const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) - if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { - return { kind: 'fenced' } - } - try { - this.persistSharedRuntimeAuthProvenance(pending.next) - return { kind: 'committed', provenance: pending.next } - } catch { - return { kind: 'fenced' } - } - } - - private parseSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const provenance = value as Record - if ( - provenance.owner === 'system-default' && - (typeof provenance.authJson === 'string' || provenance.authJson === null) - ) { - return { owner: 'system-default', authJson: provenance.authJson } - } - if ( - provenance.owner !== 'managed' || - typeof provenance.accountId !== 'string' || - provenance.accountId.length === 0 - ) { - return null - } - const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) - if ('systemDefaultBaseline' in provenance && !baseline) { - return null - } - return { - owner: 'managed', - accountId: provenance.accountId, - ...(baseline ? { systemDefaultBaseline: baseline } : {}) - } - } - - private parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const baseline = value as Record - return typeof baseline.authJson === 'string' || baseline.authJson === null - ? { authJson: baseline.authJson } - : null - } - - private parsePendingSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthPendingProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const pending = value as Record - const next = this.parseSharedRuntimeAuthProvenance(pending.next) - return pending.owner === 'pending' && - next && - (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) - ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } - : null - } - - private readRuntimeAuthForProvenance(): string | null { - try { - return readFileSync(this.getRuntimeAuthPath(), 'utf-8') - } catch { - return null - } - } - - private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { - let rawContents: string - try { - rawContents = readFileSync(snapshotPath, 'utf-8') - } catch { - return null - } - try { - const parsed = JSON.parse(rawContents) as unknown - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - 'authJson' in parsed && - (typeof (parsed as { authJson: unknown }).authJson === 'string' || - (parsed as { authJson: unknown }).authJson === null) - ) { - return parsed as CodexSystemDefaultSnapshot - } - // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - !('authJson' in parsed) - ) { - return { authJson: rawContents } - } - } catch { - return null - } - return null - } - - clearSystemDefaultSnapshot(): void { - rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) - } } diff --git a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts index 0cb6c02cf3a..1982facd5f3 100644 --- a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts +++ b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts @@ -226,16 +226,26 @@ describe('GpuCrashDiagnosticsRecorder', () => { describe('GPU crash diagnostics production wiring', () => { it('starts diagnostics without delaying safe-graphics fallback', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const listenerStart = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-preflight.ts'), + 'utf8' + ) + const listenerSource = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const listenerStart = listenerSource.indexOf(" app.on('child-process-gone'") expect(listenerStart).toBeGreaterThan(0) - const listener = source.slice(listenerStart, source.indexOf('\n })', listenerStart)) + const listener = listenerSource.slice( + listenerStart, + listenerSource.indexOf('\n })', listenerStart) + ) expect(source).toMatch( /recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/ ) expect(listener).toMatch( - /const crashedAt = performance\.now\(\)[\s\S]*?void gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ + /const crashedAt = performance\.now\(\)[\s\S]*?void state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ ) - expect(listener).not.toMatch(/gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) + expect(listener).not.toMatch(/state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) }) }) diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index aec22e2e446..43441250708 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -40,10 +40,13 @@ const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } ] -/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */ +/** Ready-phase `child-process-gone` listener body — the wiring these claims rest on. */ function readChildProcessGoneListener(): string { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const start = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const start = source.indexOf(" app.on('child-process-gone'") expect(start).toBeGreaterThan(0) return source.slice(start, source.indexOf('\n })', start)) } @@ -89,7 +92,7 @@ describe('1.4.190 win32 GPU-child crash cluster', () => { expect(guardStart).toBeGreaterThan(0) expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) expect(listener).toMatch( - /isGpuFallbackCrashCandidate\([\s\S]*?gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ + /isGpuFallbackCrashCandidate\([\s\S]*?state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ ) // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which // re-couples recovery to the suppression decision, so pin the guard to that check alone. diff --git a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts index 3c93f7992ff..23707754ff8 100644 --- a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts +++ b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts @@ -108,11 +108,18 @@ describe('handleGpuFallbackRecoveredLaunch', () => { describe('recovered safe-graphics production wiring', () => { it('prompts only after the recovered window is shown and persists both consent states', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - expect(source).toMatch( + const windowSource = readFileSync( + join(__dirname, '..', 'startup', 'main-window-controller.ts'), + 'utf8' + ) + const lifecycleSource = readFileSync( + join(__dirname, '..', 'startup', 'gpu-lifecycle.ts'), + 'utf8' + ) + expect(windowSource).toMatch( /window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/ ) - expect(source).toMatch( + expect(lifecycleSource).toMatch( /persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/ ) }) diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts index d8cc23c28a3..5f7a10fedec 100644 --- a/src/main/headless-automation-dispatcher-source-boundary.test.ts +++ b/src/main/headless-automation-dispatcher-source-boundary.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-automations.ts'), 'utf8') function sourceBetween(startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) diff --git a/src/main/index.ts b/src/main/index.ts index 5a4964bd729..c66191c82b3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3831 +1,63 @@ -/* eslint-disable max-lines -- main-process entry point; owns app lifecycle, service wiring, window creation, and hook/daemon startup with no cleaner split seam. */ -import { existsSync, statSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { isAbsolute, join } from 'node:path' -import os from 'node:os' -import { - app, - BrowserWindow, - clipboard, - dialog, - ipcMain, - nativeTheme, - powerMonitor, - type Tray, - session -} from 'electron' -import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' -import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' -import { electronApp, is } from '@electron-toolkit/utils' -import { - Store, - initDataPath, - getCanonicalUserDataPath, - migrateMobilePairingDataToCanonicalUserDataPath -} from './persistence' -import { setAppEnvironment } from '../shared/app-environment' -import { ElectronAppEnvironment } from './host/electron-app-environment' -import { setPtyHostBindings } from './ipc/pty-host-bindings' -import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface' -import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface' -import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands' -import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' -import { electronHttpClient } from './host/electron-http-client' -import { setMainHttpClient } from './network/http-client' -import { electronSpeechServiceFactories } from './host/electron-speech-services' -import { setSpeechServiceFactories } from './speech/speech-runtime-service' -import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' -import { setSecretStore } from '../shared/secret-store' -import { ElectronSecretStore } from './host/electron-secret-store' -import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report' -import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' -import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' -import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' -import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' -import { applyAppIcon } from './app-icon' -import { relaunchApp } from './app-relaunch' -import { StatsCollector, initStatsPath } from './stats/collector' -import { initSshHostKeyStoreFile } from './ssh/ssh-host-key-store' -import { AgentSessionTransitionRecorder } from './stats/agent-session-transition-recorder' -import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' -import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' -import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' -import { - killAllPty, - clearProviderPtyState, - getPtyIdForPaneKey, - registerPaneKeyTeardownListener, - getLocalPtyProvider, - getSshPtyProvider, - registerHeadlessPtyRuntime, - type CodexHomeLaunchContext -} from './ipc/pty' -import { - initDaemonPtyProvider, - disconnectDaemon, - getDaemonProvider, - listLiveDaemonPtyIds, - shutdownDaemon -} from './daemon/daemon-init' -import { - type CodexPaneHomeRoute, - getCodexPaneAccount, - hasAnyRecordedLegacyWslCodexPane, - hasRecordedManagedHostCodexPane, - isCodexPaneHomeRouteProvenAwayFromSharedHome, - reconcileCodexPaneAccountsWithLivePtys -} from './codex/codex-pane-account-registry' -import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher' -import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' -import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade' -import { registerCoreHandlers } from './ipc/register-core-handlers/register-core-handlers' -import { initObservability, shutdownObservability } from './observability' -import { registerMobileHandlers } from './ipc/mobile' -import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' -import { classifyError } from './telemetry/classify-error' -import { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry' -import { - indexPersistedPaneKeyPtyIds, - isLocalExecutionHost, - resolveAgentWorkspaceExecutionHostId, - sweepRestoredSubagentsWithoutLiveAgent -} from './agent-hooks/restored-subagent-liveness-sweep' -import { - installManagedAgentHooks, - isAgentStatusHooksEnabled, - removeManagedAgentHooksAsync, - resolveStartupManagedHookAction, - shouldInstallStartupManagedAgentHook, - shouldContinueManagedHookStartup -} from './agent-hooks/managed-agent-hook-controls' -import { initCohortClassifier } from './telemetry/cohort-classifier' -import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' -import { resolveConsent } from './telemetry/consent' -import { triggerStartupNotificationRegistration } from './ipc/startup-notification-registration' -import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime' -import { ArtifactCloudService } from './artifacts/artifact-cloud-service' -import { SkillCloudService } from './skills/skill-cloud-service' -import { recoverPendingSkillTransactions } from './skills/skill-transaction-startup-recovery' -import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate' -import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity' -import { - fingerprintOrchestrationPeer, - type OrchestrationEnvironmentTransport -} from './runtime/orchestration/environment-transport' -import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-routing' -import { resolveEnvironment } from '../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../shared/runtime-environments' -import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' -import { - recordRuntimeRpcStartFailure, - showRuntimeRpcStartupFailureDialog -} from './runtime/runtime-rpc-startup-failure' -import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint' -import { ServeReadinessPublisher } from './server/serve-readiness' -import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary' -import { DesktopRelayService } from './runtime/relay/desktop-relay-service' -import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker' -import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' -import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' -import { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion' -import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n' -import { - getNextDefaultOnAppearanceSettingValue, - registerAppMenu, - rebuildAppMenu -} from './menu/register-app-menu' -import { createGpuAccelerationAboutPanelOptions } from './menu/gpu-acceleration-about-panel' -import { - checkForRemoteServerUpdate, - checkForUpdatesFromMenu, - downloadRemoteServerUpdate, - getRemoteServerUpdaterSnapshot, - installRemoteServerUpdate, - isQuittingForUpdate, - resolveUpdateInstallMode -} from './updater' -import { configureRemoteServerUpdater } from './runtime/remote-server-updater' -import type { UpdateCheckOptions } from '../shared/update-status-types' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { - installServeSupervisorDisconnectQuit, - notifyServeSupervisorReady -} from './serve-update-handoff' -import { - configureElectronNetworkCompatibility, - configureDevUserDataPath, - configureOrcaUserDataPathEnv, - disableUnsupportedChromiumFeatures, - optOutOfHiddenPageWakeUpThrottling, - enableMainProcessGpuFeatures, - installDevParentDisconnectQuit, - installDevParentSignalQuit, - installDevParentWatchdog, - isDevParentShutdownRequested, - patchPackagedProcessPath, - shouldInstallManagedHooks -} from './startup/configure-process' -import { - installUncaughtPipeErrorGuard, - installUnhandledRejectionLogging -} from './startup/main-process-error-guards' -import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' -import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' -import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' -import { - clearGpuFallbackMarker, - readActiveGpuFallbackMarker, - writeGpuFallbackMarker, - type GpuFallbackMarker, - type GpuFallbackEnvironment, - type WindowsGpuFallbackEnvironment -} from './startup/gpu-fallback-marker' -import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' -import { - DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, - DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - GpuCrashFallbackTracker, - isGpuFallbackCrashCandidate -} from './crash-reporting/gpu-crash-fallback-decision' -import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt' -import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement' -import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics' -import { - handleGpuFallbackRecoveredLaunch, - promptForGpuFallbackRecoveredLaunch -} from './crash-reporting/gpu-fallback-recovered-launch' -import { - shouldSuppressDevEducation, - suppressDevEducationForStore -} from './startup/dev-education-suppression' -import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' -import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' -import { startFirstWindowStartupServices } from './startup/first-window-startup-services' -import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery' -import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' -import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './startup/dev-instance-identity' -import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' -import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration' -import { - startWindowsDesktopBeforeShellPathReady, - type WindowsDesktopStartupServices -} from './startup/windows-desktop-shell-path-startup' -import { - acquireSingleInstanceLock, - logSingleInstanceLockBypass, - logSingleInstanceLockFailure, - shouldActivateDesktopForSecondInstance, - shouldBypassSingleInstanceLock, - shouldSkipSingleInstanceLock, - SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE -} from './startup/single-instance-lock' -import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' -import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' -import { settledDiffCache } from './git/source-control/git-read-cache-invalidation' +import { app, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' -import { SkillShareDeepLinkState } from './startup/skill-share-deep-link-state' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic, - logStartupMilestone -} from './startup/startup-diagnostics' -import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' -import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe' -import { - describeInstallDirAclPoison, - startWindowsInstallDirAclRepairIfPoisoned -} from './startup/windows-install-dir-acl-recovery' -import { presentRendererRecoveryPrompt } from './window/renderer-recovery-prompt' -import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' -import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' -import { registerServeSignalHandlers } from './startup/serve-signal-handlers' -import { - createServeDesktopActivationGate, - settleServeDesktopActivation as settleServeDesktopActivationGate -} from './startup/serve-desktop-activation' -import { RateLimitService } from './rate-limits/service' -import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' -import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' -import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' -import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home' -import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' -import { - attachMainWindowServices, - ensureAutoUpdaterConfigured -} from './window/attach-main-window-services' -import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { shutdownPairedRuntimeBrowserClientHosts } from './browser/paired-runtime-browser-client-host-runtime' -import { - getDashboardPopoutWindow, - zoomDashboardPopoutIfFocused -} from './window/dashboard-popout-window' -import { - createSystemTray, - destroySystemTray, - setMacMenuBarIconVisible, - setTrayAttention, - type SystemTrayOptions -} from './tray/system-tray' import { createMacAppActivationHandler } from './window/macos-app-activation' -import { focusExistingMainWindow, safelyRevealWindow } from './window/focus-existing-window' -import { applyBackgroundActivationPolicy } from './window/foreground-activation-policy' -import { notifyMainWindowBecameVisible } from './window/main-window-visibility' -import { CodexAccountService } from './codex-accounts/service' -import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' -import { markCodexProjectTrusted } from './agent-trust-presets' import { - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './codex-accounts/runtime-selection' -import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' -import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' -import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state' -import { - ensureRealHomeCodexHookState, - isRealHomeCodexHookLaneUsable -} from './codex/codex-real-home-hook-install' -import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry' -import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' -import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' -import { - startCodexStateDbBackfillRecoveryInBackground, - stopCodexStateDbBackfillRecoveries -} from './codex/codex-state-db-backfill-recovery' -import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' -import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume' -import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' -import { ManagedCodexHomeTemporarilyUnavailableError } from './codex-accounts/host-codex-managed-home-ownership' -import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' -import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' -import { prepareCodexSessionResume } from './codex/codex-session-resume-preparation' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' -import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' -import { getDefaultWslDistro } from './wsl' -import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash' -import { ClaudeAccountService } from './claude-accounts/service' -import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './claude-accounts/live-pty-gate' -import { StarNagService } from './star-nag/service' -import { agentHookServer, type AgentHookProviderSessionIdentity } from './agent-hooks/server' -import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation' -import { createHookStatusSessionTabsInvalidator } from './agent-hooks/hook-status-session-tabs-invalidation' -import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' -import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' -import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' -import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' -import { moveWorktree } from './git/worktree' -import { - configureWindowsHostGitEnvironmentReadiness, - setDefaultWslDistroOverride -} from './git/runner' -import { getRepoIdFromWorktreeId } from '../shared/worktree/id' -import { parseWorkspaceKey } from '../shared/workspace-scope' -import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { AgentBrowserBridge } from './browser/agent-browser-bridge' -import { configureBrowserClientPageAutomationRuntime } from './browser/browser-client-page-automation-runtime' -import { BrowserClientPageCommandError } from './browser/browser-client-page-command-failure' -import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserCertificateTrustController, browserManager } from './browser/browser-manager' -import { RpcDispatcher } from './runtime/rpc/dispatcher' -import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' -import { browserSessionRegistry } from './browser/browser-session-registry' -import { - applyBrowserSessionProxies, - setBrowserNetworkProxySettingsResolver -} from './browser/browser-session-proxy' -import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' -import { - installDocPreviewProtocolHandler, - registerDocPreviewSchemePrivileges -} from './browser/doc-preview-protocol' -import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc' -import { initializeBrowserClientHostId } from './browser/browser-client-host-id' -import { setUnreadDockBadgeCount } from './dock/unread-badge' -import { AutomationService } from './automations/service' -import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' -import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' -import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer' -import { AgentAwakeService } from './agent-awake-service' -import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' -import { registerSystemResumeBroadcast } from './system-resume-broadcast' -import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline' -import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime' -import { quitTeardownStartGate } from './quit-teardown-start-gate' -import { beginSshShutdown } from './ipc/ssh-shutdown-drain' -import { PluginService } from './plugins/plugin-service' -import { PluginKillListService } from './plugins/plugin-kill-list-service' -import { getPluginsDataDir } from './plugins/plugin-discovery' -import { PluginMarketplaceService } from './plugins/plugin-marketplace-service' -import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer' -import { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator' -import { resolveBundledPluginRoot } from './plugins/plugin-bundled-bootstrap' -import { resolvePluginHostEntryPath } from './plugins/plugin-host-process' -import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement' -import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins' -import { - normalizePluginConsents, - normalizePluginIdList -} from '../shared/plugins/plugin-consent-state' -import { - recordCoalescedCrashBreadcrumb, - recordCrashBreadcrumb -} from './crash-reporting/crash-breadcrumb-store' -import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' -import { installMainThreadHangWatchdog } from './hang-watchdog/main-thread-hang-watchdog' -import { - consumeHangDetectionMarker, - hangDetectionMarkerPath -} from './hang-watchdog/hang-detection-marker' -import { getMainProcessLifecycleIdentity } from './crash-reporting/main-process-lifecycle-identity' -import { CrashReportStore } from './crash-reporting/crash-report-store' -import { - shouldRecoverRendererAfterProcessGone, - type ExpectedTeardownScope -} from './crash-reporting/process-gone-classification' -import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' -import { startCrashpadCapture } from './crash-reporting/crashpad-capture' -import { startPreGoneProcessMetricsSampling } from './crash-reporting/process-gone-diagnostics' -import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state' -import { - advanceSyntheticTitleSpinnerEntries, - getSyntheticTitleSpinnerPaneKeyToStop, - type SyntheticTitleSpinnerEntry -} from './synthetic-title-spinner' -import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' -import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' -import { - getSyntheticAgentTitleProfile, - shouldDriveSyntheticAgentTitleFromHook, - type SyntheticAgentTitleProfile -} from '../shared/synthetic-agent-title' -import type { AgentStatusState } from '../shared/agent-status-types' -import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' -import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' -import { - HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeDesktopWindowStatus -} from '../shared/runtime-types' -import { LocalPtyProvider } from './providers/local-pty-provider' -import { KeybindingService } from './keybindings/keybinding-service' -import { - applyElectronProxySettings, - setDefaultProxySessionResolver -} from './network/proxy-settings' -import { handleElectronProxyLogin } from './network/electron-proxy-credentials' -import { installElectronProxyRequestGuard } from './network/electron-proxy-request-guard' -import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' -import { CliInstaller } from './cli/cli-installer' -import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' -import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' + focusExistingWindow as focusExistingWindowAction, + setMainWindowOpener +} from './startup/main-window-actions' +import { openMainWindow as openMainWindowController } from './startup/main-window-controller' +import { mainProcessState as state } from './startup/main-process-state' +import { runMainProcessPreflight } from './startup/main-process-preflight' +import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-bootstrap' +import { initializeMainProcessReady } from './startup/main-process-ready' +import { installMainProcessQuitHandlers } from './startup/main-process-quit' +import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' -let mainWindow: BrowserWindow | null = null -/** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ -let isQuitting = false -let store: Store | null = null -let stats: StatsCollector | null = null -let claudeUsage: ClaudeUsageStore | null = null -let codexUsage: CodexUsageStore | null = null -let openCodeUsage: OpenCodeUsageStore | null = null -let codexAccounts: CodexAccountService | null = null -let codexRuntimeHome: CodexRuntimeHomeService | null = null -let codexSessionMigration: ReturnType | null = null -let claudeAccounts: ClaudeAccountService | null = null -let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null -let runtime: OrcaRuntimeService | null = null -let rateLimits: RateLimitService | null = null -let runtimeRpc: OrcaRuntimeRpcServer | null = null -const serveReadinessPublisher = new ServeReadinessPublisher() -let desktopRelayService: DesktopRelayService | null = null -let desktopRelayStatus: RelayBrokerStatus = 'offline' -let pendingUnpairedDeviceAuthFailure = false -// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support). -let headlessBrowserDisplayAvailable = false - -let starNag: StarNagService | null = null -let agentAwakeService: AgentAwakeService | null = null -let crashReports: CrashReportStore | null = null -let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null -let unsubscribeSystemResumeBroadcast: (() => void) | null = null -let watcherShutdownPromise: Promise | null = null -let watcherShutdownDone = false -let automations: AutomationService | null = null -let pluginService: PluginService | null = null -let pluginKillListService: PluginKillListService | null = null -let pluginMarketplaceService: PluginMarketplaceService | null = null -let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null -let keybindings: KeybindingService | null = null - -function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { - pluginService?.emitEvent( - event.kind === 'created' ? 'worktree.created' : 'worktree.removed', - event.kind === 'created' - ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } - : { worktreeId: event.worktreeId, path: event.path } - ) -} -// Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). -const expectedRendererReload = createWebContentsTimedFlag() -const recoveryReloadInFlight = createWebContentsTimedFlag() -// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. -const pendingOpenSettings = createWebContentsTimedFlag() -const skillShareDeepLinks = new SkillShareDeepLinkState() -let firstWindowStartupServicesReady: Promise = Promise.resolve() -let managedWslCliReconciliationReady: Promise = Promise.resolve() -let managedWslCliStartupBarrierReady: Promise = Promise.resolve() -// Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). -let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled' -const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ - windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD -}) -let activeGpuFallbackMarker: GpuFallbackMarker | null = null -let gpuFallbackActiveThisLaunch = false -let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null -const gpuCrashDiagnostics = - process.platform === 'win32' - ? new GpuCrashDiagnosticsRecorder({ - provider: { - getGPUInfo: (infoType) => app.getGPUInfo(infoType), - getGPUFeatureStatus: () => app.getGPUFeatureStatus() - }, - recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) - }) - : null -let localPtyStartupReady: Promise = Promise.resolve() -let localPtyProviderStartupReady: Promise = Promise.resolve() -const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 - -function handleCodexHomePtySpawned(args: { - id: string - codexHomePath: string | null - reattached?: boolean - reattachedHomeRoute?: CodexPaneHomeRoute | null - launchEnv?: NodeJS.ProcessEnv - startedAt?: Date - startedSequence?: number -}): void { - // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. - if (args.reattached && args.startedSequence !== undefined) { - const paneAccount = getCodexPaneAccount(args.id) - const homeRoute = - args.reattachedHomeRoute !== undefined - ? (args.reattachedHomeRoute ?? undefined) - : paneAccount?.homeRoute - if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { - codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) - return - } - } - const fullScanRequired = - codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { - reattached: args.reattached, - launchEnv: args.launchEnv - }) ?? null - if (fullScanRequired !== null) { - codexSessionMigration?.beginLaunch( - args.id, - args.reattached === true || fullScanRequired, - args.startedAt, - args.startedSequence - ) - } +function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + return openMainWindowController(options) } -function handlePtyExit(id: string, exitSequence: number): void { - codexSessionMigration?.finishLaunch(id, exitSequence) -} -// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. -// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. -// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its -// command-name lookup finds a port number and strands the launch in an in-process serve. The -// packaged-CLI one matches on the entry path instead, so order cannot affect it either way. -const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (packagedCliEntryRedirect.redirected) { - app.exit(packagedCliEntryRedirect.status) -} -const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (appImageCliRedirect.redirected) { - app.exit(appImageCliRedirect.status) -} -// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the -// Electron process without the CLI rewrite that injects `--serve` (#12677). -// Guarded so a normal GUI launch keeps its original argv array identity. -if (argvRequestsServeMode(process.argv)) { - process.argv = normalizeServeModeArgv(process.argv) -} -const isServeMode = process.argv.includes('--serve') - -function updateGpuAccelerationAboutPanel(): void { - app.setAboutPanelOptions( - createGpuAccelerationAboutPanelOptions({ - appName: app.name, - appVersion: app.getVersion(), - platform: process.platform, - gpuFallbackActive: gpuFallbackActiveThisLaunch, - gpuFeatureStatus - }) - ) -} - -app.on('gpu-info-update', () => { - gpuFeatureStatus = app.getGPUFeatureStatus() - gpuCrashDiagnostics?.warm() - if (app.isReady()) { - updateGpuAccelerationAboutPanel() - } -}) -if (isServeMode) { - reserveServeStdoutForReadiness() -} -const desktopActivationGate = createServeDesktopActivationGate({ - initialState: isServeMode ? 'initializing' : 'ready', - activateWindow: () => { - // Why: an updater replacement must not resurrect the old app bundle. - if (!isQuittingForUpdate()) { - focusExistingWindow() - } - }, - onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) -}) - -// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. -const ENABLE_FIRST_WORK_FOLDER_RENAME = false - -// Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. -function maybeAutoRenameBranchOnFirstWorkFromHook(event: { - paneKey: string - tabId: string | undefined - worktreeId: string | undefined - payload: { state: string; prompt?: string; lastAssistantMessage?: string } - isReplay: boolean | undefined -}): void { - const currentStore = store - const currentRuntime = runtime - if (!currentStore || !currentRuntime) { - return - } - void maybeAutoRenameBranchOnFirstWork( - { - paneKey: event.paneKey, - tabId: event.tabId, - worktreeId: event.worktreeId, - state: event.payload.state, - prompt: event.payload.prompt, - assistantMessage: event.payload.lastAssistantMessage, - isReplay: event.isReplay - }, - { - getSettings: () => currentStore.getSettings(), - getRepo: (repoId) => currentStore.getRepo(repoId), - getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(), - getCurrentDisplayName: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name - } - return currentStore.getWorktreeMeta(worktreeId)?.displayName - }, - getFolderWorkspacePath: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath - : undefined - }, - isPendingFirstAgentMessageRename: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return ( - currentStore.getFolderWorkspace(scope.folderWorkspaceId) - ?.pendingFirstAgentMessageRename === true - ) - } - return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true - }, - canRenameOrcaCreatedBranch: (worktreeId) => { - const meta = currentStore.getWorktreeMeta(worktreeId) - // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. - return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true - }, - setDisplayName: (worktreeId, displayName) => { - rememberBranchRenameFailureOutput(worktreeId, null) - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - name: displayName, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentStore.setWorktreeMeta(worktreeId, { - displayName, - // The first-agent title is an intentional user-facing label; keep it stable after the - // generated branch is renamed and across subsequent catalog refreshes. - displayNameIsPinned: true, - pendingFirstAgentMessageRename: false, - // Success clears the failure badge (redundant with the explicit setRenameError(null)). - firstAgentMessageRenameError: null - }) - }, - renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME - ? (worktreeId, newLeaf) => - renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getSettings: () => currentStore.getSettings(), - migrateWorktreeIdentity: (oldId, newId) => - currentStore.migrateWorktreeIdentity(oldId, newId), - notifyWorktreeRenamed: (repoId, oldId, newId) => - currentRuntime.notifyWorktreeFolderRenamed(repoId, oldId, newId), - pathExists: async (candidate) => existsSync(candidate), - moveWorktree - }) - : undefined, - setRenameError: (worktreeId, error, failureOutput) => { - // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. - rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) - // Skip the write + push when unchanged — most settled worktrees never had an error to clear. - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const current = currentStore.getFolderWorkspace( - scope.folderWorkspaceId - )?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - firstAgentMessageRenameError: error - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) - // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. - currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) - }, - resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId), - onRenamed: (repoIdOrWorktreeId) => { - if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId) - } - } - ) -} - -const devInstanceIdentity = getDevInstanceIdentity(is.dev) -const devAgentHookEndpointNamespace = devInstanceIdentity.isDev - ? devInstanceIdentity.appUserModelId - : undefined - -installUncaughtPipeErrorGuard() -// Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). -installUnhandledRejectionLogging() -// Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. -process.env.ORCA_APP_VERSION = app.getVersion() -configureRemoteServerUpdater({ - getSnapshot: getRemoteServerUpdaterSnapshot, - check: checkForRemoteServerUpdate, - download: downloadRemoteServerUpdate, - install: installRemoteServerUpdate -}) -patchPackagedProcessPath() -// Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. -if (app.isPackaged && process.platform !== 'win32') { - void hydrateShellPath().then((result) => { - if (result.ok) { - mergePathSegments(result.segments) - return - } - // Why: on failure the seeded fallbacks stay in front. For an nvm user that is - // now their `default` version rather than the newest install, so it is usually - // survivable — but it is still not what their shell would have resolved. Name - // the reason so it shows up in a log bundle instead of as a missing CLI. - console.warn( - `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` - ) - }) -} -configureDevUserDataPath(is.dev) -configureOrcaUserDataPathEnv() -// Why these four lines are one step (#16761): the two above decide where userData lives, and -// everything below may resolve a path. Installing the accessor any later leaves a window where an -// early resolve either throws — which is what killed `orca serve` — or, worse, memoizes the -// pre-override directory and silently writes user state to the wrong place for the whole session. -// Safe this early: ElectronAppEnvironment holds no state and calls `app` lazily per accessor, so it -// changes no timing, and initDataPath only joins strings. -setAppEnvironment(new ElectronAppEnvironment()) -// Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) -// changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. -initDataPath() - -// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon. -const TRAY_CREATE_FALLBACK_MS = 12_000 - -const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('before-single-instance-lock', { - version: app.getVersion(), - packaged: app.isPackaged, - platform: process.platform, - osRelease: os.release(), - userData: app.getPath('userData'), - e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - }) - startEventLoopStallProbe() -} -// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). -// Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache -// look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. -startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) +setMainWindowOpener(openMainWindow) function focusExistingWindow(): void { - focusExistingMainWindow({ - app, - getWindow: () => mainWindow, - openWindow: openMainWindow, - warn: console.warn - }) + focusExistingWindowAction() } function requestDesktopActivation(argv: readonly string[] = []): void { - skillShareDeepLinks.capture(argv, (shareId) => { - mainWindow?.webContents.send('ui:openSkillShare', shareId) + state.skillShareDeepLinks.capture(argv, (shareId) => { + state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) - // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return } - desktopActivationGate.requestActivation() + state.desktopActivationGate?.requestActivation() } -app.on('open-url', (event, url) => { - if (!parseSkillShareId(url)) { - return - } - event.preventDefault() - requestDesktopActivation([url]) -}) - -skillShareDeepLinks.capture(process.argv) - const handleMacAppActivation = createMacAppActivationHandler({ - getWindow: () => mainWindow, + getWindow: () => state.mainWindow, requestActivation: requestDesktopActivation }) -function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { - const state = desktopActivationGate.getState() - return state === 'ready' ? 'openable' : state -} - -function settleServeDesktopActivation(): void { - settleServeDesktopActivationGate(desktopActivationGate, { - hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) - }) -} - -// Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals. -function createWebContentsTimedFlag(defaultDurationMs = 10_000): { - mark: (webContentsId: number, durationMs?: number) => void - clear: (webContentsId?: number) => void - matches: (webContentsId: number, options?: { consume?: boolean }) => boolean -} { - let state: { webContentsId: number; until: number } | null = null - return { - mark(webContentsId, durationMs = defaultDurationMs) { - state = { webContentsId, until: Date.now() + durationMs } - }, - clear(webContentsId) { - if (webContentsId === undefined || state?.webContentsId === webContentsId) { - state = null - } - }, - matches(webContentsId, options) { - if (!state || Date.now() > state.until) { - state = null - return false - } - if (state.webContentsId !== webContentsId) { - return false - } - if (options?.consume) { - state = null - } - return true - } - } -} - -function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { - expectedRendererReload.mark(webContentsId, durationMs) -} - -function clearExpectedRendererReload(webContentsId?: number): void { - expectedRendererReload.clear(webContentsId) -} - -function getExpectedTeardownScope( - webContentsId?: number, - includeSystemSessionEnd = true -): ExpectedTeardownScope { - return resolveExpectedTeardownScope({ - isQuitting, - isQuittingForUpdate: isQuittingForUpdate(), - isExpectedRendererReload: - webContentsId !== undefined && expectedRendererReload.matches(webContentsId), - includeSystemSessionEnd - }) -} - -function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { - recoveryReloadInFlight.mark(webContentsId, durationMs) -} - -function isRecoveryReloadInFlight(webContentsId: number): boolean { - // Why: consume on read — the recovery reload fires exactly one did-finish-load, so a later genuine reload still sweeps orphaned PTYs. - return recoveryReloadInFlight.matches(webContentsId, { consume: true }) -} - -function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void { - // Why: hook pings arrive many times/sec; coalesce so identical state pings don't fill all 30 breadcrumbs, leaving room for renderer errors. - recordCoalescedCrashBreadcrumb({ - name: 'agent_state_changed', - data: { agentType, state }, - coalesceKey: `agent:${agentType}:${state}`, - minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS - }) -} - -// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. -// Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). -const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ - isDev: is.dev, - isServeMode -}) -const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ - isDev: is.dev, - isServeMode -}) -if (bypassSingleInstanceLock) { - // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. - logSingleInstanceLockBypass() -} -const hasSingleInstanceLock = skipSingleInstanceLock - ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, requestDesktopActivation) -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('single-instance-lock-result', { - acquired: hasSingleInstanceLock, - bypassed: bypassSingleInstanceLock, - skippedForDev: skipSingleInstanceLock - }) -} -if (!hasSingleInstanceLock) { - // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. - logSingleInstanceLockFailure() - // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). - app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) -} - -// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. -if (hasSingleInstanceLock) { - // Why first in this block: the accessor throws until installed and everything below may read a - // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so - // installing here changes no timing, in particular not the pre-ready Keychain service-name - // resolution. The app-environment port and the userData capture install earlier still, next to - // the path decision they depend on. - setSecretStore(new ElectronSecretStore()) - // Why at process level, not per-window: pty.ts registers against injected surfaces so - // it can load without electron, and an Electron main process always has ipcMain — - // whether a window exists is irrelevant. Installing this in attachMainWindowServices - // meant `orca serve` registered its PTY handlers against no-ops before any window - // attached, so a paired desktop owner never received them. - setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) - // Why also at process level: the runtime's notification, window-lookup and - // tab-create-reply channel are desktop-only. A Node host installs none and the - // runtime routes notifications to paired clients instead. - setRuntimeDesktopSurface(electronRuntimeDesktopSurface) - // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser - // cluster into the graph. The desktop installs it; a Node host installs none and every - // browser RPC rejects, which capability filtering already tells clients about. - setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) - // Why here: proxy-settings only needed electron for `session.defaultSession`. The - // desktop supplies it; a Node host has no Chromium proxy config to consult, so the - // environment variables are the whole answer there. - setDefaultProxySessionResolver(() => session.defaultSession) - // Why here: integrations use Chromium's network stack on the desktop. A Node host - // falls back to the platform default, which is a real behavioural difference (proxy - // read from the environment, Node's user agent) rather than a transparent swap. - setMainHttpClient(electronHttpClient) - // Why here: constructing the speech services is what pulls Electron's streaming net - // request in. A host without them rejects speech calls rather than pretending. - setSpeechServiceFactories(electronSpeechServiceFactories) - setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) - // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. - const shouldCoupleToDevParent = is.dev && !isServeMode - installDevParentDisconnectQuit(shouldCoupleToDevParent) - installDevParentWatchdog(shouldCoupleToDevParent) - installDevParentSignalQuit(shouldCoupleToDevParent) - // Why not at module scope with the other lifetime couplings (#16761): this resolves the handoff - // path, so it throws until setAppEnvironment() above installs the accessor — which killed every - // `orca serve` process before it could listen. After initDataPath() specifically, so the - // path-equality check against the CLI's env var uses the dir captured before app.setName(). - // Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module - // finishes evaluating, so moving this behind an await would open a real orphan window. - installServeSupervisorDisconnectQuit(isServeMode) - // Why here: initDataPath above gives the canonical userData path for the record file; the write - // itself lands for the next launch (see macos-press-and-hold-default.ts). - applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) - // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. - initSessionParseCachePersistence({ - filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), - appVersion: app.getVersion() - }) - initOrcaProfilePaths() - // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. - initStatsPath() - initClaudeUsagePath() - initCodexUsagePath() - initOpenCodeUsagePath() - // Why: Electron resolves the macOS safeStorage Keychain service name - // (" Safe Storage") before `ready`, so the setName in whenReady is - // too late to move it — dev otherwise lands on the package.json name. Dev-only - // so a packaged build keeps deriving the key from its own CFBundleName. - // Safe here: dev always pins userData via app.setPath (configure-process.ts), - // so setName cannot shift the paths captured just above. - if (shouldApplyPreReadyAppName(devInstanceIdentity)) { - app.setName(devInstanceIdentity.appName) - } - // Why: Electron freezes the privileged scheme table at ready, so the doc-preview - // scheme must be declared here or its webview loses fetch/secure-origin privileges. - registerDocPreviewSchemePrivileges() - // Why: must precede app.whenReady() so Crashpad is installed before the - // first renderer spawns; a CHECK before this point is still exit-code-only. - startCrashpadCapture() - crashReports = CrashReportStore.fromUserData() - recordCrashBreadcrumb('app_started', { - packaged: app.isPackaged, - platform: process.platform, - ...getMainProcessLifecycleIdentity() - }) - disableUnsupportedChromiumFeatures() - // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. - optOutOfHiddenPageWakeUpThrottling() - configureElectronNetworkCompatibility() - enableRendererHeapHeadroom() - maybeApplyGpuFallbackForThisLaunch() - if (!gpuFallbackActiveThisLaunch) { - enableMainProcessGpuFeatures() - } - // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. - headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode }) -} - -ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) +const preflightReady = runMainProcessPreflight({ + focusExistingWindow, + requestDesktopActivation }) -ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) - await runtime?.prepareStructuredAgentSessionStartupRestoration() -}) - -ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => - recoverLegacyWorkerTerminalsForRendererStartup({ - firstWindowStartupServicesReady, - managedWslCliStartupBarrierReady, - localPtyProviderStartupReady, - reconcile: async () => { - await runtime?.refreshRestoredOrchestrationAuthority() - return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) - }, - onDeferredRecoveryError: (error) => { - console.warn('[orchestration] legacy worker provider-ready recovery failed', error) - } - }) -) - -// Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. -ipcMain.handle('ui:consumePendingOpenSettings', (event) => - pendingOpenSettings.matches(event.sender.id, { consume: true }) -) - -ipcMain.handle('ui:consumePendingSkillShare', () => { - return skillShareDeepLinks.consume() -}) - -ipcMain.handle( - 'app:startupDiagnostic', - (_event, event: string, details?: Record) => { - if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) { +if (preflightReady) { + app.on('open-url', (event, url) => { + if (!parseSkillShareId(url)) { return } - logStartupMilestone(event, details && typeof details === 'object' ? details : {}) - } -) - -/** A PTY that dies while Orca is down never runs the teardown that clears pane - * state, so hydrate can rebuild a Claude subagent roster that no later hook can - * retire — pinning the pane 'working' and locking its agent out of hibernation - * for good. Once provider and hook hydration settle, targeted PTY liveness can - * retire only rows whose local owner is proven gone. */ -async function reapRestoredSubagentsWithoutLiveAgent(): Promise { - const currentStore = store - if (!currentStore) { - return - } - const provider = getDaemonProvider() - if (!provider) { - return - } - const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( - currentStore.getWorkspaceSession().terminalLayoutsByTabId ?? {} - ) - await sweepRestoredSubagentsWithoutLiveAgent({ - probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), - isLocalExecutionHost: (worktreeId) => - isLocalExecutionHost( - resolveAgentWorkspaceExecutionHostId(worktreeId, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getWorktreeMeta: (resolvedWorktreeId) => currentStore.getWorktreeMeta(resolvedWorktreeId), - getFolderWorkspace: (folderWorkspaceId) => - currentStore.getFolderWorkspace(folderWorkspaceId), - getProjectGroups: () => currentStore.getProjectGroups() - }) - ), - getBoundPtyIdForPaneKey: getPtyIdForPaneKey, - getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), - reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => - agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalHost, - isLocalPaneAgentLive, - isLocalPaneLivenessEvidenceCurrent - ) + event.preventDefault() + requestDesktopActivation([url]) }) -} - -function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { - logStartupMilestone('first-window-startup-services-start') - const startupServices = startFirstWindowStartupServices({ - // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. - startDaemonPtyProvider: async (signal) => { - logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) - // Why: only GUI-spawned macOS daemons watch for login-session death; a headless - // serve daemon must survive its spawning session ending (SSH disconnect). - await initDaemonPtyProvider(signal, { - macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode - }) - // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. - const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() - if (codexRuntimeHome && (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane())) { - const livePtyIds = await listLiveDaemonPtyIds() - if (livePtyIds) { - reconcileCodexPaneAccountsWithLivePtys(livePtyIds) - const settings = store?.getSettings() - // Why (#16441): each retained home can run a codex app-server grant - // session. Awaiting them here delayed the first window by N sessions; - // a retained shell cannot invoke Codex before this provider serves. - if (hasRetainedManagedHostPane) { - void reconcileRetainedCodexHookHomes({ - hookService: codexHookService, - hooksEnabled: - isAgentStatusHooksEnabled(settings) && - settings?.disabledTuiAgents.includes('codex') !== true, - runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) - }).catch((error: unknown) => { - console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) - }) - } - } - } - // Why: retained shells can invoke Codex immediately after the startup gate. - codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() - logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) - }, - // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. - startAgentHookServer: async () => { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) - // Why (#11217): the hook listener fails open on every request error, so an IDS resetting - // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. - // Log + telemetry (the daemon_start_failed pattern) so it is diagnosable without a packet capture. - agentHookServer.setTransportInterferenceListener((report) => { - track('agent_hook_transport_blocked', { count: report.count }) - }) - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) - }, - onDaemonError: (error) => { - // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). - const reason = error instanceof Error ? error.message : String(error) - console.error( - `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` - ) - track('daemon_start_failed', classifyError(error)) - }, - onAgentHookServerError: (error) => { - // Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails. - console.error('[agent-hooks] Failed to start local hook server:', error) - } - }) - void startupServices.firstWindowReady.then(() => { - logStartupMilestone('first-window-startup-services-ready') - }) - void startupServices.localPtyReady.then(() => { - logStartupMilestone('local-pty-startup-ready') - void reapRestoredSubagentsWithoutLiveAgent().catch((error) => { - console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) + state.skillShareDeepLinks.capture(process.argv) + registerMainProcessIpcHandlers() + installMainProcessQuitHandlers() + void app.whenReady().then(async () => { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation }) }) - return startupServices } - -function bindTerminalRuntimeStartupServices( - services: Promise -): void { - firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) - localPtyStartupReady = services.then((value) => value.localPtyReady) - localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) -} - -async function prepareCodexRuntimeHomeForLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - launchContext?: CodexHomeLaunchContext -): Promise { - if ( - target?.runtime !== 'wsl' && - launchContext?.launchAgent === 'codex' && - launchContext.workspacePath - ) { - try { - // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. - await markCodexProjectTrusted(launchContext.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) - } - } - const ensureRealHomeHooksIfSelected = async (): Promise => { - if ( - target?.runtime === 'wsl' || - !codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv) - ) { - return false - } - // Why (flag ON, system default): the hook entry must exist — appended last - // and trusted by codex's own app-server grant — in the real ~/.codex before - // the pane spawns. An incapable grant flips the lane gate so the launch - // below falls back to the managed home instead of a status-blind pane. - await ensureRealHomeCodexHookState({ - hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()), - userDataPath: app.getPath('userData') - }) - return true - } - let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — - // the fallbacks below all key off `null`, which means "system default", so - // swallowing the refusal would launch the wrong account (#STA-4422). - let runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - if (runtimeHomePath === null && !realHomeHooksPrepared) { - // Why: launch prep can reject an untrusted managed home and clear its - // selection. Establish hook capability for that newly selected lane, then - // re-resolve if the capability gate rejects it. - realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - if (realHomeHooksPrepared) { - runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - } - } - if (runtimeHomePath === null && target?.runtime !== 'wsl') { - // Why: Codex runs on the user's real ~/.codex; the managed-home hook - // install below would target a home Codex never reads on this lane. - return null - } - const hookTarget = - target?.runtime === 'wsl' - ? { - runtime: 'wsl' as const, - wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() - } - : target - const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) - try { - // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. - const status = await codexHookService.prepareRuntimeHomeForLaunch( - runtimeHomePath, - hookTarget, - hooksEnabled - ) - if (status.state === 'error') { - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - status.detail - ) - } - } catch (error) { - // Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting. - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - error - ) - } - return runtimeHomePath -} - -async function prepareCodexSessionResumeForLaunch(args: { - providerSession: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string -}): Promise { - if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { - return null - } - const systemHomePath = getSystemCodexHomePath() - // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. - const trustedHomes = [ - systemHomePath, - ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() - ] - const settingsStore = store - // Why: resolved eagerly, once, before any ranking or provenance match. The - // marker read used to be deferred into the ranking thunk so a - // provenance-present resume never paid for it, but that optimisation let an - // unreadable selected home reach the PTY as "no selection": the provenance - // branch simply omits the account from `trustedHomes` and another account's - // readable alias wins. A throw here refuses the whole resume instead - // (#STA-4422). - const selectedAccountCodexHome = - codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume() - // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is - // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). - const preparation = await prepareCodexSessionResume({ - sessionId: args.providerSession.id, - transcriptPath: args.providerSession.transcriptPath, - trustedCodexHomes: trustedHomes, - // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; - // rank it by the current selection so settings insertion order can never decide the account. - getSelectedAccountCodexHome: () => selectedAccountCodexHome, - systemCodexHomePath: systemHomePath, - // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must - // outrank the path-sorted account homes or a system-default selection resumes as an account. - sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), - resolveVerifiedResumeHome: async (sessionSource) => { - let migrated = { useRealCodexHome: false } - try { - migrated = await prepareLegacySharedCodexSessionResume( - { - agent: 'codex', - executionHostId: 'local', - filePath: sessionSource.transcriptPath, - codexHome: sessionSource.homePath - }, - { - isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), - systemCodexHomePath: systemHomePath - } - ) - } catch (error) { - // Why: this launch path pins CODEX_HOME to the account that OWNS the - // rollout and deliberately refuses to repin onto whichever account is - // selected now (#10793), so it does not wire - // getSelectedHostAccountCodexHomePath and this branch cannot fire today. - // It stays as a contract guard: the blanket catch below must never - // silently swallow a typed refusal if that ever changes. - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - throw error - } - // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. - console.warn( - '[codex-session-resume] Legacy rollout migration failed; using origin home:', - error - ) - } - const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath - - if (args.workspacePath) { - try { - await markCodexProjectTrusted(args.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) - } - } - const isSystemHome = - normalizeRuntimePathForComparison(resumeHome) === - normalizeRuntimePathForComparison(systemHomePath) - const hooksEnabled = isAgentStatusHooksEnabled(settingsStore.getSettings()) - try { - if (isSystemHome) { - await ensureRealHomeCodexHookState({ - hooksEnabled, - userDataPath: app.getPath('userData') - }) - } else if (hooksEnabled) { - await codexHookService.installForLaunchPrep(resumeHome) - } else { - await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome) - } - } catch (error) { - // Why: hook repair is best-effort; session provenance must still win over the currently selected home. - console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) - } - return resumeHome - } - }) - return preparation.outcome === 'resume' - ? { - ...preparation, - reconcileSharedRuntimeAuth: - normalizeRuntimePathForComparison(preparation.codexHomePath) === - normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) - } - : preparation -} - -// Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down. -function showMainWindowFromTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - safelyRevealWindow(mainWindow) - return - } - if (!isQuittingForUpdate()) { - openMainWindow() - } -} - -function openSettingsFromSystemMenu(): void { - showMainWindowFromTray() - const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null - if (!targetWindow) { - return - } - recordCrashBreadcrumb('settings_opened') - - // Why: no signal proves the renderer listener is attached — push, and also leave a one-shot intent the unmounted renderer pulls at mount. - targetWindow.webContents.send('ui:openSettings') - // Why: untimed — any TTL can be outrun by a slow cold start; id-scoping + consume-on-read still prevent leaking to a later renderer. - pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) -} - -function quitFromSystemTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a hidden session may veto shutdown with a save/discard prompt, so make the window visible. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the 'close' handler tears down instead of re-hiding to tray. - isQuitting = true - app.quit() -} - -// Why: menu/tray are clickable before anything else configures the updater. -function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { - ensureAutoUpdaterConfigured() - checkForUpdatesFromMenu(options) -} - -function getSystemTrayOptions(): SystemTrayOptions | null { - if (!store) { - return null - } - return { - appIcon: store.getSettings().appIcon, - isDevInstance: devInstanceIdentity.isDev, - devInstanceLabel: devInstanceIdentity.devLabel, - onOpen: showMainWindowFromTray, - onOpenSettings: openSettingsFromSystemMenu, - onCheckForUpdates: () => { - // Why: updater status renders in the main window, so a bare check would complete invisibly. - showMainWindowFromTray() - runUserInitiatedUpdateCheck() - }, - onQuit: quitFromSystemTray - } -} - -function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { - if (process.platform !== 'darwin' || isServeMode) { - return null - } - const options = getSystemTrayOptions() - return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null -} - -function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { - logStartupMilestone('open-main-window-start') - if (!store) { - throw new Error('Store must be initialized before opening the main window') - } - if (!runtime) { - throw new Error('Runtime must be initialized before opening the main window') - } - if (!stats) { - throw new Error('Stats must be initialized before opening the main window') - } - if (!claudeUsage) { - throw new Error('Claude usage store must be initialized before opening the main window') - } - if (!codexUsage) { - throw new Error('Codex usage store must be initialized before opening the main window') - } - if (!openCodeUsage) { - throw new Error('OpenCode usage store must be initialized before opening the main window') - } - if (!rateLimits) { - throw new Error('Rate limit service must be initialized before opening the main window') - } - if (!automations) { - throw new Error('Automation service must be initialized before opening the main window') - } - if (!codexAccounts) { - throw new Error('Codex account service must be initialized before opening the main window') - } - if (!codexRuntimeHome) { - throw new Error('Codex runtime home service must be initialized before opening the main window') - } - if (!claudeAccounts) { - throw new Error('Claude account service must be initialized before opening the main window') - } - if (!claudeRuntimeAuth) { - throw new Error( - 'Claude runtime auth service must be initialized before opening the main window' - ) - } - if (!keybindings) { - throw new Error('Keybinding service must be initialized before opening the main window') - } - - // Why: Chromium's BrowserWindow ctor resets userData to a Protected DACL, breaking writes; re-grant ACEs (marker-gated to avoid a ~60s startup stall). - if (process.platform === 'win32') { - logStartupMilestone('acl-grant-start') - ensureWindowsUserDataAclGrant(app.getPath('userData'), { - onDone: (result) => { - logStartupMilestone('acl-grant-done', { mode: result.mode }) - if (result.mode === 'failed') { - console.warn('[win32-acl] userData ACL grant failed:', result.reason) - } - } - }) - // Why here: read-only, and the install DACL is the one thing a 0x80000003 - // child death cannot tell us about itself. See electron/electron#51761. - probeWindowsInstallDirAcl({ - isServeMode, - onDone: (data) => - startWindowsInstallDirAclRepairIfPoisoned(data, { - isServeMode, - userDataPath: app.getPath('userData'), - appVersion: app.getVersion() - }) - }) - } - - const window = createMainWindow(store, { - getIsQuitting: () => isQuitting, - onQuitAborted: () => { - isQuitting = false - clearExpectedRendererReload() - }, - onRendererProcessGone: (details, webContentsId) => { - recordProcessGoneCrash( - 'renderer', - 'renderer', - details.reason, - details.exitCode ?? null, - { - processType: 'renderer' - }, - webContentsId - ) - }, - shouldRecoverRenderer: (details, webContentsId) => - shouldRecoverRendererAfterProcessGone({ - reason: details.reason, - expectedTeardown: getExpectedTeardownScope(webContentsId, false) - }), - onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { - recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { - reason: details.reason, - exitCode: details.exitCode ?? null, - recentRecoveryCount - }) - void showRendererRecoveryPrompt(recentRecoveryCount) - }, - deferLoad: true, - ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), - title: devInstanceIdentity.name, - getKeybindings: () => keybindings?.getOverrides(), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - // Why: the recovery reload re-fires did-finish-load; flag it so the local-PTY orphan sweep skips that reload (#5787). - onBeforeRecoveryReload: (webContentsId) => { - markRecoveryReloadInFlight(webContentsId) - recordDurableCrashBreadcrumb('renderer_recovery_reload') - } - }) - recordCrashBreadcrumb('main_window_created') - logStartupMilestone('window-created') - // Why: Windows Tray construction can block synchronously on Shell_NotifyIcon, so both platforms defer creation to after first paint. - let trayCreated = false - const createSystemTrayDeferred = (): void => { - if (trayCreated || window.isDestroyed() || isQuitting || !store) { - return - } - trayCreated = true - if (process.platform === 'darwin') { - // Why: route through syncMacMenuBarIcon so startup and the live toggle share one serve-mode/visibility policy. - if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { - logStartupMilestone('tray-created') - } - return - } - const options = getSystemTrayOptions() - if (options && createSystemTray(options)) { - logStartupMilestone('tray-created') - } - } - window.once('ready-to-show', () => { - logStartupMilestone('ready-to-show') - setImmediate(createSystemTrayDeferred) - }) - window.once('show', () => { - logStartupMilestone('window-shown') - void presentGpuFallbackRecoveredLaunchPrompt(window) - }) - const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) - trayCreateFallback.unref?.() - - // Why: telemetry-plan.md anchors default-on app_opened to the first main-window load; this path fires only once consent is already enabled. - const rendererWebContentsId = window.webContents.id - const onFirstWindowLoad = (): void => { - clearExpectedRendererReload(rendererWebContentsId) - recordCrashBreadcrumb('main_window_loaded') - logStartupMilestone('did-finish-load') - if (!store) { - return - } - const consent = resolveConsent(store.getSettings()) - if (consent.effective !== 'enabled') { - return - } - trackAppOpenedOnce() - } - window.webContents.on('did-finish-load', onFirstWindowLoad) - - registerCoreHandlers( - store, - runtime, - stats, - claudeUsage, - codexUsage, - openCodeUsage, - codexAccounts, - claudeAccounts, - rateLimits, - rendererWebContentsId, - automations, - { - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }, - agentAwakeService ?? undefined, - crashReports ?? undefined, - keybindings, - { - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - onBeforeRelaunch: async () => { - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) - }, - onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(), - onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow() - }, - pluginService ?? undefined, - pluginMarketplaceService && pluginMarketplaceInstaller - ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller } - : undefined - ) - automations.setWebContents(window.webContents) - automations.start() - attachMainWindowServices( - window, - store, - runtime, - prepareCodexRuntimeHomeForLaunch, - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - { - prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, - awaitLocalPtyStartup: () => localPtyStartupReady, - awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady, - onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { - if (window.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) - }, - // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). - isRecoveryReloadInFlight, - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), - updateInstallMode: resolveUpdateInstallMode(isServeMode), - onWorktreeLifecycle: emitPluginWorktreeLifecycle - } - ) - // Why: attach the durable renderer pull now, but launch the diagnostic process after first paint. - initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) - rateLimits.attach(window) - // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. - rateLimits.start({ fetchImmediately: false }) - window.on('closed', () => { - if (mainWindow === window) { - mainWindow = null - } - clearExpectedRendererReload(rendererWebContentsId) - automations?.setWebContents(null) - // Why: detach the hook listener on close so the server never fires into destroyed webContents before reopen, and replay runs only on deliberate recreations. - agentHookServer.setListener(null) - agentHookServer.setPaneStatusClearListener(null) - setMigrationUnsupportedPtyListener(null) - // Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes. - stopAllSyntheticTitleSpinners() - }) - mainWindow = window - window.on('show', resumeSyntheticTitleSpinnerTimer) - window.on('restore', resumeSyntheticTitleSpinnerTimer) - window.on('hide', stopSyntheticTitleSpinnerTimer) - window.on('minimize', stopSyntheticTitleSpinnerTimer) - // Why: visibility-gated pollers (SSH port scanner) park while hidden and resume on this signal; re-wired per window since dock re-activation recreates it. - window.on('show', notifyMainWindowBecameVisible) - window.on('restore', notifyMainWindowBecameVisible) - // Why: user is back on show/restore, so clear the tray attention dot set while hidden (see notifications.ts). - window.on('show', () => setTrayAttention(false)) - window.on('restore', () => setTrayAttention(false)) - agentHookServer.setListener( - ({ - paneKey, - tabId, - worktreeId, - connectionId, - payload, - receivedAt, - stateStartedAt, - launchToken, - providerSession, - providerSessionOnly, - promptInteractionKey, - restoredUnconfirmed, - observation, - isReplay - }) => { - if (mainWindow?.isDestroyed()) { - return - } - if (providerSessionOnly) { - // Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI. - mainWindow?.webContents.send('agentStatus:set', { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(observation ? { observation } : {}), - providerSessionOnly: true - }) - return - } - if (!restoredUnconfirmed) { - maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay }) - } - const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) - const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) - const suppressSyntheticCodexAutoApprovalTitle = - payload.agentType === 'codex' && - (payload.state === 'waiting' || payload.state === 'blocked') - ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ - agentType: payload.agentType, - state: payload.state, - launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) - }) - : false - const statusEvent = { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - ...(terminalHandle ? { terminalHandle } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(promptInteractionKey ? { promptInteractionKey } : {}), - ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(observation ? { observation } : {}), - ...(orchestration ? { orchestration } : {}) - } - mainWindow?.webContents.send('agentStatus:set', statusEvent) - if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { - getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) - } - recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) - // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. - const profile = getSyntheticAgentTitleProfile(payload.agentType) - if ( - profile && - shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && - !suppressSyntheticCodexAutoApprovalTitle - ) { - driveSyntheticTitleFromHook(paneKey, payload.state, profile) - } - } - ) - agentHookServer.setPaneStatusClearListener((clear) => { - if (mainWindow?.isDestroyed()) { - return - } - mainWindow?.webContents.send('agentStatus:clear', clear) - getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) - }) - setMigrationUnsupportedPtyListener((event) => { - if (mainWindow?.isDestroyed()) { - return - } - if (event.type === 'set') { - mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) - } else { - mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { - ptyId: event.ptyId - }) - } - }) - logStartupMilestone('load-start') - loadMainWindow(window) - return window -} - -function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openFeatureTour') -} - -function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openSetupGuide') -} - -function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openCrashReport') -} - -// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. -async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise { - await presentRendererRecoveryPrompt({ - recentRecoveryCount, - isQuitting: () => isQuitting, - diagnose: describeInstallDirAclPoison, - showMessageBox: (options) => { - const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options) - }, - copyToClipboard: (text) => clipboard.writeText(text), - reload: () => { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') - // Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop. - loadMainWindow(mainWindow) - }, - quit: () => { - isQuitting = true - app.quit() - } - }) -} - -function getGpuFallbackEnvironment(): GpuFallbackEnvironment { - return { - appVersion: app.getVersion(), - electronVersion: process.versions.electron ?? '', - platform: process.platform - } -} - -function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { - const environment = getGpuFallbackEnvironment() - if (environment.platform !== 'win32') { - return null - } - return { ...environment, platform: 'win32' } -} - -// Writes both crash-time and post-recovery consent states through one build-scoped path. -function persistGpuFallbackMarker( - userDataPath: string, - info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } -): boolean { - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return false - } - try { - writeGpuFallbackMarker(userDataPath, info, environment) - return true - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return false - } -} - -// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. -function maybeApplyGpuFallbackForThisLaunch(): void { - if (isServeMode || process.platform !== 'win32') { - return - } - const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) - if (!marker) { - return - } - activeGpuFallbackMarker = marker - app.disableHardwareAcceleration() - const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) - gpuFallbackActiveThisLaunch = true - // Why: with no GPU child left, child-process-gone can't report a GPU fault, so - // name the applied switches in the trail any later crash report carries. - recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow, - switches: appliedSwitches.join(',') - }) -} - -async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise { - const marker = activeGpuFallbackMarker - if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) { - return - } - // One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries. - activeGpuFallbackMarker = null - const userDataPath = app.getPath('userData') - await handleGpuFallbackRecoveredLaunch({ - isQuitting: () => isQuitting, - prompt: () => promptForGpuFallbackRecoveredLaunch(window), - confirmSafeGraphics: () => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: marker.engagedAt, - crashesInWindow: marker.crashesInWindow, - userConfirmed: true - }) - }, - clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), - onSafeGraphicsKept: () => - recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { - crashesInWindow: marker.crashesInWindow - }), - restartWithHardware: () => { - isQuitting = true - relaunchApp('gpu-fallback', { - mode: 'hardware-retry', - crashesInWindow: marker.crashesInWindow - }) - destroySystemTray() - app.exit(0) - } - }) -} - -// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. -async function handleGpuChildCrash( - reason: string, - exitCode: number | null, - crashedAt: number -): Promise { - // Software rendering already active or shutting down: nothing more to do. - if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) { - return - } - const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt) - if (!result.shouldEngageFallback) { - return - } - const fallbackData = { - processReason: reason, - exitCode, - crashesInWindow: result.crashesInWindow - } - const userDataPath = app.getPath('userData') - await engageGpuFallbackAfterCrashBurst( - { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, - { - isQuitting: () => isQuitting, - onEngaged: (engagement) => - recordCrashBreadcrumb('gpu_fallback_engaged', { - reason: engagement.reason, - exitCode: engagement.exitCode, - crashesInWindow: engagement.crashesInWindow - }), - persistMarker: (engagement) => - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: false - }), - confirmMarker: (engagement) => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: true - }) - }, - clearMarker: () => clearGpuFallbackMarker(userDataPath), - promptForRestart: () => - promptForGpuFallbackRestart( - mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - ), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show restart prompt:', error), - onRestartDeferred: () => - recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), - restartIntoSafeGraphics: () => { - isQuitting = true - relaunchApp('gpu-fallback', fallbackData) - // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. - destroySystemTray() - app.exit(0) - } - } - ) -} - -function recordProcessGoneCrash( - source: 'renderer' | 'child', - processType: string, - reason: string, - exitCode: number | null, - details: Record, - webContentsId?: number -): void { - recordProcessGoneCrashEvent(crashReports, { - source, - processType, - reason, - exitCode, - expectedTeardown: getExpectedTeardownScope(webContentsId), - details, - ...(webContentsId !== undefined ? { webContentsId } : {}) - }) -} - -function shutdownWatchersOnce(): Promise { - if (watcherShutdownDone) { - return Promise.resolve() - } - if (!watcherShutdownPromise) { - // Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits. - stopFolderRepoGitUpgradeWatch() - watcherShutdownPromise = Promise.allSettled([ - closeAllWatchers(), - disposeWorktreeBaseDirectoryWatchers() - ]) - .then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - console.error('[filesystem-watcher] shutdown failed:', result.reason) - } - } - }) - .then(() => { - watcherShutdownDone = true - }) - } - return watcherShutdownPromise -} - -// Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval. -// 80ms matches Pi's cadence (smooth but under the IPC budget). opencode needs only one frame but reuses this for consistent animated UX. -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] -const SPINNER_INTERVAL_MS = 80 - -const syntheticTitleSpinnerByPaneKey = new Map< - string, - SyntheticTitleSpinnerEntry ->() -let syntheticTitleSpinnerTimer: ReturnType | null = null - -type ServeOptions = { - json: boolean - wsPort?: number - pairingAddress: string | null - noPairing: boolean - mobilePairing: boolean - recipeJson: boolean - projectRoot: string | null -} - -function getServeOptions(argv = process.argv): ServeOptions { - const valueAfter = (flag: string): string | null => { - const index = argv.indexOf(flag) - if (index === -1) { - return null - } - const value = argv[index + 1] - return value && !value.startsWith('--') ? value : null - } - const rawPort = valueAfter('--serve-port') - let wsPort: number | undefined - if (rawPort) { - const parsedPort = Number(rawPort) - if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { - throw new Error(`Invalid --serve-port value: ${rawPort}`) - } - wsPort = parsedPort - } - return { - json: argv.includes('--serve-json'), - ...(wsPort !== undefined ? { wsPort } : {}), - pairingAddress: valueAfter('--serve-pairing-address'), - noPairing: argv.includes('--serve-no-pairing'), - mobilePairing: argv.includes('--serve-mobile-pairing'), - recipeJson: argv.includes('--serve-recipe-json'), - projectRoot: valueAfter('--serve-project-root') - } -} - -function getBundledWebClientRoot(): string | undefined { - const appPath = app.getAppPath() - const roots = [ - join(appPath, 'out', 'web'), - // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. - join(appPath, '..', 'web') - ] - return roots.find((root) => existsSync(join(root, 'web-index.html'))) -} - -async function renderTerminalPairingQr(pairingUrl: string): Promise { - // Why dynamic: qrcode is only reachable from mobile pairing, so launch should - // not parse it for the majority who never pair a device. - const QRCode = await import('qrcode') - try { - return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) - } catch { - try { - return await QRCode.toString(pairingUrl, { type: 'utf8' }) - } catch { - return null - } - } -} - -async function printServeReady(options: ServeOptions): Promise { - if (!runtime || !runtimeRpc) { - throw new Error('Runtime server must be initialized before printing serve readiness') - } - if (options.recipeJson) { - if (!options.projectRoot) { - throw new Error('--serve-recipe-json requires --serve-project-root') - } - if (!isAbsolute(options.projectRoot)) { - throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) - } - const projectRootStats = statSync(options.projectRoot) - if (!projectRootStats.isDirectory()) { - throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) - } - } - const boundEndpoint = runtimeRpc.getWebSocketEndpoint() - const advertised = boundEndpoint - ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) - : null - const pairing = options.noPairing - ? ({ - available: false, - reason: 'disabled_by_operator', - guidance: 'Restart without --no-pairing to create a client pairing offer.' - } as const) - : runtimeRpc.createPairingOffer({ - address: options.pairingAddress, - name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' - }) - const pairingQr = - pairing.available && options.mobilePairing - ? await renderTerminalPairingQr(pairing.pairingUrl) - : null - await serveReadinessPublisher.publish( - { - runtimeId: runtime.getRuntimeId(), - boundEndpoint, - advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, - // Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair. - managedWslCliReconciliation: managedWslCliReconciliationStatus, - pairing: pairing.available - ? { - available: true, - url: pairing.pairingUrl, - endpoint: pairing.endpoint, - deviceId: pairing.deviceId, - webClientUrl: pairing.webClientUrl, - scope: options.mobilePairing ? 'mobile' : 'runtime', - qr: pairingQr - } - : pairing - }, - options.recipeJson - ? { mode: 'recipe-json', projectRoot: options.projectRoot! } - : { mode: options.json ? 'json' : 'human' } - ) - notifyServeSupervisorReady(runtime.getRuntimeId()) -} - -// Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. -registerPaneKeyTeardownListener((paneKey) => { - stopSyntheticTitleSpinner(paneKey) -}) - -// Why: the spinner is a stand-in for a live hook status, so it must retire with the row it -// stands in for — otherwise a pane whose status was cleared or dismissed keeps rotating a -// working title long after the agent finished (#13890). Both paths are covered: the -// pane-scoped clear fan-out, and user dismissal, which never routes through it. -agentHookServer.subscribePaneStatusClear((clear) => { - const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) - if (paneKey) { - stopSyntheticTitleSpinner(paneKey) - } -}) -agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) - -function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - // Why: throttle decorative spinner frames (up to 80ms/agent); final/permission frames are forced because they drive BEL. - if ( - !shouldSendSyntheticTitleFrame({ - force: options.force === true, - windowVisible: isSyntheticTitleWindowVisible() - }) - ) { - return - } - // Why: feed the per-PTY tracker directly, never onPtyData — emulator/tails/transcripts/stats must not see fabricated bytes. - runtime?.ingestSyntheticTitleFrame(ptyId, data) - // Why: only the kill-switch-off renderer byte-parses synthetic frames; under main authority the copy mints phantom ACKs (see synthetic-title-frame-routing.ts). - if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { - mainWindow.webContents.send('pty:data', { id: ptyId, data }) - } -} - -function isSyntheticTitleWindowVisible(): boolean { - return ( - mainWindow !== null && - !mainWindow.isDestroyed() && - mainWindow.isVisible() && - !mainWindow.isMinimized() - ) -} - -function canSendDecorativeSyntheticTitle(): boolean { - return shouldSendSyntheticTitleFrame({ - force: false, - windowVisible: isSyntheticTitleWindowVisible() - }) -} - -function stopSyntheticTitleSpinner(paneKey: string): void { - if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { - stopSyntheticTitleSpinnerTimerIfIdle() - } -} - -function stopAllSyntheticTitleSpinners(): void { - syntheticTitleSpinnerByPaneKey.clear() - stopSyntheticTitleSpinnerTimer() -} - -function stopSyntheticTitleSpinnerTimer(): void { - if (!syntheticTitleSpinnerTimer) { - return - } - clearInterval(syntheticTitleSpinnerTimer) - syntheticTitleSpinnerTimer = null -} - -function stopSyntheticTitleSpinnerTimerIfIdle(): void { - if (syntheticTitleSpinnerByPaneKey.size === 0) { - stopSyntheticTitleSpinnerTimer() - } -} - -function tickSyntheticTitleSpinners(): void { - if (!canSendDecorativeSyntheticTitle()) { - stopSyntheticTitleSpinnerTimer() - return - } - const ticks = advanceSyntheticTitleSpinnerEntries({ - entries: syntheticTitleSpinnerByPaneKey, - frameCount: SPINNER_FRAMES.length, - getPtyIdForPaneKey - }) - for (const tick of ticks) { - sendSyntheticTitle( - tick.ptyId, - `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` - ) - } - stopSyntheticTitleSpinnerTimerIfIdle() -} - -function ensureSyntheticTitleSpinnerTimer(): void { - if ( - syntheticTitleSpinnerTimer || - syntheticTitleSpinnerByPaneKey.size === 0 || - !canSendDecorativeSyntheticTitle() - ) { - return - } - // Why: one shared timer for all spinners — per-pane intervals multiplied idle wakeups when several agents were working. - syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) -} - -function resumeSyntheticTitleSpinnerTimer(): void { - ensureSyntheticTitleSpinnerTimer() -} - -function driveSyntheticTitleFromHook( - paneKey: string, - state: AgentStatusState, - profile: SyntheticAgentTitleProfile -): void { - const ptyId = getPtyIdForPaneKey(paneKey) - if (!ptyId) { - return - } - if (state === 'working') { - // Why: emit the first frame immediately so the spinner is visible now, not up to 80ms later at the next interval tick. - const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) - const frame = existing ? existing.frame : 0 - sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) - if (existing) { - // Why: refresh the profile so a mid-pane agent-type change lands on the right idle/permission labels at terminal state. - existing.profile = profile - return - } - syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) - ensureSyntheticTitleSpinnerTimer() - return - } - // Why: stop the spinner first so the next tick can't race the state back to "working", then inject the terminal frame. - // Permission frames add a trailing BEL to light up user-input states; done frames omit it (completion notifications own that attention). - stopSyntheticTitleSpinner(paneKey) - const needsUserInput = state === 'blocked' || state === 'waiting' - const label = needsUserInput ? profile.permissionLabel : profile.idleLabel - sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { - force: true - }) -} - -function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { - agentType: string | null | undefined - state: AgentStatusState - launchConfig: - | { - agentArgs?: string | null - agentEnv?: Record | null - } - | null - | undefined -}): boolean { - if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { - return false - } - if (!args.launchConfig) { - return false - } - return ( - resolveTuiAgentPermissionMode({ - agent: 'codex', - agentArgs: args.launchConfig.agentArgs, - agentEnv: args.launchConfig.agentEnv - }) === 'yolo' - ) -} - -void app.whenReady().then(async () => { - logStartupMilestone('app-ready') - // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. - applyBackgroundActivationPolicy({ warn: console.warn }) - installElectronProxyRequestGuard(session.defaultSession) - app.on('login', (event, webContents, details, authInfo, callback) => { - handleElectronProxyLogin( - event, - webContents, - details, - authInfo, - callback, - session.defaultSession - ) - }) - installMainThreadHangWatchdog({ userDataPath: getCanonicalUserDataPath() }) - const hangDetection = consumeHangDetectionMarker( - hangDetectionMarkerPath(getCanonicalUserDataPath()) - ) - if (hangDetection) { - recordDurableCrashBreadcrumb('main_thread_hang_detected', { - unresponsiveMs: hangDetection.unresponsiveMs, - previousPid: hangDetection.parentPid, - selfRecovered: hangDetection.selfRecovered - }) - } - // Why: install certificate decisions before any webview or headless window issues its first TLS request. - app.on( - 'certificate-error', - (event, webContents, url, error, certificate, callback, isMainFrame) => { - browserCertificateTrustController.handleCertificateError({ - event, - webContents, - url, - error, - certificate, - callback, - isMainFrame - }) - } - ) - electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) - // Why: names the app menu/About panel. Dev already applied this pre-ready (see the - // safeStorage note above); this call stays unconditional so packaged builds keep their - // existing post-ready rename, which lands after the Keychain name is already resolved. - app.setName(devInstanceIdentity.appName) - updateGpuAccelerationAboutPanel() - - // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. - managedWslCliReconciliationStatus = 'pending' - managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ - isPackaged: app.isPackaged, - userDataPath: getCanonicalUserDataPath(), - appVersion: app.getVersion() - }) - .then((results) => { - for (const result of results) { - if (result.outcome === 'failed') { - console.warn( - `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` - ) - } else if (result.outcome === 'repaired') { - console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) - } - } - managedWslCliReconciliationStatus = 'settled' - }) - .catch((error) => { - managedWslCliReconciliationStatus = 'failed' - console.warn( - '[wsl-cli] Managed registration reconciliation discovery failed:', - error instanceof Error ? error.message : String(error) - ) - }) - managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( - managedWslCliReconciliationReady - ) - - const activeOrcaProfile = ensureActiveOrcaProfile() - // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable - // read has to have happened by then or the renderer and the browser-host lease disagree. - initializeBrowserClientHostId(activeOrcaProfile.profileDirectory) - store = new Store({ - dataFile: activeOrcaProfile.dataFile, - storageAuthority: isServeMode ? 'runtime' : 'desktop' - }) - // Why: create pending readiness before the guard can observe the default session. - const initialProxyApplication = applyElectronProxySettings(store.getSettings()) - installElectronProxyRequestGuard(session.defaultSession) - // Why armed here and not at install time: the report remembers what it last said, and - // that state lives beside the profile data file, which does not exist until now. - // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux - // and must not gate the first window (STA-5765). - scheduleSecretProtectionGapReport({ - dataFile: activeOrcaProfile.dataFile, - force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', - deferUntilFirstWindow: !isServeMode - }) - // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults - // it. Left unbound it reports nothing trusted, which is safe but silently discards our own - // accept records on every launch. - initSshHostKeyStoreFile(activeOrcaProfile.dataFile) - // Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow. - neutralizeLegacyTerminalShimDir(app.getPath('userData')) - const windowsShellPathHydration = createWindowsShellPathHydration() - configureWindowsHostGitEnvironmentReadiness( - process.platform === 'win32' ? windowsShellPathHydration.whenReady : null - ) - if (process.platform === 'win32') { - const settings = store.getSettings() - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null) - logStartupMilestone('store-loaded') - // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. - setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) - store.onSettingsChanged((updates, settings) => { - if ('terminalWindowsWslDistro' in updates) { - // Why: synchronize fallback WSL distro updates to runner. - setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) - } - if ( - ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && - process.platform === 'win32' - ) { - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - if ('showMenuBarIcon' in updates) { - // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. - syncMacMenuBarIcon(settings.showMenuBarIcon !== false) - } - if ('agentStatusHooksEnabled' in updates) { - // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running - // guest process and timers, and on must restart them — otherwise open WSL panes report no - // status until their next spawn. - if (isAgentStatusHooksEnabled(settings)) { - wslHookRelayManager.resumeStoppedRelays() - } else { - wslHookRelayManager.disposeAll({ permanent: false }) - } - } - }) - // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) - // Why: while a live claude defers the managed OAuth refresh, usage shows - // "Waiting for Claude session"; refetch when the last live PTY exits so the - // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { - void rateLimits?.refreshAfterClaudeLivePtysDrained() - }) - const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) - if (persistedClaudePtyIds.length > 0) { - console.log( - `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` - ) - } - applyAppIcon(store.getSettings().appIcon) - if (shouldSuppressDevEducation({ isDev: is.dev })) { - suppressDevEducationForStore(store) - } - try { - // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - const proxyApplyResult = await initialProxyApplication - if (proxyApplyResult.source === 'invalid-settings') { - // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. - console.warn('[proxy] persisted proxy settings are invalid; using direct networking') - } - } catch { - console.warn('[proxy] Failed to apply network proxy settings') - } - // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. - setBrowserNetworkProxySettingsResolver(() => store!.getSettings()) - // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. - installDocPreviewProtocolHandler() - registerDocPreviewGrantHandlers() - // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. - initializeBrowserSessionsForApp({ - orcaProfileId: activeOrcaProfile.profile.id, - profileDirectory: activeOrcaProfile.profileDirectory, - // Why: local direct-SSH partitions are scoped to targets, and the orphan - // sweep must see the live target list or it would clear their cookie jars. - listLocalSshTargetIds: () => { - if (!store) { - // Why: an empty list would read as "every SSH jar is an orphan"; throwing skips the sweep. - throw new Error('ssh target store unavailable at partition sweep') - } - return store.getSshTargets().map((target) => target.id) - } - }) - try { - // Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write. - await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) - } catch { - console.warn('[proxy] Failed to apply network proxy settings to browser sessions') - } - unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() - agentAwakeService = new AgentAwakeService() - agentAwakeService.setMode( - normalizeComputerAwakeMode( - store.getSettings().computerAwakeMode, - store.getSettings().keepComputerAwakeWhileAgentsRun - ) - ) - // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. - agentAwakeService.setStatuses([]) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() - const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { - const ownedIdentities = identities.map((identity) => ({ - ...identity, - worktreeId: - identity.worktreeId ?? - runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? - undefined - })) - for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged - // `snapshotVersion`, which every client drops on its monotonic gate. - runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) - } - } - const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { - agentAwakeService?.setStatuses(statuses) - }) - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => { - // Healthy session.tabs streams need a push when transcript identity changes. - publishProviderSessionChanges(sessions) - } - ) - // Why: hook rows are the only carrier of live agent state on a headless host, and - // nothing else republishes `session.tabs` when one changes — so a paired client - // would keep the pane's last projection until an unrelated PTY touch came along. - const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() - const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { - if (hookStatusChangedSessionTabs(enriched)) { - runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) - } - }) - // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land - // here. Without it the live state published above becomes a zombie question card. - const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { - const clearedPaneKeys = - 'paneKey' in clear - ? [clear.paneKey] - : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) - for (const paneKey of clearedPaneKeys) { - hookStatusChangedSessionTabs.forgetPane(paneKey) - runtime?.touchMobileSessionTabsForPane(paneKey) - } - }) - unsubscribeAgentAwakeStatusChanges = () => { - unsubscribeStatusChanges() - unsubscribeProviderSessionChanges() - unsubscribeHookStatusSessionTabs() - unsubscribeHookStatusClear() - } - // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. - initTelemetry(store) - // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not - // a crash (the app is force-quit, so no report is ever generated). Without this the incidence - // number the watchdog exists to produce would sit unread on the user's disk. Must run after - // initTelemetry: track() drops silently until the client and store are wired. - if (hangDetection) { - track('main_thread_hang_detected', { - unresponsive_ms: Math.round(hangDetection.unresponsiveMs), - self_recovered: hangDetection.selfRecovered - }) - } - // Why: the trust-grant module is bundled into plain-node CLI entries where - // the telemetry client cannot load, so the tracker is injected here instead - // of imported there. - setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { - track('codex_trust_grant', { - outcome, - host_kind: hostKind, - lane, - ...(reason !== undefined ? { fallback_reason: reason } : {}), - ...(errorClass !== undefined ? { error_class: errorClass } : {}), - ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) - }) - }) - // Why: the error-tracking lane (telemetry-error-tracking.md) is its own - // composition root — independent of product telemetry — and must - // initialize before any IPC handler / runtime span is created so the - // tracer's active sink is populated at the moment the first span fires. - // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED - // / CI internally; those gates do not need to be re-checked here. - initObservability() - recordDurableCrashBreadcrumb('main_process_lifecycle_started', { - packaged: app.isPackaged, - platform: process.platform - }) - const skillTransactionRecovery = recoverPendingSkillTransactions( - join(app.getPath('userData'), 'skill-installs') - ) - void skillTransactionRecovery - .then((report) => { - if (report.scanned || report.failures.length || report.truncated) { - console.info('[skills] startup transaction recovery:', { - scanned: report.scanned, - recovered: report.recovered, - failures: report.failures.map((failure) => failure.code), - truncated: report.truncated - }) - } - }) - .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) - // Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track(). - initCohortClassifier(store) - initOnboardingCohortClassifier(store) - stats = new StatsCollector() - // Agent-session stats come from hook status transitions, the same truth the - // sidebar and dashboard read — never from OSC terminal titles, which miss - // hook-only agents and count any spinner TUI as an agent (#10201). - const agentSessionRecorder = new AgentSessionTransitionRecorder(stats) - agentHookServer.subscribeEnrichedStatus((enriched) => { - agentSessionRecorder.onStatus(enriched) - }) - agentHookServer.subscribePaneStatusClear((clear) => { - agentSessionRecorder.onCleared(clear) - }) - claudeUsage = new ClaudeUsageStore(store) - codexUsage = new CodexUsageStore(store) - openCodeUsage = new OpenCodeUsageStore(store) - rateLimits = new RateLimitService() - codexRuntimeHome = new CodexRuntimeHomeService(store) - void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) - // Why: an incapable trust-grant host must fall back to the managed home for - // every consumer (PTY env, rate limits, commit messages) in one place. - codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) - // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy - // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off, or an incapable - // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed( - () => - codexRuntimeHome !== null && - codexRuntimeHome.isHostSystemDefaultRealHome() && - isAgentStatusHooksEnabled(store?.getSettings()) - ) - codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, - isQuitting: () => isQuitting, - resolveSystemCodexHomePathOverride: () => - resolveHostCodexSessionSourceHome(store!.getSettings()), - prepareScheduledRun: (scanDates) => - codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), - finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), - startBackfill: startCodexSessionBackfillInBackground, - startIndexHeal: startCodexSessionIndexHealInBackground - }) - codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { - onHostSystemDefaultSelected: codexSessionMigration.requestRun - }) - // Why: migrate historical shared-home sessions after startup; compatibility - // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). - codexSessionMigration.scheduleInitialRun() - claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) - claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) - rateLimits.setCodexHomePathResolver((target) => - codexRuntimeHome!.prepareForRateLimitFetch(target) - ) - rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) - // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the - // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). - rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings()))) - rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) - const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( - rateLimits, - store.getSettings() - ) - store.onSettingsChanged((updates, settings) => { - // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. - void syncAccountRuntimeTargets(updates, settings).catch((error) => - console.warn('[rate-limits] Failed to apply account runtime target:', error) - ) - }) - rateLimits.setClaudeAuthPreparationResolver((target) => - claudeRuntimeAuth!.prepareForRateLimitFetch(target) - ) - // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). - agentHookServer.setClaudeStatusLineListener((event) => { - rateLimits?.ingestLiveClaudeRateLimits(event) - }) - rateLimits.setOpenCodeGoConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: settings.opencodeSessionCookie, - workspaceIdOverride: settings.opencodeWorkspaceId - } - }) - rateLimits.setMiniMaxConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: readMiniMaxSessionCookie() ?? '', - groupId: settings.minimaxGroupId, - models: settings.minimaxUsageModels - } - }) - rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) - rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) - keybindings = new KeybindingService({ - homePath: app.getPath('home'), - getLegacyOverrides: () => store!.getSettings().keybindings, - legacyTabSwitchSeed: { - isPending: () => store!.getSettings().tabSwitchKeybindingSeed === 'pending', - markSeeded: () => { - store!.updateSettings({ tabSwitchKeybindingSeed: 'done' }) - } - } - }) - browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) - rateLimits.setInactiveClaudeAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeClaudeRuntimeSelection(settings).host, - ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.claudeManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - managedAuthPath: account.managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime, - wslDistro: account.wslDistro, - wslLinuxAuthPath: account.wslLinuxAuthPath - })) - }) - rateLimits.setInactiveCodexAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeCodexRuntimeSelection(settings).host, - ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.codexManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - resolveHome: () => { - const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) - return resolved.kind === 'ready' - ? { kind: 'ready' as const, managedHomePath: resolved.homePath } - : { kind: 'skip' as const } - } - })) - }) - const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { - resolve: (selector) => { - const environment = resolveEnvironment(app.getPath('userData'), selector) - const pairing = getPreferredPairingOffer(environment) - return { - environmentId: environment.id, - name: environment.name, - peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) - } - }, - call: (selector, method, params, timeoutMs, envelope) => - callRuntimeEnvironment( - app.getPath('userData'), - selector, - method, - params, - timeoutMs, - undefined, - envelope - ) - } - const runtimeService = new OrcaRuntimeService(store, stats, { - agentSessionClaimSigner: loadAgentSessionClaimSigner( - getProfileUserDataPath(), - getProfileUserDataPath() - ), - // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). - getLocalProvider: () => getLocalPtyProvider(), - // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. - getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - onPtyStopped: clearProviderPtyState, - onTerminalAgentStatus: (event) => { - agentHookServer.ingestTerminalStatus(event) - }, - // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - }, - getDesktopWindowStatus: getDesktopWindowStatus, - // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. - getAgentStatusSnapshot: () => - agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), - // Why: the filter above hides resume-identity rows from the live-agent views, but - // those rows carry the provider session mobile native chat addresses transcripts - // by — Pi publishes identity that way and would otherwise be unreachable. - getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => - agentHookServer.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - agentHookServer.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey) => - agentHookServer.retirePaneAuthority(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => { - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys) - }, - canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, - // Why: evaluated per call, not captured — the RPC server that owns the device registry is - // constructed with this runtime and does not exist yet at this point. - getPairedDeviceName: (pairedDeviceId) => - runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, - // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { - launchAgent: 'codex', - workspacePath - }), - buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, - orchestrationEnvironmentTransport, - skillTransactionRecovery - }) - runtime = runtimeService - runtimeService.prepareLegacyWorkerTerminalRecovery() - // Why before anything can attach: a client host that reattaches to a restarted runtime is only - // handed its pages back if the runtime found them first. - runtimeService.rehydrateClientHostedBrowserPages() - publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities()) - browserManager.setBrowserGuestStateChangedListener((worktreeId) => { - runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - automations = new AutomationService(store, { - claudeUsage, - codexUsage, - terminalObserver: createRuntimeAutomationRunTerminalObserver(runtimeService), - onAutomationsChanged: (payload) => runtimeService.notifyAutomationsChanged(payload), - // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. - allowRemoteHostScheduling: isServeMode, - headlessDispatcher: isServeMode - ? async ({ automation, run, target }) => { - const terminalSnapshotLimit = 2_000 - let terminalHandle: string - let terminalSessionId: string | null = null - let terminalPaneKey: string | null = null - let terminalPtyId: string | null = null - let workspaceId: string - let workspaceDisplayName: string | null = null - - if (automation.workspaceMode === 'new_per_run') { - const created = await runtimeService.createManagedWorktree({ - ...buildHeadlessAutomationWorktreeCreateArgs({ - automation, - run, - repo: target.repo - }) - }) - terminalHandle = created.startupTerminal?.handle ?? '' - terminalSessionId = created.startupTerminal?.tabId ?? null - terminalPaneKey = created.startupTerminal?.paneKey ?? null - terminalPtyId = created.startupTerminal?.ptyId ?? null - workspaceId = created.worktree.id - workspaceDisplayName = created.worktree.displayName ?? null - if (!terminalHandle) { - throw new Error( - created.warning || - 'Automation workspace was created, but no agent terminal started.' - ) - } - } else { - if (!automation.workspaceId) { - throw new Error('The target workspace is no longer available.') - } - const terminal = await runtimeService.launchAgentTerminal( - `id:${automation.workspaceId}`, - { - agent: automation.agentId, - prompt: automation.prompt, - title: run.title - } - ) - terminalHandle = terminal.handle - terminalSessionId = terminal.tabId ?? null - terminalPaneKey = terminal.paneKey ?? null - terminalPtyId = terminal.ptyId ?? null - workspaceId = terminal.worktreeId - const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`) - workspaceDisplayName = worktree.displayName ?? null - } - - const completion = (async () => { - const wait = await runtimeService.waitForTerminal(terminalHandle, { - condition: 'tui-idle' - }) - const read = await runtimeService.readTerminal(terminalHandle, { - limit: terminalSnapshotLimit - }) - const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() - snapshotBuffer.append(read.tail.join('\n')) - if (wait.satisfied) { - return { - status: 'completed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: null - } - } - return { - status: 'dispatch_failed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: wait.blockedReason - ? `Automation agent is blocked: ${wait.blockedReason}.` - : 'Automation agent did not report completion.' - } - })() - - return { - workspaceId, - workspaceDisplayName, - terminalSessionId, - terminalPaneKey, - terminalPtyId, - completion - } - } - : undefined - }) - runtimeService.setAutomationService(automations) - runtimeService.setArtifactService( - new ArtifactCloudService(app.getPath('userData'), () => - isArtifactSharingEnabled(store?.getSettings()) - ) - ) - runtimeService.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) - runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) - runtimeService.setCommitMessageAgentEnvironmentResolvers({ - // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }) - const pluginSystemStartupStartedAt = performance.now() - pluginKillListService = new PluginKillListService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')) - }) - await pluginKillListService.initialize() - pluginMarketplaceService = new PluginMarketplaceService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')), - getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null - }) - const requestOfficialMarketplaceSeed = (): void => { - if (store?.getSettings().pluginSystemEnabled !== true) { - return - } - void pluginMarketplaceService?.seedOfficialSource().catch((error) => { - console.warn('[plugins] failed to configure the official marketplace:', error) - }) - } - pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ - marketplace: pluginMarketplaceService, - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null - }) - pluginService = new PluginService({ - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - // Feature flag: with the setting off, discovery returns nothing and no - // plugin code path runs at all. - isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true, - getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins), - getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents), - getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths), - getKeybindings: () => keybindings?.getOverrides() ?? {}, - getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, - hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) - }) - const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ - root: resolveBundledPluginRoot({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath() - }), - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - isEnabled: () => store?.getSettings().pluginSystemEnabled === true, - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, - refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() - }) - const requestBundledPluginBootstrap = (): void => { - void bundledPluginBootstrap - .request() - .then((result) => { - for (const failure of result?.errors ?? []) { - console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) - } - }) - .catch((error) => { - console.warn('[plugins] failed to bootstrap bundled plugins:', error) - }) - } - pluginKillListService.onChanged(() => { - void pluginService?.reconcileActivationState().catch((error) => { - console.warn('[plugins] failed to apply plugin safety-list refresh:', error) - }) - }) - store.onSettingsChanged((updates) => { - if (updates.pluginSystemEnabled === true) { - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - } - if (app.isPackaged && updates.pluginSystemEnabled === true) { - void pluginKillListService?.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - }) - // Why: headless `orca serve` clients reach plugins through the runtime RPC - // methods, which resolve the service via this module-level setter. Consent - // over RPC uses the same hash-keyed write path as the desktop dialog. - setPluginServiceForRpc(pluginService, { - applyConsent: (request) => - applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }), - applyEnablement: (pluginKey, enabled) => - applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled }) - }) - // Lazy kernel: initialize() only discovers manifests — no worker forks, no - // panel reads. Zero plugin code runs before an explicit trigger. - void pluginService - .initialize() - .then(() => { - logStartupMilestone('plugin-system-initialized', { - durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), - installedPlugins: pluginService?.getDiscovered().length ?? 0 - }) - }) - .catch((error) => { - console.warn('[plugins] failed to initialize plugin service:', error) - }) - if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) { - void pluginKillListService.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - pluginService.onChanged((event) => { - if ( - event.contentPacksChanged && - setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) - ) { - void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) - } - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('plugins:changed', event) - } - } - }) - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - // v0 plugin event seams: agent status (hook pipeline tap) + worktree - // lifecycle (runtime tap). Server-side filtered per plugin subscription. - agentHookServer.subscribeEnrichedStatus((enriched) => { - // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. - if (enriched.restoredUnconfirmed) { - return - } - pluginService?.emitEvent('agent.status.changed', { - worktreeId: enriched.worktreeId ?? null, - paneKey: enriched.paneKey, - state: enriched.payload.state, - receivedAt: enriched.receivedAt - }) - }) - runtimeService.onWorktreeLifecycle((event) => { - emitPluginWorktreeLifecycle(event) - }) - starNag = new StarNagService(store, stats) - starNag.start() - starNag.registerIpcHandlers() - const agentBrowserBridge = new AgentBrowserBridge(browserManager, { - onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - runtimeService.setAgentBrowserBridge(agentBrowserBridge) - // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. - void agentBrowserBridge.sweepOrphanedSessions() - const browserClientAutomationDispatcher = new RpcDispatcher({ runtime: runtimeService }) - configureBrowserClientPageAutomationRuntime({ - browserManager, - getAgentBrowserBridge: () => agentBrowserBridge, - executeRpc: async (method, params, signal) => { - const response = await browserClientAutomationDispatcher.dispatch( - { - id: randomUUID(), - authToken: 'local-browser-client-automation', - method, - params - }, - { signal } - ) - if (!response.ok) { - throw new BrowserClientPageCommandError(response.error.code) - } - return response.result - } - }) - - // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. - // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. - const emulatorBridge = new EmulatorBridge() - runtimeService.setEmulatorBridge(emulatorBridge) - // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or - // crash mid-delete can leave the moved directory on disk. - void sweepStaleWorktreeTrash( - collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) - ).catch((error) => { - console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) - }) - nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why (#16441): the real-home grant runs a codex app-server session. It stays - // ordered before managed-hook reconciliation — an incapable host must re-arm - // and complete the legacy real-home sweep first — but awaiting it inline - // stalled app init behind that session, so chain instead of blocking. - const startupManagedHookSettings = store.getSettings() - const shouldReconcileStartupManagedHooks = - shouldInstallManagedHooks(is.dev) && - resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' - const realHomeCodexHookState = - shouldReconcileStartupManagedHooks && - shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && - codexRuntimeHome.isHostSystemDefaultRealHomeSelected() - ? ensureRealHomeCodexHookState({ - hooksEnabled: true, - userDataPath: app.getPath('userData') - }).catch((error: unknown) => { - console.warn('[codex-real-home-hooks] startup ensure failed:', error) - }) - : Promise.resolve() - // Why skip rather than remove when the off switch is set: the hook files are user-global but this - // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca - // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. - if (shouldReconcileStartupManagedHooks) { - const managedHookStore = store - void realHomeCodexHookState - .then(() => - installManagedAgentHooks(managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged, - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const settings = managedHookStore.getSettings() - return shouldContinueManagedHookStartup(isQuitting, settings, agent) - } - }) - ) - .catch((error: unknown) => { - console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) - }) - } - // Why: process-gone metrics only see survivors; retain a recent whole-app - // snapshot for comparison in crash reports. - startPreGoneProcessMetricsSampling() - app.on('child-process-gone', (_event, details) => { - recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { - name: details.name, - serviceName: details.serviceName, - type: details.type - }) - if ( - isGpuFallbackCrashCandidate({ - platform: process.platform, - processType: details.type, - reason: details.reason - }) - ) { - const crashedAt = performance.now() - void gpuCrashDiagnostics?.record() - void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) - } - }) - - logStartupMilestone('services-initialized') - await ensureMainI18n() - await setMainUiLanguage(store.getSettings().uiLanguage) - logStartupMilestone('i18n-ready') - - registerAppMenu({ - appMenuLabel: devInstanceIdentity.name, - onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - onOpenSettings: openSettingsFromSystemMenu, - onOpenSetupGuide: (targetWindow) => { - recordCrashBreadcrumb('setup_guide_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenSetupGuide(targetBrowserWindow) - }, - onOpenCrashReport: (targetWindow) => { - recordCrashBreadcrumb('crash_report_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenCrashReport(targetBrowserWindow) - }, - onOpenFeatureTour: (targetWindow) => { - recordCrashBreadcrumb('feature_tour_opened') - // Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus. - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenFeatureTour(targetBrowserWindow) - }, - // Why: menu zoom must act on the window the user is looking at — routing to - // the main window while the dashboard pop-out is focused zooms behind it. - onZoomIn: () => { - if (!zoomDashboardPopoutIfFocused('in')) { - mainWindow?.webContents.send('terminal:zoom', 'in') - } - }, - onZoomOut: () => { - if (!zoomDashboardPopoutIfFocused('out')) { - mainWindow?.webContents.send('terminal:zoom', 'out') - } - }, - onZoomReset: () => { - if (!zoomDashboardPopoutIfFocused('reset')) { - mainWindow?.webContents.send('terminal:zoom', 'reset') - } - }, - onToggleLeftSidebar: () => { - mainWindow?.webContents.send('ui:toggleLeftSidebar') - }, - onToggleRightSidebar: () => { - mainWindow?.webContents.send('ui:toggleRightSidebar') - }, - onToggleAppearance: (key) => { - if (!store) { - return - } - if (key === 'statusBarVisible') { - // Why: status bar visibility lives in persisted UI state (not settings) and the renderer owns the toggle — forward the event, let it flip + store. - mainWindow?.webContents.send('ui:toggleStatusBar') - return - } - const current = store.getSettings() - // Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden. - const next = getNextDefaultOnAppearanceSettingValue(current[key]) - store.updateSettings({ [key]: next }, { notifyListeners: true }) - rebuildAppMenu() - }, - getAppearanceState: () => { - const settings = store?.getSettings() - const ui = store?.getUI() - return { - showTasksButton: settings?.showTasksButton !== false, - showAutomationsButton: settings?.showAutomationsButton !== false, - showMobileButton: settings?.showMobileButton !== false, - showTitlebarAppName: settings?.showTitlebarAppName !== false, - statusBarVisible: ui?.statusBarVisible !== false - } - }, - getKeybindings: () => keybindings?.getOverrides() - }) - // Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port. - const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT - const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) - if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { - throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) - } - // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). - const devWsPort = is.dev && !isE2E ? 6769 : undefined - let serveOptions: ServeOptions | null = null - try { - serveOptions = isServeMode ? getServeOptions() : null - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - app.exit(1) - return - } - // Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path. - migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) - runtimeRpc = new OrcaRuntimeRpcServer({ - runtime, - // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. - userDataPath: getCanonicalUserDataPath(), - enableWebSocket: true, - // Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device; - // `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over. - exposeNetworkByDefault: Boolean(serveOptions) || isE2E, - ...(isE2E ? { wsPort: e2eWsPort } : {}), - ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), - ...(serveOptions?.wsPort !== undefined - ? { - wsPort: serveOptions.wsPort, - // Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability. - preferPinnedWsPort: true - } - : {}), - webClientRoot: getBundledWebClientRoot() - }) - registerMobileHandlers(runtimeRpc, { - getRelayStatus: () => desktopRelayStatus, - consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - mainWindow.webContents.id !== webContentsId || - !pendingUnpairedDeviceAuthFailure - ) { - return false - } - pendingUnpairedDeviceAuthFailure = false - return true - } - }) - // Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing. - runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { - // Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it. - pendingUnpairedDeviceAuthFailure = true - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') - } - }) - - const shellPathReady = windowsShellPathHydration.whenReady() - let desktopWindow: BrowserWindow | null = null - if (process.platform === 'win32' && app.isPackaged && !serveOptions) { - const desktopStartup = startWindowsDesktopBeforeShellPathReady({ - bindServices: bindTerminalRuntimeStartupServices, - openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }), - shellPathReady, - startServices: startTerminalRuntimeStartupServices - }) - desktopWindow = desktopStartup.window - } else { - await shellPathReady - bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) - } - app.on('activate', handleMacAppActivation) - - if (serveOptions) { - // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. - logStartupMilestone('wsl-cli-barrier-start') - await managedWslCliStartupBarrierReady - logStartupMilestone('wsl-cli-barrier-resolved', { - reconciliation: managedWslCliReconciliationStatus - }) - // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. - await localPtyStartupReady - await localPtyProviderStartupReady - await registerHeadlessPtyRuntime( - runtime, - prepareCodexRuntimeHomeForLaunch, - () => store!.getSettings(), - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - store, - prepareCodexSessionResumeForLaunch, - { - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit - } - ) - await runtime.refreshRestoredOrchestrationAuthority() - await runtime.reconcileLegacyWorkerTerminals() - // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. - if (headlessBrowserDisplayAvailable) { - runtime.setOffscreenBrowserBackend( - new OffscreenBrowserBackend(browserManager, { - getAgentBrowserBridge: () => agentBrowserBridge - }) - ) - } - // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. - runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) - await runtimeRpc.start().catch((error) => { - console.error('[runtime] Failed to start headless RPC transport:', error) - throw error - }) - settleServeDesktopActivation() - // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. - registerServeSignalHandlers(process, () => app.quit()) - // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). - if (process.platform === 'darwin' || process.platform === 'linux') { - try { - // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. - const cliStatus = await new CliInstaller({ - privilegedRunner: async () => { - throw new Error('serve CLI auto-install must not request administrator privileges') - } - }).install() - console.log( - `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` - ) - } catch (error) { - console.warn( - '[serve] orca CLI install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. - if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { - try { - const dispatcher = await installLinuxBareOrcaDispatcher({ - resourcesPath: process.resourcesPath - }) - console.log( - `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + - `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` - ) - } catch (error) { - console.warn( - '[serve] bare orca dispatcher install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here. - automations.start() - // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is - // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. - scheduleAllPendingHistoryTreeRemovals() - await printServeReady(serveOptions) - return - } - - // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. - const desktopRuntimeRpc = runtimeRpc - if (!desktopRuntimeRpc) { - throw new Error('runtime_rpc_unavailable') - } - const [win, runtimeRpcStartResult] = await Promise.all([ - Promise.resolve(desktopWindow ?? openMainWindow()), - shellPathReady - .then(() => desktopRuntimeRpc.start()) - .then( - () => ({ ok: true as const }), - (error: unknown) => { - recordRuntimeRpcStartFailure(error) - return { ok: false as const, error } - } - ) - ]) - if (!runtimeRpcStartResult.ok) { - void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) - } - - const cloudAuth = getOrcaCloudAuthConfig() - if (cloudAuth.configured) { - try { - const relayService = new DesktopRelayService({ - authConfig: cloudAuth.config, - userDataPath: getProfileUserDataPath(), - appVersion: app.getVersion(), - runtimeRpc, - onStatus: (status) => { - desktopRelayStatus = status - mainWindow?.webContents.send('mobile:relayStatusChanged', status) - } - }) - desktopRelayService = relayService - runtimeRpc.setMobileRelayPairingProvider({ - createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), - onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), - onDemandStateChanged: () => relayService.demandStateChanged(), - getEndpoints: (context, params) => relayService.getEndpoints(context, params), - provisionRelay: (context, params) => relayService.provisionRelay(context, params) - }) - relayService.start() - // Why: sleeping past relay-token expiry kills the broker with no retry - // timer; resume is the moment that state becomes recoverable. - powerMonitor.on('resume', () => desktopRelayService?.ensureLive()) - } catch (error) { - console.warn( - '[relay] Desktop relay startup unavailable:', - error instanceof Error ? error.message : String(error) - ) - } - } - - // Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window. - win.once('show', () => { - // Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener. - if (!store) { - return - } - const onboarding = store.getOnboarding() - if (onboarding.closedAt !== null) { - triggerStartupNotificationRegistration(store) - } - }) -}) - -// Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits. -process.once('exit', stopTccPromptNotice) - -app.on('before-quit', () => { - if (isQuittingForUpdate()) { - recordUpdaterLifecycle('before_quit_allowed', undefined, { - message: 'before-quit allowed for update install' - }) - } - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - runtimeRpc?.setMobileRelayPairingProvider(null) - unsubscribeAgentAwakeStatusChanges?.() - unsubscribeAgentAwakeStatusChanges = null - agentAwakeService?.dispose() - agentAwakeService = null - // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). - rateLimits?.stop() -}) - -// Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. -let daemonDisconnectDone = false -// Why 2s: a config delete is best-effort, not durable state. -const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 - -app.on('will-quit', (e) => { - // Why return instead of re-running teardown: the second pass is Electron re-firing after - // our own app.quit(), so every step below already ran and every durable write already - // landed. Re-entering would start a fresh unawaited write that the exit then tears down. - if (daemonDisconnectDone) { - return - } - // Why preventDefault before any work: everything below must be free to await, and a - // synchronous durable write here parks the main thread — uninterruptibly, on a stalled - // network profile mount. The teardown deadline cannot rescue that, because its timer - // lives on the same thread it would need to bound (#9447 covers the wedged-transport - // half; this covers the blocked-syscall half). - if (!quitTeardownStartGate.tryStart(e)) { - return - } - unsubscribeSystemResumeBroadcast?.() - unsubscribeSystemResumeBroadcast = null - // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. - stopTccPromptNotice() - const updateQuitInProgress = isQuittingForUpdate() - if (updateQuitInProgress) { - recordUpdaterLifecycle( - 'will_quit_cleanup_started', - { daemonTeardown: 'disconnect' }, - { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } - ) - } - // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. - destroySystemTray() - // Why: an agent still working at quit gets no terminating hook, so stats.flushAsync() closes those sessions out synchronously (only the write is deferred) — otherwise their duration is lost. - starNag?.stop() - automations?.stop() - // Why: plugin hosts are forked children; dispose sends shutdown and - // escalates to SIGKILL so they cannot outlive the app. The promise joins - // the teardown barrier below — quitting before it resolves would let - // Electron exit first and orphan the hosts. - setPluginServiceForRpc(null) - pluginKillListService = null - pluginMarketplaceService = null - pluginMarketplaceInstaller = null - const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() - const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() - const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() - pluginService = null - setUnreadDockBadgeCount(0) - agentHookServer.stop() - // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a - // bare script path that cannot express the guard and would otherwise keep spawning after quit. - // Why bounded here: every other teardown member carries its own ceiling, and this one reaches - // $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the - // shared 20s deadline becomes the only thing ending the quit. - const grokHookCleanup = - process.platform === 'win32' - ? settleWithinMs( - removeManagedAgentHooksAsync({ agents: ['grok'] }), - GROK_HOOK_CLEANUP_DEADLINE_MS - ).then((settled) => { - if (settled.outcome === 'timed-out') { - console.warn('[agent-hooks] Grok hook cleanup on quit timed out') - return - } - if (settled.outcome === 'failed') { - console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) - return - } - // Why: removers report failures as statuses, so inspect details even after fulfillment. - for (const status of settled.value.filter((entry) => entry.detail)) { - console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) - } - }) - : Promise.resolve() - // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. - wslHookRelayManager.disposeAll() - const statsFlush = stats?.flushAsync() ?? Promise.resolve() - // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. - // Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms, - // so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367). - // Why retire headless page owners first: it closes those helpers without a duplicate close fanout. - const browserShutdown = (async (): Promise => { - await runtime?.getOffscreenBrowserBackend()?.destroyAll?.() - await runtime?.getAgentBrowserBridge()?.destroyAllSessions() - })() - // Why (review P2-4): local SSH browser routes own loopback listeners and, on the - // system-ssh path, `ssh -N -D` children that would otherwise outlive the app. - const localSshRouteShutdown = import('./browser/local-ssh-browser-route') - .then((routes) => routes.closeAllLocalSshBrowserRoutes()) - .catch(() => {}) - browserManager.setBrowserGuestStateChangedListener(null) - const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every - // active SSH lease detached in memory synchronously, and that flush is what persists it. - const sshShutdown = beginSshShutdown() - killAllPty() - const watcherShutdown = shutdownWatchersOnce() - const storeFlush = store?.flushAsync() ?? Promise.resolve() - // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a - // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. - const usageCacheFlush = Promise.all([ - claudeUsage?.flush(), - codexUsage?.flush(), - openCodeUsage?.flush() - ]).then(() => {}) - const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() - const skillUploadShutdown = runtime?.disposeSkillUploadSessions() ?? Promise.resolve() - - // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. - const ownedPid = process.pid - const ownedRuntimeId = runtime?.getRuntimeId() - const rpcStopAndClear = runtimeRpc - ? runtimeRpc - .stop() - .then(() => awaitRuntimeFileWatcherUnsubscribes()) - .then(() => { - if (ownedRuntimeId) { - // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). - clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) - } - }) - .catch((error) => { - console.error('[runtime] Failed to stop local RPC transport:', error) - }) - : Promise.resolve() - // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. - // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. - // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. - const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() - // Why: a wedged transport (half-open post-sleep socket) can leave one - // member unsettled forever and block app.quit() until Force Quit (#9447). - // Why stats/state join here: their writes are durable but not worth hanging the app for. - // Losing at most the last debounce interval beats a quit that never completes, and the - // temp+rename swap means a write cut short by the deadline leaves the old file intact. - settleTeardownWithinDeadline([ - { name: 'daemon', promise: daemonTeardown }, - { name: 'browser', promise: browserShutdown }, - { name: 'runtime-rpc', promise: rpcStopAndClear }, - { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown }, - { name: 'browser-client-hosts', promise: browserClientHostShutdown }, - { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, - { name: 'ssh', promise: sshShutdown }, - { name: 'plugin-hosts', promise: pluginHostShutdown }, - { name: 'skill-uploads', promise: skillUploadShutdown }, - { name: 'grok-hooks', promise: grokHookCleanup }, - { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, - { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, - { name: 'usage-cache', promise: usageCacheFlush }, - { name: 'stats', promise: statsFlush }, - { name: 'state', promise: storeFlush } - ]) - .then((pendingTeardowns) => { - if (pendingTeardowns.length > 0) { - console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) - } - }) - .then(() => shutdownTelemetry()) - .then(() => shutdownObservability()) - .catch(() => { - /* swallow — telemetry must never prevent app.quit() */ - }) - .then(() => { - daemonDisconnectDone = true - app.quit() - }) -}) - -app.on('window-all-closed', () => { - // Why: serve mode / disposable offscreen browser windows must not take down runtime RPC — the policy fn keeps the app alive. - // Why: on macOS a quit-in-progress (Cmd+Q) is canceled by the renderer buffer-capture deferral; re-trigger quit so it actually exits. - if ( - shouldQuitWhenAllWindowsClosed({ - platform: process.platform, - isQuitting, - isServeMode - }) - ) { - app.quit() - } -}) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 41b8b8e8da7..e517a834d5e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,2441 +1,48 @@ -/* eslint-disable max-lines */ -import { BrowserWindow, dialog, ipcMain, shell } from 'electron' -import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import { dirname, extname, join, resolve } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } from '../git/runner' -import { parseWslPath, toWindowsWslPath } from '../wsl' -import { tryDeleteWslUncPath } from '../wsl-unc-delete' import type { Store } from '../persistence' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, MarkdownDocument } from '../../shared/filesystem-entry-types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { Repo } from '../../shared/repo-types' -import type { TuiAgent } from '../../shared/tui-agent' -import type { GitPushTarget } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' -import type { SshMutationExpectation } from '../../shared/ssh-types' -import { sortDirEntries } from '../../shared/file-name-sort' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { - buildRgArgs, - createAccumulator, - DEFAULT_SEARCH_MAX_RESULTS, - finalize, - ingestRgJsonLine, - SEARCH_TIMEOUT_MS -} from '../../shared/text-search' -import { - getStatus, - getSubmoduleStatus, - abortMerge, - abortRebase, - detectConflictOperation, - getDiff, - commitChanges, - stageFile, - unstageFile, - bulkStageFiles, - bulkUnstageFiles, - bulkDiscardChanges, - discardChanges, - getStagedCommitContext, - getBranchCompare, - getBranchDiff, - getCommitCompare, - getCommitDiff -} from '../git/status' -import { getHistory } from '../git/history' -import { - cancelGenerateCommitMessageLocal, - cancelGeneratePullRequestFieldsLocal, - discoverCommitMessageModelsLocal, - discoverCommitMessageModelsRemote, - generateCommitMessageFromContext, - generatePullRequestFieldsFromContext, - resolveCommitMessageSettings, - type DiscoverCommitMessageModelsResult, - type CommitMessageGenerationTarget, - type GenerateCommitMessageResult, - type GeneratePullRequestFieldsResult -} from '../text-generation/commit-message-text-generation' -import { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import { getUpstreamStatus } from '../git/upstream' -import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' -import { gitSyncForkDefaultBranch } from '../git/fork-sync' -import { validateGitForkSyncExpectedUpstream } from '../../shared/git-fork-sync' -import { checkIgnoredPaths } from '../git/check-ignored-paths' -import { - appendFolderToGitignore, - findKnownHugeFolderPathsToIgnore -} from '../git/huge-folder-ignore' -import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' -import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' -import type { HostedReviewProvider } from '../../shared/hosted-review' -import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' -import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' -import { validateGitPushTarget } from '../git/push-target-validation' -import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' -import { resolveAuthorizedPath, authorizeExternalPath } from './filesystem-auth' -import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { validateGitRelativeFilePath, isENOENT } from './filesystem-path-containment' -import { listQuickOpenFiles } from './filesystem-list-files' -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { searchWithGitGrep } from './filesystem-search-git' -import { - getLocalGitOptionsForRegisteredWorktree, - getLocalGitOptionsForRepo, - getLocalRepoForRegisteredWorktree -} from './local-worktree-runtime-options' -import { - resolveSourceControlAiLinkedIssue, - resolveSourceControlAiLinkedIssueMeta -} from './source-control-ai-linked-issue' -import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' -import { checkRgAvailable } from './rg-availability' -import { - absorbPendingRipgrepSpawnError, - isRipgrepUnavailableExit, - killSpawnedRipgrepProcess -} from '../../shared/ripgrep-process-availability' -import { - getSshFilesystemProvider, - requireSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-git-dispatch' -import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' -import { loadPullRequestLinkedIssue } from '../source-control/pull-request-linked-issue' -import { - prepareLocalCommitMessageAgentEnv, - type CommitMessageAgentRuntimeTarget, - type CommitMessageAgentEnvironmentResolvers -} from '../text-generation/commit-message-agent-environment' -import { listRepoWorktrees } from '../repo-worktrees' -import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-error-diagnostics' -import { splitWorktreeId } from '../../shared/worktree/id' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerLocalLogTailHandlers } from './local-log-tail' -import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' -import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { - applyGitStatusUpstreamRefWatchRequest, - type GitStatusUpstreamRefWatchRequest -} from './git-status-upstream-ref-watch-request' - -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -const BINARY_PROBE_BYTES = 8192 -const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB -const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} -async function readLocalLogSnapshot(filePath: string): Promise<{ - content: string - isBinary: boolean - fileIdentity?: string -}> { - const handle = await open(filePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) - } - } finally { - await handle.close() - } -} - -type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } - -function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { - if (encoding === 'base64') { - return Buffer.from(content, 'base64') - } - return Buffer.from(content, 'utf8') -} - -type DownloadSession = { - destinationPath: string - tempPath: string - destinationExisted: boolean - handle: FileHandle - cleanupTimer: ReturnType - senderId: number -} - -const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 - -function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -async function cleanupLocalTransferPath(filePath: string | null): Promise { - if (!filePath) { - return - } - await rm(filePath, { force: true }).catch(() => {}) -} - -async function inspectDownloadDestination(destinationPath: string): Promise<{ existed: boolean }> { - try { - const destinationStat = await stat(destinationPath) - if (destinationStat.isDirectory()) { - throw new Error('Cannot download to a directory') - } - return { existed: true } - } catch (error) { - if (isENOENT(error)) { - return { existed: false } - } - throw error - } -} - -async function assertDestinationStillUnclaimed(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination file appeared before download completed') -} - -async function promoteDownloadedFile( - tempPath: string, - destinationPath: string, - destinationExisted: boolean -): Promise { - if (!destinationExisted) { - await assertDestinationStillUnclaimed(destinationPath) - await rename(tempPath, destinationPath) - return - } - - const backupPath = createSiblingTransferPath(destinationPath, 'backup') - let backupCreated = false - try { - await rename(destinationPath, backupPath) - backupCreated = true - await rename(tempPath, destinationPath) - await cleanupLocalTransferPath(backupPath) - } catch (error) { - if (backupCreated) { - await rename(backupPath, destinationPath).catch(() => {}) - } - throw error - } -} - -function comparableLocalPath(value: string): string { - const normalized = resolve(value) - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function getCandidateLocalWorktreePaths( - worktreePath: string, - resolvedWorktreePath: string -): Set { - return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) -} - -function hasRegisteredWorktreeMetaForRepo( - store: Store, - repoId: string, - candidatePaths: Set -): boolean { - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { - return true - } - } - return false -} - -function comparableRemotePath(value: string): string { - return value.replace(/[/\\]+$/g, '') -} - -function hasRegisteredRemoteWorktreeMetaForRepo( - store: Store, - repoId: string, - worktreePath: string -): boolean { - const comparableWorktreePath = comparableRemotePath(worktreePath) - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if ( - parsed?.repoId === repoId && - comparableRemotePath(parsed.worktreePath) === comparableWorktreePath - ) { - return true - } - } - return false -} - -async function localRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string -): Promise { - let resolvedWorktreePath: string - try { - resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) - } catch { - return false - } - const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) - if (candidatePaths.has(comparableLocalPath(repo.path))) { - return true - } - if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { - return true - } - try { - const worktrees = await listRepoWorktrees(repo) - return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) - } catch { - return false - } -} - -async function remoteRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string, - connectionId: string -): Promise { - const comparableWorktreePath = comparableRemotePath(worktreePath) - if (comparableRemotePath(repo.path) === comparableWorktreePath) { - return true - } - const provider = getSshGitProvider(connectionId) - if (!provider) { - return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) - } - try { - const worktrees = await provider.listWorktrees(repo.path) - return worktrees.some( - (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath - ) - } catch { - return false - } -} - -async function getRepoForSourceControlAi( - store: Store, - args: { repoId?: string; worktreePath: string; connectionId?: string } -): Promise { - if (!args.repoId) { - return null - } - const repo = store.getRepo(args.repoId) - if (!repo) { - return null - } - if (args.connectionId) { - if (repo.connectionId !== args.connectionId) { - return null - } - // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. - return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) - ? repo - : null - } - if (repo.connectionId) { - return null - } - // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. - return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null -} - -function getLocalAgentRuntimeTarget( - gitOptions: LocalProjectWorktreeGitOptions -): CommitMessageAgentRuntimeTarget { - return gitOptions.wslDistro - ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } - : { runtime: 'host' } -} - -async function resolveModelDiscoveryLocalPath( - store: Store, - requestedPath: string -): Promise { - try { - return await resolveRegisteredWorktreePath(requestedPath, store) - } catch (error) { - const folderWorkspaces = - typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] - const isFolderWorkspaceRoot = folderWorkspaces.some( - (workspace) => - comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) - ) - if (!isFolderWorkspaceRoot) { - throw error - } - return resolveAuthorizedPath(requestedPath, store) - } -} - -function getLocalTextGenerationTarget( - worktreePath: string, - gitOptions: LocalProjectWorktreeGitOptions, - env?: NodeJS.ProcessEnv -): Extract { - return { - kind: 'local', - cwd: worktreePath, - ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), - ...(env ? { env } : {}) - } -} - -function validateFullGitObjectId(value: string, label: string): string { - if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { - throw new Error(`${label} must be a full git object id`) - } - return value -} - -/** - * Check if a buffer appears to be binary (contains null bytes in first 8KB). - */ -function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -async function isBinaryFilePrefix(filePath: string): Promise { - const handle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - -function isDirectoryEntry(entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): boolean { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - if (entry.isSymbolicLink()) { - return false - } - if (entry.isDirectory()) { - return true - } - return false -} + createFilesystemHandlerContext, + type FilesystemHandlerContext +} from './filesystem/filesystem-handler-context' +import { registerFilesystemReadHandlers } from './filesystem/filesystem-read-handlers' +import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers' +import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers' +import { registerFilesystemSearchHandlers } from './filesystem/filesystem-search-handlers' +import { registerFilesystemGitStatusHandlers } from './filesystem/filesystem-git-status-handlers' +import { registerFilesystemGitCommitHandlers } from './filesystem/filesystem-git-commit-handlers' +import { registerFilesystemGitCommitGenerationHandlers } from './filesystem/filesystem-git-commit-generation-handlers' +import { registerFilesystemGitModelDiscoveryHandlers } from './filesystem/filesystem-git-model-discovery-handlers' +import { registerFilesystemGitPullRequestGenerationHandlers } from './filesystem/filesystem-git-pull-request-generation-handlers' +import { registerFilesystemGitRemoteHandlers } from './filesystem/filesystem-git-remote-handlers' +import { registerFilesystemGitDiffHandlers } from './filesystem/filesystem-git-diff-handlers' +import { registerFilesystemGitIndexHandlers } from './filesystem/filesystem-git-index-handlers' +import { registerFilesystemGitUrlHandlers } from './filesystem/filesystem-git-url-handlers' export function registerFilesystemHandlers( store: Store, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers ): void { - const activeTextSearches = new Map() - const downloadSessions = new Map() - - async function closeDownloadSession( - transferId: string, - cleanupTemp: boolean - ): Promise { - const session = downloadSessions.get(transferId) - if (!session) { - return null - } - downloadSessions.delete(transferId) - clearTimeout(session.cleanupTimer) - await session.handle.close().catch(() => {}) - if (cleanupTemp) { - await cleanupLocalTransferPath(session.tempPath) - } - return session - } - - function cleanupDownloadSessionsForSender(senderId: number): void { - for (const [transferId, session] of Array.from(downloadSessions)) { - if (session.senderId === senderId) { - void closeDownloadSession(transferId, true) - } - } - } - - // ─── Filesystem ───────────────────────────────────────── - ipcMain.handle( - 'fs:readDir', - async (_event, args: { dirPath: string; connectionId?: string }): Promise => { - // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. - let throwSite: ReadDirThrowSite = 'authorize' - try { - if (args.connectionId) { - throwSite = 'ssh-provider' - const provider = requireSshFilesystemProvider(args.connectionId) - // Why: re-sort locally — the remote relay may be an older build with - // lexicographic ordering. - return sortDirEntries(await provider.readDir(args.dirPath)) - } - throwSite = 'authorize' - const dirPath = await resolveAuthorizedPath(args.dirPath, store) - throwSite = 'readdir' - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries.map((entry) => ({ - name: entry.name, - isDirectory: isDirectoryEntry(entry), - isSymlink: entry.isSymbolicLink() - })) - return sortDirEntries(mapped) - } catch (error: unknown) { - recordCrashBreadcrumb( - 'fs_readdir_error', - buildReadDirErrorBreadcrumb({ - dirPath: args.dirPath, - connectionId: args.connectionId, - throwSite, - error - }) - ) - throw error - } - } - ) - - ipcMain.handle( - 'fs:readFile', - async ( - _event, - args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } - ): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.readFile(args.filePath) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - if (args.includeLocalLogMetadata === true) { - return readLocalLogSnapshot(filePath) - } - const stats = await stat(filePath) - const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (mimeType) { - const buffer = await readFile(filePath) - return { - content: buffer.toString('base64'), - isBinary: true, - // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. - isImage: true, - mimeType - } - } - - // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. - if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { - return { content: '', isBinary: true } - } - - const buffer = await readFile(filePath) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - - return { content: buffer.toString('utf-8'), isBinary: false } - } - ) - - ipcMain.handle( - 'fs:downloadFile', - async ( - event, - args: { filePath?: string; connectionId?: string } - ): Promise => { - const filePath = validateRequiredString(args?.filePath, 'filePath') - const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - const provider = requireSshFilesystemProvider(connectionId) - const remoteStat = await provider.stat(filePath) - if (remoteStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - if (!provider.downloadFile) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - - const remoteBasename = getRuntimePathBasename(filePath) - const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath }) - : await dialog.showSaveDialog({ defaultPath }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await provider.downloadFile(filePath, tempPath) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - registerFilesystemDownloadFolderHandlers() - - ipcMain.handle( - 'fs:saveDownloadedFile', - async ( - event, - args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } - ): Promise => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - if (typeof args?.content !== 'string') { - throw new Error('content is required') - } - const content = args.content - const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:startDownloadedFile', - async ( - event, - args: { suggestedName?: string } - ): Promise< - | { canceled: true } - | { - canceled: false - transferId: string - destinationPath: string - } - > => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - const transferId = randomUUID() - try { - const handle = await open(tempPath, 'wx') - const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN - const cleanupTimer = setTimeout(() => { - void closeDownloadSession(transferId, true) - }, DOWNLOAD_SESSION_TTL_MS) - if (typeof cleanupTimer.unref === 'function') { - cleanupTimer.unref() - } - downloadSessions.set(transferId, { - destinationPath, - tempPath, - destinationExisted: existed, - handle, - cleanupTimer, - senderId - }) - event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) - return { canceled: false, transferId, destinationPath } - } catch (error) { - await cleanupLocalTransferPath(tempPath) - throw error - } - } - ) - - ipcMain.handle( - 'fs:appendDownloadedFileChunk', - async ( - _event, - args: { transferId?: string; contentBase64?: string } - ): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') - const session = downloadSessions.get(transferId) - if (!session) { - throw new Error('Download session not found') - } - await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:finishDownloadedFile', - async ( - _event, - args: { transferId?: string } - ): Promise<{ canceled: false; destinationPath: string }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const session = await closeDownloadSession(transferId, false) - if (!session) { - throw new Error('Download session not found') - } - let promoted = false - try { - await promoteDownloadedFile( - session.tempPath, - session.destinationPath, - session.destinationExisted - ) - promoted = true - return { canceled: false, destinationPath: session.destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(session.tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:cancelDownloadedFile', - async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - await closeDownloadSession(transferId, true) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:listMarkdownDocuments', - async ( - _event, - args: { rootPath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const relativePaths = await provider.listFiles(args.rootPath) - return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) - } - - const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) - return listMarkdownDocuments(rootPath) - } - ) - - ipcMain.handle( - 'fs:writeFile', - async ( - _event, - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.writeFile(args.filePath, args.content) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - - try { - const fileStats = await lstat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } - - await writeFile(filePath, args.content, 'utf-8') - } - ) - - ipcMain.handle( - 'fs:deletePath', - async ( - _event, - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.deletePath(args.targetPath, args.recursive) - } - // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). - const targetPath = await resolveAuthorizedPath(args.targetPath, store, { - preserveSymlink: true - }) - - // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). - if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { - return - } - - // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). - try { - await shell.trashItem(targetPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - } - ) - - registerFilesystemMutationHandlers(store) - - ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { - authorizeExternalPath(args.targetPath) - }) - - ipcMain.handle( - 'fs:stat', - async ( - _event, - args: { filePath: string; connectionId?: string } - ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const s = await provider.stat(args.filePath) - return { size: s.size, isDirectory: s.type === 'directory', mtime: s.mtime } - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - const stats = await stat(filePath) - return { - size: stats.size, - isDirectory: stats.isDirectory(), - mtime: stats.mtimeMs - } - } - ) - - ipcMain.handle( - 'fs:pathExists', - async (_event, args: { filePath: string; connectionId?: string }): Promise => { - try { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - await provider.stat(args.filePath) - return true - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - await stat(filePath) - return true - } catch (error) { - if (isENOENT(error)) { - return false - } - throw error - } - } - ) - - // ─── Search ──────────────────────────────────────────── - ipcMain.handle( - 'fs:search', - async (event, args: SearchOptions & { connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.search(args) - } - const rootPath = await resolveAuthorizedPath(args.rootPath, store) - const localGitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.rootPath, - rootPath - ) - const maxResults = Math.max( - 1, - Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) - ) - const searchKey = `${event.sender.id}:${rootPath}` - // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - - if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { - return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) - } - - return new Promise((resolvePromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) - - // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - const previousChild = activeTextSearches.get(searchKey) - if (previousChild) { - killSpawnedRipgrepProcess(previousChild) - } - - const acc = createAccumulator() - let stdoutBuffer = '' - let resolved = false - let processErrorObserved = false - let unavailableExitObserved = false - let child: ChildProcess | null = null - let killTimeout: ReturnType - - const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) - : undefined - - const finish = (result: SearchResult | PromiseLike): void => { - if (resolved) { - return - } - resolved = true - if (activeTextSearches.get(searchKey) === child) { - activeTextSearches.delete(searchKey) - } - clearTimeout(killTimeout) - // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. - child?.stdout?.off('data', handleStdoutData) - child?.stderr?.off('data', handleStderrData) - child?.off('error', handleError) - child?.off('close', handleClose) - if (child) { - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - } - resolvePromise(result) - } - const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) - - const processLine = (line: string): void => { - const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop' && child) { - killSpawnedRipgrepProcess(child) - } - } - - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) - child = nextChild - activeTextSearches.set(searchKey, nextChild) - - const handleStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } - } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. - } - const handleError = (): void => { - processErrorObserved = true - if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() - return - } - resolveOnce() - } - const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if ( - child && - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !wslDistroForOutput - }) - ) { - unavailableExitObserved = true - resolveWithoutRipgrep() - return - } - if (stdoutBuffer) { - processLine(stdoutBuffer) - } - resolveOnce() - } - - nextChild.stdout!.setEncoding('utf-8') - nextChild.stdout!.on('data', handleStdoutData) - nextChild.stderr!.on('data', handleStderrData) - nextChild.once('error', handleError) - nextChild.once('close', handleClose) - - // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. - killTimeout = setTimeout(() => { - acc.truncated = true - if (child) { - killSpawnedRipgrepProcess(child) - } - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) - } - ) - - // ─── List all files (for quick-open) ───────────────────── - // Why #7721: token-keyed so a workspace switch aborts the prior full-tree scan (SSH otherwise stacks scans past the 30s timeout). - const listFilesCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'fs:listFiles', - async ( - event, - args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - maxResults?: number - searchQuery?: string - } - ): Promise => { - const controller = listFilesCancellations.begin(event, args.requestToken) - try { - if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. - if (!provider) { - return [] - } - // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. - if ( - args.searchQuery !== undefined && - provider.supportsQuickOpenSearch && - !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) - ) { - const legacyFiles = await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, - signal: controller?.signal - }) - const ranker = new QuickOpenPathRanker( - args.searchQuery, - args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT - ) - for (const file of legacyFiles) { - ranker.consider(file) - } - return ranker.result().paths - } - return await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), - ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), - signal: controller?.signal - }) - } - return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) - } finally { - listFilesCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { - listFilesCancellations.cancel(event, args.requestToken) - }) - - // ─── Git operations ───────────────────────────────────── - const gitStatusCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'git:status', - async ( - event, - args: { - worktreePath: string - connectionId?: string - admissionTier?: GitAdmissionTier - includeIgnored?: boolean - includeLineStats?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - branchLineTotalMergeBase?: string - requestToken?: string - } - ): Promise => { - const controller = gitStatusCancellations.begin(event, args.requestToken) - const options = { - includeIgnored: args.includeIgnored ?? false, - admissionTier: args.admissionTier ?? ('status' as const), - ...(args.includeLineStats === false ? { includeLineStats: false } : {}), - ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(args.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), - ...(args.bypassEffectiveUpstreamNegativeCache === true - ? { bypassEffectiveUpstreamNegativeCache: true } - : {}), - ...(controller ? { signal: controller.signal } : {}) - } - try { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). - return await provider.getStatus(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - // Why: one registered-worktree lookup feeds both — status polls this - // handler, and the scan walks every repo's worktree meta. - const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) - const gitOptions = getLocalGitOptionsForRepo(store, repo) - const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] - return await getStatus(worktreePath, { - ...options, - ...gitOptions, - ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) - }) - } finally { - gitStatusCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { - gitStatusCancellations.cancel(event, args.requestToken) - }) - - ipcMain.handle( - 'git:setStatusUpstreamRefWatch', - (_event, args: GitStatusUpstreamRefWatchRequest): Promise => - applyGitStatusUpstreamRefWatchRequest(store, args) - ) - - // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. - ipcMain.handle( - 'git:submoduleStatus', - async ( - _event, - args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getSubmoduleStatus(worktreePath, args.submodulePath, { - ...gitOptions, - ...(args.area === 'staged' ? { staged: true } : {}) - }) - } - ) - - ipcMain.handle( - 'git:checkIgnored', - async ( - _event, - args: { worktreePath: string; paths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.checkIgnoredPaths(args.worktreePath, paths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return checkIgnoredPaths(worktreePath, paths, gitOptions) - } - ) - - // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. - ipcMain.handle( - 'git:findHugeFoldersToIgnore', - async (_event, args: { worktreePath: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) - } - ) - - ipcMain.handle( - 'git:appendGitignore', - async (_event, args: { worktreePath: string; folderName: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return appendFolderToGitignore(worktreePath, args.folderName) - } - ) - - ipcMain.handle( - 'git:history', - async ( - _event, - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => { - const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getHistory(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getHistory(worktreePath, { ...options, ...gitOptions }) - } - ) - - // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. - ipcMain.handle( - 'git:conflictOperation', - async ( - _event, - args: { worktreePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.detectConflictOperation(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return detectConflictOperation(worktreePath) - } - ) - - ipcMain.handle( - 'git:abortMerge', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortMerge(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:abortRebase', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortRebase(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:diff', - async ( - _event, - args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getDiff( - args.worktreePath, - args.filePath, - args.staged, - args.compareAgainstHead - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:commit', - async ( - _event, - args: { worktreePath: string; message: string; connectionId?: string } - ): Promise<{ success: boolean; error?: string }> => { - // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. - if (typeof args.message !== 'string' || args.message.trim().length === 0) { - throw new Error('Commit message is required') - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.commit(args.worktreePath, args.message) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return commitChanges(worktreePath, args.message, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:generateCommitMessage', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'commitMessage', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - let context - try { - context = await provider.getStagedCommitContext(args.worktreePath) - } catch (error) { - console.error('[filesystem] Failed to read remote staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args) - ) - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - let context - try { - context = await getStagedCommitContext(worktreePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } catch (error) { - console.error('[filesystem] Failed to read staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args, worktreePath) - ) - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generateCommitMessageFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGenerateCommitMessage', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGenerateCommitMessageLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:discoverCommitMessageModels', - async ( - _event, - args: { agentId: string; worktreePath?: string; connectionId?: string } - ): Promise => { - const agentId = args.agentId - const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] - if (args.connectionId) { - if (!args.worktreePath) { - return { success: false, error: 'Missing worktree path for remote model discovery.' } - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: `No git provider for connection "${args.connectionId}"` - } - } - return discoverCommitMessageModelsRemote( - agentId as TuiAgent, - args.worktreePath, - (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), - agentCommandOverride - ) - } - let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } - let localDiscoveryOptions: Parameters[3] - if (args.worktreePath) { - const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro - localRuntimeTarget = wslDistro - ? { runtime: 'wsl', wslDistro } - : getLocalAgentRuntimeTarget(gitOptions) - localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - agentId, - commitMessageAgentEnv, - localRuntimeTarget - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return localDiscoveryOptions - ? discoverCommitMessageModelsLocal( - agentId as TuiAgent, - localEnv.env, - agentCommandOverride, - localDiscoveryOptions - ) - : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) - } - ) - - ipcMain.handle( - 'git:generatePullRequestFields', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: HostedReviewProvider - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'pullRequest', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: args.worktreePath, - connectionId: args.connectionId - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: args.worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, commandOptions) => - commandOptions?.timeoutMs !== undefined - ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeoutMs }) - : commandOptions?.timeout !== undefined - ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeout }) - : provider.exec(argv, args.worktreePath), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: worktreePath, - connectionId: args.connectionId, - localGitOptions: gitOptions - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, options) => - gitExecFileAsync(argv, { - cwd: worktreePath, - ...gitOptions, - ...(options?.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), - ...(options?.timeoutMs === undefined && options?.timeout === undefined - ? {} - : { timeout: options?.timeoutMs ?? options?.timeout }) - }), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generatePullRequestFieldsFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGeneratePullRequestFields', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGeneratePullRequestFieldsLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:branchCompare', - async ( - _event, - args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: GitAdmissionTier - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return args.admissionTier - ? provider.getBranchCompare(args.worktreePath, args.baseRef, { - admissionTier: args.admissionTier - }) - : provider.getBranchCompare(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchCompare(worktreePath, args.baseRef, { - ...gitOptions, - ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) - }) - } - ) - - ipcMain.handle( - 'git:commitCompare', - async ( - _event, - args: { worktreePath: string; commitId: string; connectionId?: string } - ): Promise => { - const commitId = validateFullGitObjectId(args.commitId, 'commitId') - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitCompare(args.worktreePath, commitId) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitCompare(worktreePath, commitId, gitOptions) - } - ) - - ipcMain.handle( - 'git:upstreamStatus', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) - } - ) - - ipcMain.handle( - 'git:fetch', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fetchRemote(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFetch(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:syncFork', - async ( - _event, - args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - } - ): Promise => { - const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { - required: true - }) - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:push', - async ( - _event, - args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: GitPushTarget - } - ): Promise => { - // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. - const publish = args.publish === true - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPush(worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true, - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:pull', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pullBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPull(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:fastForward', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFastForward(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:rebaseFromBase', - async ( - _event, - args: { worktreePath: string; baseRef: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.rebaseFromBase(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await gitPullRebaseFromBase(worktreePath, args.baseRef, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:branchDiff', - async ( - _event, - args: { - worktreePath: string - compare: { - baseRef: string - baseOid: string - headOid: string - mergeBase: string - } - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { - includePatch: true, - headOid: args.compare.headOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - return ( - results[0] ?? { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchDiff( - worktreePath, - { - mergeBase: args.compare.mergeBase, - headOid: args.compare.headOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:commitDiff', - async ( - _event, - args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') - const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitDiff(args.worktreePath, { - commitOid, - parentOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitDiff( - worktreePath, - { - commitOid, - parentOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:stage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.stageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:unstage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.unstageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:discard', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.discardChanges(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await discardChanges(worktreePath, filePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkDiscard', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkDiscardChanges(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkStage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkStageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkStageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkUnstage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkUnstageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:remoteFileUrl', - async ( - _event, - args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } - ): Promise => { - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteFileUrl(worktreePath, args.relativePath, args.line) - } - ) - - ipcMain.handle( - 'git:remoteCommitUrl', - async ( - _event, - args: { worktreePath: string; sha: string; connectionId?: string } - ): Promise => { - const sha = validateFullGitObjectId(args.sha, 'sha') - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteCommitUrl(args.worktreePath, sha) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteCommitUrl(worktreePath, sha) - } + const context: FilesystemHandlerContext = createFilesystemHandlerContext( + store, + commitMessageAgentEnv, + createSenderScopedRequestCancellations(), + createSenderScopedRequestCancellations() ) + registerFilesystemReadHandlers(context) + registerFilesystemDownloadHandlers(context) + registerFilesystemWriteHandlers(context) + registerFilesystemSearchHandlers(context) + registerFilesystemGitStatusHandlers(context) + registerFilesystemGitCommitHandlers(context) + registerFilesystemGitCommitGenerationHandlers(context) + registerFilesystemGitModelDiscoveryHandlers(context) + registerFilesystemGitPullRequestGenerationHandlers(context) + registerFilesystemGitRemoteHandlers(context) + registerFilesystemGitDiffHandlers(context) + registerFilesystemGitIndexHandlers(context) + registerFilesystemGitUrlHandlers(context) registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts new file mode 100644 index 00000000000..51a90c36213 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -0,0 +1,210 @@ +import { BrowserWindow, dialog, ipcMain } from 'electron' +import { randomUUID } from 'node:crypto' +import { open, writeFile } from 'node:fs/promises' +import { getRuntimePathBasename } from '../../../shared/cross-platform-path' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { sanitizeLocalDownloadFilename } from '../../local-download-filename' +import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + cleanupLocalTransferPath, + decodeDownloadedFileContent, + DOWNLOAD_SESSION_TTL_MS, + inspectDownloadDestination, + promoteDownloadedFile, + validateRequiredString, + createSiblingTransferPath, + type DownloadFileResult +} from './filesystem-file-helpers' + +export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { + const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context + + ipcMain.handle( + 'fs:downloadFile', + async ( + event, + args: { filePath?: string; connectionId?: string } + ): Promise => { + const filePath = validateRequiredString(args?.filePath, 'filePath') + const connectionId = validateRequiredString(args?.connectionId, 'connectionId') + const provider = requireSshFilesystemProvider(connectionId) + const remoteStat = await provider.stat(filePath) + if (remoteStat.type === 'directory') { + throw new Error('Cannot download a directory') + } + if (!provider.downloadFile) { + throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') + } + + const remoteBasename = getRuntimePathBasename(filePath) + const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath }) + : await dialog.showSaveDialog({ defaultPath }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await provider.downloadFile(filePath, tempPath) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + registerFilesystemDownloadFolderHandlers() + + ipcMain.handle( + 'fs:saveDownloadedFile', + async ( + event, + args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } + ): Promise => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + if (typeof args?.content !== 'string') { + throw new Error('content is required') + } + const content = args.content + const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:startDownloadedFile', + async ( + event, + args: { suggestedName?: string } + ): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + const transferId = randomUUID() + try { + const handle = await open(tempPath, 'wx') + const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN + const cleanupTimer = setTimeout(() => { + void closeDownloadSession(transferId, true) + }, DOWNLOAD_SESSION_TTL_MS) + if (typeof cleanupTimer.unref === 'function') { + cleanupTimer.unref() + } + downloadSessions.set(transferId, { + destinationPath, + tempPath, + destinationExisted: existed, + handle, + cleanupTimer, + senderId + }) + event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) + return { canceled: false, transferId, destinationPath } + } catch (error) { + await cleanupLocalTransferPath(tempPath) + throw error + } + } + ) + + ipcMain.handle( + 'fs:appendDownloadedFileChunk', + async ( + _event, + args: { transferId?: string; contentBase64?: string } + ): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') + const session = downloadSessions.get(transferId) + if (!session) { + throw new Error('Download session not found') + } + await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) + return { ok: true } + } + ) + + ipcMain.handle( + 'fs:finishDownloadedFile', + async ( + _event, + args: { transferId?: string } + ): Promise<{ canceled: false; destinationPath: string }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const session = await closeDownloadSession(transferId, false) + if (!session) { + throw new Error('Download session not found') + } + let promoted = false + try { + await promoteDownloadedFile( + session.tempPath, + session.destinationPath, + session.destinationExisted + ) + promoted = true + return { canceled: false, destinationPath: session.destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(session.tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:cancelDownloadedFile', + async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + await closeDownloadSession(transferId, true) + return { ok: true } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-file-helpers.ts b/src/main/ipc/filesystem/filesystem-file-helpers.ts new file mode 100644 index 00000000000..53e2eb3ec30 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-file-helpers.ts @@ -0,0 +1,173 @@ +import { randomUUID } from 'node:crypto' +import { open, rename, rm, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' +import { isENOENT } from '../filesystem-path-containment' + +// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +export const BINARY_PROBE_BYTES = 8192 +export const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +export const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 +// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + +export function validateRequiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`) + } + return value +} + +export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') +} + +export function createSiblingTransferPath(destinationPath: string, suffix: string): string { + // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) +} + +export async function cleanupLocalTransferPath(filePath: string | null): Promise { + if (!filePath) { + return + } + await rm(filePath, { force: true }).catch(() => {}) +} + +export async function inspectDownloadDestination( + destinationPath: string +): Promise<{ existed: boolean }> { + try { + const destinationStat = await stat(destinationPath) + if (destinationStat.isDirectory()) { + throw new Error('Cannot download to a directory') + } + return { existed: true } + } catch (error) { + if (isENOENT(error)) { + return { existed: false } + } + throw error + } +} + +export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { + try { + await stat(destinationPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + throw new Error('Destination file appeared before download completed') +} + +export async function promoteDownloadedFile( + tempPath: string, + destinationPath: string, + destinationExisted: boolean +): Promise { + if (!destinationExisted) { + await assertDestinationStillUnclaimed(destinationPath) + await rename(tempPath, destinationPath) + return + } + + const backupPath = createSiblingTransferPath(destinationPath, 'backup') + let backupCreated = false + try { + await rename(destinationPath, backupPath) + backupCreated = true + await rename(tempPath, destinationPath) + await cleanupLocalTransferPath(backupPath) + } catch (error) { + if (backupCreated) { + await rename(backupPath, destinationPath).catch(() => {}) + } + throw error + } +} + +/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, BINARY_PROBE_BYTES) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle: FileHandle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + +export function isDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + if (entry.isSymbolicLink()) { + return false + } + return entry.isDirectory() +} + +export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } + +export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts new file mode 100644 index 00000000000..4e55a39f76a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -0,0 +1,157 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import { + cancelGenerateCommitMessageLocal, + generateCommitMessageFromContext, + resolveCommitMessageSettings, + type GenerateCommitMessageResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getStagedCommitContext } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssue } from '../source-control-ai-linked-issue' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-worktree-helpers' + +export function registerFilesystemGitCommitGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generateCommitMessage', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'commitMessage', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + let context + try { + context = await provider.getStagedCommitContext(args.worktreePath) + } catch (error) { + console.error('[filesystem] Failed to read remote staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args) + ) + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + let context + try { + context = await getStagedCommitContext(worktreePath, { + ...gitOptions, + admissionTier: 'interactive' + }) + } catch (error) { + console.error('[filesystem] Failed to read staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args, worktreePath) + ) + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generateCommitMessageFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGenerateCommitMessage', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGenerateCommitMessageLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts new file mode 100644 index 00000000000..8040a766c2a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts @@ -0,0 +1,42 @@ +import { ipcMain } from 'electron' +import { commitChanges } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:commit', + async ( + _event, + args: { worktreePath: string; message: string; connectionId?: string } + ): Promise<{ success: boolean; error?: string }> => { + // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. + if (typeof args.message !== 'string' || args.message.trim().length === 0) { + throw new Error('Commit message is required') + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.commit(args.worktreePath, args.message) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return commitChanges(worktreePath, args.message, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts new file mode 100644 index 00000000000..ff706fcb078 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { getBranchDiff, getCommitDiff } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:branchDiff', + async ( + _event, + args: { + worktreePath: string + compare: { + baseRef: string + baseOid: string + headOid: string + mergeBase: string + } + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { + includePatch: true, + headOid: args.compare.headOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + return ( + results[0] ?? { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchDiff( + worktreePath, + { + mergeBase: args.compare.mergeBase, + headOid: args.compare.headOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) + + ipcMain.handle( + 'git:commitDiff', + async ( + _event, + args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') + const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitDiff(args.worktreePath, { + commitOid, + parentOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitDiff( + worktreePath, + { + commitOid, + parentOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts new file mode 100644 index 00000000000..fbd550c70e5 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts @@ -0,0 +1,173 @@ +import { ipcMain } from 'electron' +import { + stageFile, + unstageFile, + discardChanges, + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles +} from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:stage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.stageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:unstage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.unstageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:discard', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.discardChanges(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await discardChanges(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:bulkDiscard', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkDiscardChanges(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkStage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkStageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkStageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkUnstage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkUnstageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts new file mode 100644 index 00000000000..0933c08587e --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -0,0 +1,82 @@ +import { ipcMain } from 'electron' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import { + discoverCommitMessageModelsLocal, + discoverCommitMessageModelsRemote, + type DiscoverCommitMessageModelsResult +} from '../../text-generation/commit-message-text-generation' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { parseWslPath } from '../../wsl' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { + resolveModelDiscoveryLocalPath, + getLocalAgentRuntimeTarget +} from './filesystem-worktree-helpers' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitModelDiscoveryHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:discoverCommitMessageModels', + async ( + _event, + args: { agentId: string; worktreePath?: string; connectionId?: string } + ): Promise => { + const agentId = args.agentId + const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] + if (args.connectionId) { + if (!args.worktreePath) { + return { success: false, error: 'Missing worktree path for remote model discovery.' } + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + return discoverCommitMessageModelsRemote( + agentId as TuiAgent, + args.worktreePath, + (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + agentCommandOverride + ) + } + let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } + let localDiscoveryOptions: Parameters[3] + if (args.worktreePath) { + const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro + localRuntimeTarget = wslDistro + ? { runtime: 'wsl', wslDistro } + : getLocalAgentRuntimeTarget(gitOptions) + localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + agentId, + commitMessageAgentEnv, + localRuntimeTarget + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return localDiscoveryOptions + ? discoverCommitMessageModelsLocal( + agentId as TuiAgent, + localEnv.env, + agentCommandOverride, + localDiscoveryOptions + ) + : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts new file mode 100644 index 00000000000..b779fb44429 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -0,0 +1,226 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import type { HostedReviewProvider } from '../../../shared/hosted-review' +import { + cancelGeneratePullRequestFieldsLocal, + generatePullRequestFieldsFromContext, + resolveCommitMessageSettings, + type GeneratePullRequestFieldsResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getPullRequestDraftContext } from '../../text-generation/pull-request-context' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { gitExecFileAsync } from '../../git/runner' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssueMeta } from '../source-control-ai-linked-issue' +import { resolveHostedReviewBodyForGeneration } from '../../source-control/pull-request-template' +import { loadPullRequestLinkedIssue } from '../../source-control/pull-request-linked-issue' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-worktree-helpers' + +export function registerFilesystemGitPullRequestGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generatePullRequestFields', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: HostedReviewProvider + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'pullRequest', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: args.worktreePath, + connectionId: args.connectionId + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: args.worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv, commandOptions) => + commandOptions?.timeoutMs !== undefined + ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeoutMs }) + : commandOptions?.timeout !== undefined + ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeout }) + : provider.exec(argv, args.worktreePath), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: worktreePath, + connectionId: args.connectionId, + localGitOptions: gitOptions + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv, options) => + gitExecFileAsync(argv, { + cwd: worktreePath, + ...gitOptions, + ...(options?.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), + ...(options?.timeoutMs === undefined && options?.timeout === undefined + ? {} + : { timeout: options?.timeoutMs ?? options?.timeout }) + }), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGeneratePullRequestFields', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGeneratePullRequestFieldsLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts new file mode 100644 index 00000000000..83d1422e112 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts @@ -0,0 +1,322 @@ +import { ipcMain } from 'electron' +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../../shared/git-diff-compare-types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../../shared/git-fork-sync' +import type { GitUpstreamStatus } from '../../../shared/git-status-types' +import type { GitPushTarget } from '../../../shared/worktree/types' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' +import { getBranchCompare, getCommitCompare } from '../../git/status' +import { gitFetch, gitPush, gitPull, gitFastForward, gitPullRebaseFromBase } from '../../git/remote' +import { gitSyncForkDefaultBranch } from '../../git/fork-sync' +import { getUpstreamStatus } from '../../git/upstream' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitPushTarget } from '../../git/push-target-validation' +import { assertGitPushTargetShape } from '../../../shared/git-push-target-validation' +import { validateGitForkSyncExpectedUpstream } from '../../../shared/git-fork-sync' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitRemoteHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:branchCompare', + async ( + _event, + args: { + worktreePath: string + baseRef: string + connectionId?: string + admissionTier?: GitAdmissionTier + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return args.admissionTier + ? provider.getBranchCompare(args.worktreePath, args.baseRef, { + admissionTier: args.admissionTier + }) + : provider.getBranchCompare(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchCompare(worktreePath, args.baseRef, { + ...gitOptions, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) + }) + } + ) + + ipcMain.handle( + 'git:commitCompare', + async ( + _event, + args: { worktreePath: string; commitId: string; connectionId?: string } + ): Promise => { + const commitId = validateFullGitObjectId(args.commitId, 'commitId') + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitCompare(args.worktreePath, commitId) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitCompare(worktreePath, commitId, gitOptions) + } + ) + + ipcMain.handle( + 'git:upstreamStatus', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) + } + ) + + ipcMain.handle( + 'git:fetch', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fetchRemote(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFetch(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:syncFork', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + } + ): Promise => { + const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { + required: true + }) + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:push', + async ( + _event, + args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: GitPushTarget + } + ): Promise => { + // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. + const publish = args.publish === true + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPush(worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true, + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:pull', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pullBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPull(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:fastForward', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFastForward(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:rebaseFromBase', + async ( + _event, + args: { worktreePath: string; baseRef: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.rebaseFromBase(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await gitPullRebaseFromBase(worktreePath, args.baseRef, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts new file mode 100644 index 00000000000..09a80f9f993 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -0,0 +1,307 @@ +import { ipcMain } from 'electron' +import type { + GitConflictOperation, + GitStagingArea, + GitStatusResult +} from '../../../shared/git-status-types' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' +import type { GitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' +import { + getStatus, + getSubmoduleStatus, + abortMerge, + abortRebase, + detectConflictOperation, + getDiff +} from '../../git/status' +import { getHistory } from '../../git/history' +import { checkIgnoredPaths } from '../../git/check-ignored-paths' +import { + appendFolderToGitignore, + findKnownHugeFolderPathsToIgnore +} from '../../git/huge-folder-ignore' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import { + getLocalGitOptionsForRegisteredWorktree, + getLocalGitOptionsForRepo, + getLocalRepoForRegisteredWorktree +} from '../local-worktree-runtime-options' +import { getWorktreeSharedLinkPaths } from '../../git/worktree-shared-directories' +import { applyGitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerContext): void { + const { store, gitStatusCancellations } = context + ipcMain.handle( + 'git:status', + async ( + event, + args: { + worktreePath: string + connectionId?: string + admissionTier?: GitAdmissionTier + includeIgnored?: boolean + includeLineStats?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + branchLineTotalMergeBase?: string + requestToken?: string + } + ): Promise => { + const controller = gitStatusCancellations.begin(event, args.requestToken) + const options = { + includeIgnored: args.includeIgnored ?? false, + admissionTier: args.admissionTier ?? ('status' as const), + ...(args.includeLineStats === false ? { includeLineStats: false } : {}), + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(controller ? { signal: controller.signal } : {}) + } + try { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). + return await provider.getStatus(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + // Why: one registered-worktree lookup feeds both — status polls this + // handler, and the scan walks every repo's worktree meta. + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] + return await getStatus(worktreePath, { + ...options, + ...gitOptions, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }) + } finally { + gitStatusCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { + gitStatusCancellations.cancel(event, args.requestToken) + }) + + ipcMain.handle( + 'git:setStatusUpstreamRefWatch', + (_event, args: GitStatusUpstreamRefWatchRequest): Promise => + applyGitStatusUpstreamRefWatchRequest(store, args) + ) + + // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. + ipcMain.handle( + 'git:submoduleStatus', + async ( + _event, + args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getSubmoduleStatus(worktreePath, args.submodulePath, { + ...gitOptions, + ...(args.area === 'staged' ? { staged: true } : {}) + }) + } + ) + + ipcMain.handle( + 'git:checkIgnored', + async ( + _event, + args: { worktreePath: string; paths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.checkIgnoredPaths(args.worktreePath, paths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return checkIgnoredPaths(worktreePath, paths, gitOptions) + } + ) + + // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. + ipcMain.handle( + 'git:findHugeFoldersToIgnore', + async (_event, args: { worktreePath: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) + } + ) + + ipcMain.handle( + 'git:appendGitignore', + async (_event, args: { worktreePath: string; folderName: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return appendFolderToGitignore(worktreePath, args.folderName) + } + ) + + ipcMain.handle( + 'git:history', + async ( + _event, + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => { + const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getHistory(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getHistory(worktreePath, { ...options, ...gitOptions }) + } + ) + + // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. + ipcMain.handle( + 'git:conflictOperation', + async ( + _event, + args: { worktreePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.detectConflictOperation(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return detectConflictOperation(worktreePath) + } + ) + + ipcMain.handle( + 'git:abortMerge', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortMerge(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:abortRebase', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortRebase(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:diff', + async ( + _event, + args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getDiff( + args.worktreePath, + args.filePath, + args.staged, + args.compareAgainstHead + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts new file mode 100644 index 00000000000..cb14d98db60 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -0,0 +1,54 @@ +import { ipcMain } from 'electron' +import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' +import { getRemoteCommitUrl, getRemoteFileUrl } from '../../git/repo' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:remoteFileUrl', + async ( + _event, + args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } + ): Promise => { + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteFileUrl(worktreePath, args.relativePath, args.line) + } + ) + + ipcMain.handle( + 'git:remoteCommitUrl', + async ( + _event, + args: { worktreePath: string; sha: string; connectionId?: string } + ): Promise => { + const sha = validateFullGitObjectId(args.sha, 'sha') + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteCommitUrl(args.worktreePath, sha) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteCommitUrl(worktreePath, sha) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts new file mode 100644 index 00000000000..3aeb8320cc3 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -0,0 +1,75 @@ +import type { ChildProcess } from 'node:child_process' +import type { FileHandle } from 'node:fs/promises' +import type { Store } from '../../persistence' +import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' +import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import { cleanupLocalTransferPath } from './filesystem-file-helpers' + +export type DownloadSession = { + destinationPath: string + tempPath: string + destinationExisted: boolean + handle: FileHandle + cleanupTimer: ReturnType + senderId: number +} + +export type FilesystemHandlerContext = { + store: Store + commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers + activeTextSearches: Map + downloadSessions: Map + listFilesCancellations: SenderScopedRequestCancellations + gitStatusCancellations: SenderScopedRequestCancellations + closeDownloadSession: ( + transferId: string, + cleanupTemp: boolean + ) => Promise + cleanupDownloadSessionsForSender: (senderId: number) => void +} + +export function createFilesystemHandlerContext( + store: Store, + commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | undefined, + listFilesCancellations: SenderScopedRequestCancellations, + gitStatusCancellations: SenderScopedRequestCancellations +): FilesystemHandlerContext { + const activeTextSearches = new Map() + const downloadSessions = new Map() + + const closeDownloadSession = async ( + transferId: string, + cleanupTemp: boolean + ): Promise => { + const session = downloadSessions.get(transferId) + if (!session) { + return null + } + downloadSessions.delete(transferId) + clearTimeout(session.cleanupTimer) + await session.handle.close().catch(() => {}) + if (cleanupTemp) { + await cleanupLocalTransferPath(session.tempPath) + } + return session + } + + const cleanupDownloadSessionsForSender = (senderId: number): void => { + for (const [transferId, session] of Array.from(downloadSessions)) { + if (session.senderId === senderId) { + void closeDownloadSession(transferId, true) + } + } + } + + return { + store, + commitMessageAgentEnv, + activeTextSearches, + downloadSessions, + listFilesCancellations, + gitStatusCancellations, + closeDownloadSession, + cleanupDownloadSessionsForSender + } +} diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts new file mode 100644 index 00000000000..d1a86f1f748 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -0,0 +1,170 @@ +import { ipcMain } from 'electron' +import { readdir, readFile, stat } from 'node:fs/promises' +import { extname } from 'node:path' +import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../../shared/file-name-sort' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' +import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-store' +import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + BINARY_PROBE_BYTES, + isBinaryBuffer, + isBinaryFilePrefix, + isDirectoryEntry, + MAX_PREVIEWABLE_BINARY_SIZE, + MAX_TEXT_FILE_SIZE, + PREVIEWABLE_BINARY_MIME_TYPES, + readLocalLogSnapshot +} from './filesystem-file-helpers' + +export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:readDir', + async (_event, args: { dirPath: string; connectionId?: string }): Promise => { + // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. + let throwSite: ReadDirThrowSite = 'authorize' + try { + if (args.connectionId) { + throwSite = 'ssh-provider' + const provider = requireSshFilesystemProvider(args.connectionId) + // Why: re-sort locally — the remote relay may be an older build with lexicographic ordering. + return sortDirEntries(await provider.readDir(args.dirPath)) + } + const dirPath = await resolveAuthorizedPath(args.dirPath, store) + throwSite = 'readdir' + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = entries.map((entry) => ({ + name: entry.name, + isDirectory: isDirectoryEntry(entry), + isSymlink: entry.isSymbolicLink() + })) + return sortDirEntries(mapped) + } catch (error: unknown) { + recordCrashBreadcrumb( + 'fs_readdir_error', + buildReadDirErrorBreadcrumb({ + dirPath: args.dirPath, + connectionId: args.connectionId, + throwSite, + error + }) + ) + throw error + } + } + ) + + ipcMain.handle( + 'fs:readFile', + async ( + _event, + args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + ): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.readFile(args.filePath) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + if (args.includeLocalLogMetadata === true) { + return readLocalLogSnapshot(filePath) + } + const stats = await stat(filePath) + const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` + ) + } + + if (mimeType) { + const buffer = await readFile(filePath) + return { + content: buffer.toString('base64'), + isBinary: true, + // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. + isImage: true, + mimeType + } + } + + // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. + if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { + return { content: '', isBinary: true } + } + + const buffer = await readFile(filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } + ) + + ipcMain.handle( + 'fs:listMarkdownDocuments', + async ( + _event, + args: { rootPath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const relativePaths = await provider.listFiles(args.rootPath) + return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) + } + const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) + return listMarkdownDocuments(rootPath) + } + ) + + ipcMain.handle( + 'fs:stat', + async ( + _event, + args: { filePath: string; connectionId?: string } + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const result = await provider.stat(args.filePath) + return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + const stats = await stat(filePath) + return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + } + ) + + ipcMain.handle( + 'fs:pathExists', + async (_event, args: { filePath: string; connectionId?: string }): Promise => { + try { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + await provider.stat(args.filePath) + return true + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + await stat(filePath) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts new file mode 100644 index 00000000000..c2ec1531ead --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -0,0 +1,234 @@ +import { ipcMain } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { SearchOptions, SearchResult } from '../../../shared/code-search-types' +import { + buildRgArgs, + createAccumulator, + DEFAULT_SEARCH_MAX_RESULTS, + finalize, + ingestRgJsonLine, + SEARCH_TIMEOUT_MS +} from '../../../shared/text-search' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../../shared/ripgrep-process-availability' +import { toWindowsWslPath, parseWslPath } from '../../wsl' +import { wslAwareSpawn } from '../../git/runner' +import { + getSshFilesystemProvider, + requireSshFilesystemProvider +} from '../../providers/ssh-filesystem-dispatch' +import { checkRgAvailable } from '../rg-availability' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { listQuickOpenFiles } from '../filesystem-list-files' +import { searchWithGitGrep } from '../filesystem-search-git' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT } from './filesystem-file-helpers' + +export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { + const { store, activeTextSearches } = context + + ipcMain.handle( + 'fs:search', + async (event, args: SearchOptions & { connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.search(args) + } + const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.rootPath, + rootPath + ) + const maxResults = Math.max( + 1, + Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro + + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { + return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) + } + + return new Promise((resolvePromise) => { + const rgArgs = buildRgArgs(args.query, rootPath, args) + // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } + + const acc = createAccumulator() + let stdoutBuffer = '' + let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let child: ChildProcess | null = null + let killTimeout: ReturnType + + const transformAbsPath = wslDistroForOutput + ? (path: string): string => + path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + : undefined + + const finish = (result: SearchResult | PromiseLike): void => { + if (resolved) { + return + } + resolved = true + if (activeTextSearches.get(searchKey) === child) { + activeTextSearches.delete(searchKey) + } + clearTimeout(killTimeout) + // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. + child?.stdout?.off('data', handleStdoutData) + child?.stderr?.off('data', handleStderrData) + child?.off('error', handleError) + child?.off('close', handleClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) + } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) + const processLine = (line: string): void => { + const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) + } + } + + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + activeTextSearches.set(searchKey, nextChild) + + const handleStdoutData = (chunk: string): void => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + } + const handleStderrData = (): void => { + // Drain stderr so rg cannot block on a full pipe. + } + const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + } + + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', handleStdoutData) + nextChild.stderr!.on('data', handleStderrData) + nextChild.once('error', handleError) + nextChild.once('close', handleClose) + + // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. + killTimeout = setTimeout(() => { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + }, SEARCH_TIMEOUT_MS) + }) + } + ) + + const { listFilesCancellations } = context + ipcMain.handle( + 'fs:listFiles', + async ( + event, + args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + maxResults?: number + searchQuery?: string + } + ): Promise => { + const controller = listFilesCancellations.begin(event, args.requestToken) + try { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. + if (!provider) { + return [] + } + // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. + if ( + args.searchQuery !== undefined && + provider.supportsQuickOpenSearch && + !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) + ) { + const legacyFiles = await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, + signal: controller?.signal + }) + const ranker = new QuickOpenPathRanker( + args.searchQuery, + args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT + ) + for (const file of legacyFiles) { + ranker.consider(file) + } + return ranker.result().paths + } + return await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), + ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), + signal: controller?.signal + }) + } + return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) + } finally { + listFilesCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { + listFilesCancellations.cancel(event, args.requestToken) + }) +} diff --git a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts b/src/main/ipc/filesystem/filesystem-worktree-helpers.ts new file mode 100644 index 00000000000..18dd5c4f1f6 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-worktree-helpers.ts @@ -0,0 +1,185 @@ +import type { Repo } from '../../../shared/repo-types' +import type { Store } from '../../persistence' +import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' +import { resolve } from 'node:path' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { listRepoWorktrees } from '../../repo-worktrees' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { splitWorktreeId } from '../../../shared/worktree/id' + +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getCandidateLocalWorktreePaths( + worktreePath: string, + resolvedWorktreePath: string +): Set { + return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) +} + +function hasRegisteredWorktreeMetaForRepo( + store: Store, + repoId: string, + candidatePaths: Set +): boolean { + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { + return true + } + } + return false +} + +function comparableRemotePath(value: string): string { + return value.replace(/[/\\]+$/g, '') +} + +function hasRegisteredRemoteWorktreeMetaForRepo( + store: Store, + repoId: string, + worktreePath: string +): boolean { + const comparableWorktreePath = comparableRemotePath(worktreePath) + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if ( + parsed?.repoId === repoId && + comparableRemotePath(parsed.worktreePath) === comparableWorktreePath + ) { + return true + } + } + return false +} + +async function localRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string +): Promise { + let resolvedWorktreePath: string + try { + resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + } catch { + return false + } + const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + if (candidatePaths.has(comparableLocalPath(repo.path))) { + return true + } + if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { + return true + } + try { + const worktrees = await listRepoWorktrees(repo) + return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) + } catch { + return false + } +} + +async function remoteRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string, + connectionId: string +): Promise { + const comparableWorktreePath = comparableRemotePath(worktreePath) + if (comparableRemotePath(repo.path) === comparableWorktreePath) { + return true + } + const provider = getSshGitProvider(connectionId) + if (!provider) { + return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) + } + try { + const worktrees = await provider.listWorktrees(repo.path) + return worktrees.some( + (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath + ) + } catch { + return false + } +} + +export async function getRepoForSourceControlAi( + store: Store, + args: { repoId?: string; worktreePath: string; connectionId?: string } +): Promise { + if (!args.repoId) { + return null + } + const repo = store.getRepo(args.repoId) + if (!repo) { + return null + } + if (args.connectionId) { + if (repo.connectionId !== args.connectionId) { + return null + } + // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. + return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) + ? repo + : null + } + if (repo.connectionId) { + return null + } + // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. + return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null +} + +export function getLocalAgentRuntimeTarget( + gitOptions: LocalProjectWorktreeGitOptions +): CommitMessageAgentRuntimeTarget { + return gitOptions.wslDistro + ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } + : { runtime: 'host' } +} + +export async function resolveModelDiscoveryLocalPath( + store: Store, + requestedPath: string +): Promise { + try { + return await resolveRegisteredWorktreePath(requestedPath, store) + } catch (error) { + const folderWorkspaces = + typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] + const isFolderWorkspaceRoot = folderWorkspaces.some( + (workspace) => + comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) + ) + if (!isFolderWorkspaceRoot) { + throw error + } + return resolveAuthorizedPath(requestedPath, store) + } +} + +export function getLocalTextGenerationTarget( + worktreePath: string, + gitOptions: LocalProjectWorktreeGitOptions, + env?: NodeJS.ProcessEnv +): Extract { + return { + kind: 'local', + cwd: worktreePath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), + ...(env ? { env } : {}) + } +} + +export function validateFullGitObjectId(value: string, label: string): string { + const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + if (!pattern.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts new file mode 100644 index 00000000000..07d4de5b56a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -0,0 +1,91 @@ +import { ipcMain, shell } from 'electron' +import { lstat, writeFile } from 'node:fs/promises' +import type { SshMutationExpectation } from '../../../shared/ssh-types' +import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { tryDeleteWslUncPath } from '../../wsl-unc-delete' +import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { registerFilesystemMutationHandlers } from '../filesystem-mutations' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:writeFile', + async ( + _event, + args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.writeFile(args.filePath, args.content) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + await writeFile(filePath, args.content, 'utf-8') + } + ) + + ipcMain.handle( + 'fs:deletePath', + async ( + _event, + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.deletePath(args.targetPath, args.recursive) + } + // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). + const targetPath = await resolveAuthorizedPath(args.targetPath, store, { + preserveSymlink: true + }) + // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). + if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { + return + } + // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). + try { + await shell.trashItem(targetPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + } + ) + + registerFilesystemMutationHandlers(store) + + ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { + authorizeExternalPath(args.targetPath) + }) +} diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts index c30aed82d10..b1535b50408 100644 --- a/src/main/macos-press-and-hold-default.test.ts +++ b/src/main/macos-press-and-hold-default.test.ts @@ -267,20 +267,26 @@ describe('readBundleIdentifierFromExecutablePath', () => { }) describe('startup wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { const callIndex = source.indexOf( 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' ) const initDataPathIndex = source.indexOf('initDataPath()') - const readyIndex = source.indexOf('app.whenReady().then(') + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') expect(callIndex).toBeGreaterThanOrEqual(0) expect(readyIndex).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData // path is only captured there. expect(callIndex).toBeGreaterThan(initDataPathIndex) - expect(callIndex).toBeLessThan(readyIndex) + expect(preflightCall).toBeLessThan(readyIndex) }) }) diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts index cc2c7d7a26e..315eba73aa0 100644 --- a/src/main/quit-teardown-agent-browser-daemons.test.ts +++ b/src/main/quit-teardown-agent-browser-daemons.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and * every open tab's daemon outlived the app (#16367). */ -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-quit.ts'), 'utf8') function teardownBarrierMembers(): string { const start = source.indexOf('settleTeardownWithinDeadline([') @@ -25,7 +25,7 @@ describe('quit teardown of agent-browser daemons', () => { it('captures the destroyAllSessions promise instead of firing and forgetting', () => { expect(source).toMatch( - /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ + /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await state\.runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ ) // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 8ce0b167a4a..5d12b979bc3 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1,2182 +1,10 @@ -/* eslint-disable max-lines -- Why: centralizes polling, stale-data handling, account-switch fetch semantics, and renderer push coordination in one place */ -import type { BrowserWindow } from 'electron' -import type { - CodexRateLimitResetResult, - RateLimitState, - ProviderRateLimits, - InactiveAccountUsage, - RateLimitRuntimeTarget -} from '../../shared/rate-limit-types' -import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' -import type { InactiveClaudeAccountInfo } from './claude-fetcher' -import { mapClaudeUsageWindow } from './claude-usage-window' -import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' -import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import { - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget, - type NormalizedClaudeAccountSelectionTarget -} from '../claude-accounts/runtime-selection' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' -import { fetchKimiRateLimits } from './kimi-fetcher' -import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' -import { fetchGrokRateLimits } from './grok-fetcher' -import { readGrokAuthSession } from './grok-auth' -import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' -import { fetchMiniMaxRateLimits } from './minimax-fetcher' -import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' -import { - normalizeCodexAccountSelectionTarget, - type CodexAccountSelectionTarget, - type NormalizedCodexAccountSelectionTarget -} from '../codex-accounts/runtime-selection' -import type { CodexRateLimitHomeResolution } from '../codex-accounts/runtime-home-service' +import { RateLimitServiceConfiguration } from './service/service-configuration' -export type InactiveCodexAccountInfo = { - id: string - resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } -} +export type { InactiveCodexAccountInfo } from './service/service-types' -type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => CodexRateLimitHomeResolution -type KimiHomeResolver = () => Promise -type ClaudeAuthPreparationResolver = ( - target?: ClaudeAccountSelectionTarget -) => Promise - -type OpenCodeGoRateLimitConfig = { - sessionCookie: string - workspaceIdOverride: string -} - -type MiniMaxRateLimitConfig = { - sessionCookie: string - groupId: string - models: string -} - -type MiniMaxResolvedConfig = { - config: MiniMaxRateLimitConfig - error: string | null -} - -type GeminiCliOAuthEnabledResolver = () => boolean -type ActiveRateLimitProvider = ProviderRateLimits['provider'] -type ActiveProviderState = { - provider: ActiveRateLimitProvider - limits: ProviderRateLimits | null -} -type ActiveWindowRefreshPlan = - | { kind: 'none' } - | { kind: 'full' } - | { kind: 'providers'; providers: ActiveRateLimitProvider[] } - -// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. -const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes -const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. -const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. -const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts -const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS -// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. -const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS -const MAX_ACTIVE_FAILURE_STREAK = 8 -// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. -const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ - 'claude', - 'codex', - 'grok' -]) -const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped -// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". -const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 -// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. -const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 -const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open -// Why: each inactive Codex probe spawns a real codex process inside that -// account's live credential home; pace them out instead of bursting every -// account the moment the switcher opens. -const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 -const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 - -// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). -type InternalRateLimitState = { - claude: ProviderRateLimits | null - codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null - opencodeGo: ProviderRateLimits | null - kimi: ProviderRateLimits | null - antigravity: ProviderRateLimits | null - minimax: ProviderRateLimits | null - grok: ProviderRateLimits | null -} - -function normalizePollingInterval(ms: number): number { - if (!Number.isFinite(ms)) { - return DEFAULT_POLL_MS - } - return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) -} - -function isSystemDefaultClaudeAuth( - authPreparation: ClaudeRuntimeAuthPreparation | undefined -): boolean { - // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. - if (!authPreparation) { - return true - } - const provenance = authPreparation?.provenance - return provenance === 'system' || Boolean(provenance?.endsWith(':system')) -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { - // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. - const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') - return trimmed || null -} - -function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.resolve() - } - return new Promise((resolve) => { - const onAbort = (): void => { - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort) - resolve() - }, ms) - signal.addEventListener('abort', onAbort, { once: true }) - }) -} - -function isSameUsageWindow( - a: ProviderRateLimits['session'], - b: ProviderRateLimits['session'] -): boolean { - if (!a || !b) { - return a === b - } - return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt -} - -export class RateLimitService { - private state: InternalRateLimitState = { - claude: null, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null - } - private grokAuthConfigured = readGrokAuthSession().status === 'ok' - private pollInterval: number = DEFAULT_POLL_MS - private timer: ReturnType | null = null - private deferredStartupRefreshTimer: ReturnType | null = null - // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. - private lastActiveFailureRetryAtByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. - private activeFailureStreakByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - private mainWindow: BrowserWindow | null = null - private detachWindowListeners: (() => void) | null = null - private isFetching = false - private fullFetchQueued = false - private codexOnlyFetchQueued = false - private claudeOnlyFetchQueued = false - private grokOnlyFetchQueued = false - private activeFetchAbortControllers = new Set() - private fetchIdleResolvers: (() => void)[] = [] - private codexFetchGeneration = 0 - private claudeFetchGeneration = 0 - // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. - private lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null - private opencodeFetchGeneration = 0 - private minimaxFetchGeneration = 0 - private lastOpencodeConfigHash = '' - private lastMiniMaxConfigHash = '' - private codexHomePathResolver: CodexHomePathResolver | null = null - private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - // Why: resolved per cycle — the local-account runtime policy can flip between fetches. - private kimiHomeResolver: KimiHomeResolver | null = null - private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null - private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null - private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null - private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null - private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null - private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null - private networkProxySettingsResolver: (() => NetworkProxySettings) | null = null - private inactiveClaudeCache = new Map() - private inactiveCodexCache = new Map() - private inactiveClaudeFetching = new Set() - private inactiveCodexFetching = new Set() - private inactiveCodexFetchInFlight = false - private lastInactiveClaudeFetchAt = 0 - private inactiveClaudeAccountsGeneration = 0 - private lastInactiveCodexFetchAt = 0 - private inactiveCodexAccountsGeneration = 0 - private stateListeners = new Set<(state: RateLimitState) => void>() - - constructor() {} - - onStateChange(listener: (state: RateLimitState) => void): () => void { - this.stateListeners.add(listener) - return () => { - this.stateListeners.delete(listener) - } - } - - setCodexHomePathResolver(resolver: CodexHomePathResolver): void { - this.codexHomePathResolver = resolver - } - - // Why: `skip` and a `ready` null are different answers — null still means the - // system-default lane, so it must never stand in for "don't fetch" (#STA-4422). - private resolveCodexHome(target?: CodexAccountSelectionTarget): { - skip: boolean - homePath: string | null - } { - const resolution = this.codexHomePathResolver?.(target) - if (!resolution) { - return { skip: false, homePath: null } - } - return resolution.kind === 'skip' - ? { skip: true, homePath: null } - : { skip: false, homePath: resolution.codexHomePath } - } - - setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { - this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) - } - - setKimiHomeResolver(resolver: KimiHomeResolver): void { - this.kimiHomeResolver = resolver - } - - // Why: resolving a WSL home probes wsl.exe, so it must not run before the other - // providers' fetches are started; chaining keeps the no-resolver path immediate. - private fetchKimiWithResolvedHome(): Promise { - const pendingHome = this.kimiHomeResolver?.() - return pendingHome - ? pendingHome.then((home) => fetchKimiRateLimits({ home })) - : fetchKimiRateLimits({ home: undefined }) - } - - setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { - this.claudeAuthPreparationResolver = resolver - } - - setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { - this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) - } - - setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { - this.openCodeGoConfigResolver = resolver - } - - setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { - this.miniMaxConfigResolver = resolver - } - - setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { - this.geminiCliOAuthEnabledResolver = resolver - } - - setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { - this.networkProxySettingsResolver = resolver - } - - setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { - this.inactiveClaudeAccountsResolver = resolver - this.inactiveClaudeAccountsGeneration += 1 - } - - setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { - this.inactiveCodexAccountsResolver = resolver - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - } - - attach(mainWindow: BrowserWindow): void { - this.detachWindowListeners?.() - this.mainWindow = mainWindow - const refreshOnResume = (): void => { - void this.refreshIfWindowActive() - } - // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. - const detachWindowListeners = (): void => { - mainWindow.removeListener('focus', refreshOnResume) - mainWindow.removeListener('show', refreshOnResume) - mainWindow.removeListener('restore', refreshOnResume) - mainWindow.removeListener('closed', onClosed) - } - const onClosed = (): void => { - detachWindowListeners() - if (this.detachWindowListeners === detachWindowListeners) { - this.detachWindowListeners = null - } - if (this.mainWindow === mainWindow) { - this.mainWindow = null - } - } - mainWindow.on('focus', refreshOnResume) - mainWindow.on('show', refreshOnResume) - mainWindow.on('restore', refreshOnResume) - mainWindow.on('closed', onClosed) - this.detachWindowListeners = detachWindowListeners - } - - start(options: { fetchImmediately?: boolean } = {}): void { - if (options.fetchImmediately !== false) { - void this.fetchAll() - } else { - this.scheduleDeferredStartupRefresh() - } - this.startTimer() - } - - stop(): void { - this.abortActiveFetchCycle() - this.clearQueuedFetches() - this.inactiveClaudeFetching.clear() - this.inactiveCodexFetching.clear() - this.resolveAndClearFetchIdleWaiters() - this.stopTimer() - this.clearDeferredStartupRefresh() - this.detachWindowListeners?.() - this.detachWindowListeners = null - this.mainWindow = null - } - - getState(): RateLimitState { - this.pruneInactiveClaudeState() - this.pruneInactiveCodexState() - return { - ...this.state, - // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. - minimaxCookieConfigured: hasMiniMaxSessionCookie(), - grokAuthConfigured: this.grokAuthConfigured, - claudeTarget: this.claudeFetchTarget, - codexTarget: this.codexFetchTarget, - inactiveClaudeAccounts: this.buildInactiveArray( - this.inactiveClaudeCache, - this.inactiveClaudeFetching - ), - inactiveCodexAccounts: this.buildInactiveArray( - this.inactiveCodexCache, - this.inactiveCodexFetching - ) - } - } - - async refresh(): Promise { - // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. - await this.fetchAll({ force: true }) - return this.getState() - } - - async refreshIfStale(): Promise { - // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - return this.getState() - } - - async refreshGrok(): Promise { - await this.fetchGrokOnly({ force: true }) - return this.getState() - } - - invalidateMiniMaxCredentialState(): void { - this.minimaxFetchGeneration += 1 - // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. - this.updateState({ - ...this.state, - minimax: this.withFetchingStatus(null, 'minimax') - }) - } - - async refreshForCodexAccountChange( - outgoingAccountId?: string | null, - target?: CodexAccountSelectionTarget - ): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - // Why: weekly-only plans report no session window, so gating on session alone - // dropped their snapshot and left the switcher's inline bars empty. - if ( - outgoingAccountId && - (this.state.codex?.session || this.state.codex?.weekly) && - this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - ) { - this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) - } - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.codex = 0 - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - // Why: the switch must NOT reset the inactive-fetch debounce — re-probing - // every inactive account per switch spawns codex in each credential home - // and endangers rotating refresh tokens; the switcher shows the cached - // snapshot (seeded above for the outgoing account) until the debounce ends. - // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(null, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - this.activeFailureStreakByProvider.codex = 0 - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async consumeCodexRateLimitResetCredit(options: { - idempotencyKey: string - target: RateLimitRuntimeTarget - codexHomePath: string | null - }): Promise { - const codexTarget = normalizeCodexAccountSelectionTarget(options.target) - const codexHomePath = options.codexHomePath - const scopedStateBeforeReset = this.getState() - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - if (missingWslCodexHome) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') - } - try { - const outcome = await consumeCodexRateLimitResetCredit({ - codexHomePath, - idempotencyKey: options.idempotencyKey - }) - const state = await this.fetchCodexResetResultState( - codexTarget, - codexHomePath, - scopedStateBeforeReset - ) - return { outcome, state } - } catch (error) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw error - } - } - - async refreshForClaudeAccountChange( - outgoingAccountId?: string | null, - target?: ClaudeAccountSelectionTarget - ): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. - if ( - outgoingAccountId && - this.state.claude?.session && - this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - ) { - this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) - } - this.claudeFetchTarget = nextTarget - this.inactiveClaudeAccountsGeneration += 1 - this.pruneInactiveClaudeState() - this.claudeFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.claude = 0 - // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. - this.lastClaudeAuthSnapshot = null - this.lastInactiveClaudeFetchAt = 0 - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(null, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - this.claudeFetchTarget = nextTarget - this.claudeFetchGeneration += 1 - this.activeFailureStreakByProvider.claude = 0 - if (targetChanged) { - // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. - this.lastClaudeAuthSnapshot = null - } - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshAfterClaudeLivePtysDrained(): Promise { - // Why: "Waiting for Claude session" can only recover once no live claude - // owns the credentials. Refetch on the last PTY exit instead of leaving - // the stale terminal error up until the failure backoff elapses. - if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { - return - } - this.activeFailureStreakByProvider.claude = 0 - await this.fetchClaudeOnly({ force: true }) - } - - async fetchInactiveClaudeAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveClaudeState() - if (this.inactiveClaudeFetching.size > 0) { - return - } - const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - const fetchGeneration = this.inactiveClaudeAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - - for (const account of accounts) { - this.inactiveClaudeFetching.add(account.id) - } - this.pushToRenderer() - - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - const fresh = await fetchManagedAccountUsage(account, { - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveClaudeCache.get(account.id) ?? null - this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeCache.delete(account.id) - } - } - this.inactiveClaudeFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { - this.lastInactiveClaudeFetchAt = Date.now() - } - } finally { - this.finishFetchCycle(controller) - } - } - - async fetchInactiveCodexAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveCodexState() - if (this.inactiveCodexFetchInFlight) { - return - } - const accounts = this.inactiveCodexAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. - const fetchGeneration = this.inactiveCodexAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - this.inactiveCodexFetchInFlight = true - - let staggerNextProbe = false - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - if (staggerNextProbe) { - await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) - // Why: the account set can change while the stagger delay runs. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - } - const home = account.resolveHome() - if (home.kind === 'skip') { - continue - } - staggerNextProbe = true - this.inactiveCodexFetching.add(account.id) - this.pushToRenderer() - try { - // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. - // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. - const fresh = await fetchCodexRateLimits({ - codexHomePath: home.managedHomePath, - allowPtyFallback: false, - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveCodexCache.get(account.id) ?? null - this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one failure from aborting the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexCache.delete(account.id) - } - } - this.inactiveCodexFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { - this.lastInactiveCodexFetchAt = Date.now() - } - } finally { - this.inactiveCodexFetchInFlight = false - this.finishFetchCycle(controller) - } - } - - evictInactiveClaudeCache(accountId: string): void { - this.inactiveClaudeAccountsGeneration += 1 - this.inactiveClaudeCache.delete(accountId) - this.inactiveClaudeFetching.delete(accountId) - this.pushToRenderer() - } - - private isCurrentInactiveClaudeAccount(accountId: string): boolean { - return (this.inactiveClaudeAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private isCurrentInactiveCodexAccount(accountId: string): boolean { - return (this.inactiveCodexAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private pruneInactiveClaudeState(): void { - const currentIds = new Set( - (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveClaudeCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeCache.delete(accountId) - } - } - for (const accountId of this.inactiveClaudeFetching) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeFetching.delete(accountId) - } - } - } - - private pruneInactiveCodexState(): void { - const currentIds = new Set( - (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveCodexCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveCodexCache.delete(accountId) - } - } - for (const accountId of this.inactiveCodexFetching) { - if (!currentIds.has(accountId)) { - this.inactiveCodexFetching.delete(accountId) - } - } - } - - evictInactiveCodexCache(accountId: string): void { - // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. - this.inactiveCodexCache.delete(accountId) - this.inactiveCodexFetching.delete(accountId) - this.pushToRenderer() - } - - setPollingInterval(ms: number): void { - this.pollInterval = normalizePollingInterval(ms) - if (this.timer) { - this.stopTimer() - this.startTimer() - } - } - - // --------------------------------------------------------------------------- - // Internal - // --------------------------------------------------------------------------- - - private startTimer(): void { - this.stopTimer() - this.timer = setInterval(() => { - if (!this.shouldBackgroundPoll()) { - return - } - void this.fetchAll() - }, this.pollInterval) - } - - private stopTimer(): void { - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - } - - private scheduleDeferredStartupRefresh(): void { - this.clearDeferredStartupRefresh() - this.deferredStartupRefreshTimer = setTimeout(() => { - this.deferredStartupRefreshTimer = null - void this.refreshIfWindowActive() - }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) - } - - private clearDeferredStartupRefresh(): void { - if (this.deferredStartupRefreshTimer) { - clearTimeout(this.deferredStartupRefreshTimer) - this.deferredStartupRefreshTimer = null - } - } - - private shouldBackgroundPoll(): boolean { - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return false - } - // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). - if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { - return false - } - return this.mainWindow.isFocused() - } - - private getActiveProviderState(): ActiveProviderState[] { - // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. - const byProvider: Record = { - claude: this.state.claude, - codex: this.state.codex, - gemini: this.state.gemini, - 'opencode-go': this.state.opencodeGo, - kimi: this.state.kimi, - minimax: this.state.minimax, - grok: this.state.grok, - antigravity: this.state.antigravity - } - return Object.entries(byProvider).map(([provider, limits]) => ({ - provider: provider as ActiveRateLimitProvider, - limits - })) - } - - private getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { - const retryableFailures: ActiveRateLimitProvider[] = [] - for (const { provider, limits } of this.getActiveProviderState()) { - if (!limits || limits.status === 'idle' || limits.status === 'fetching') { - return { kind: 'full' } - } - if (limits.status === 'ok' || limits.status === 'unavailable') { - if (now - limits.updatedAt >= MIN_REFETCH_MS) { - return { kind: 'full' } - } - continue - } - // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. - if (limits.status === 'error') { - // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. - if (this.isRetryAfterActive(limits)) { - continue - } - const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] - const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ? Math.min( - ACTIVE_FAILURE_REFETCH_MS * - 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), - MAX_ACTIVE_FAILURE_REFETCH_MS - ) - : MIN_REFETCH_MS - if (now - lastRetryAt >= throttleMs) { - retryableFailures.push(provider) - } - } - } - - if (retryableFailures.length === 0) { - return { kind: 'none' } - } - return { kind: 'providers', providers: retryableFailures } - } - - private async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { - if (plan.kind === 'none') { - return - } - if (plan.kind === 'full') { - // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. - // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. - if (!this.isFetching) { - const now = Date.now() - for (const { provider, limits } of this.getActiveProviderState()) { - if (limits?.status === 'error') { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - } - } - await this.fetchAll() - return - } - - // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. - if (this.isFetching) { - return - } - - const now = Date.now() - for (const provider of plan.providers) { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - - const canRefreshIndividually = plan.providers.every((provider) => - INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ) - if (!canRefreshIndividually) { - await this.fetchAll() - return - } - - // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. - if (plan.providers.includes('claude')) { - await this.fetchClaudeOnly() - } - if (plan.providers.includes('codex')) { - await this.fetchCodexOnly() - } - if (plan.providers.includes('grok')) { - await this.fetchGrokOnly() - } - } - - private async refreshIfWindowActive(): Promise { - if (!this.shouldBackgroundPoll()) { - return - } - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - } - - private async fetchAll(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.fullFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - shouldContinue = true - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchCodexOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.codexOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - shouldContinue = true - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchClaudeOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.claudeOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchGrokOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.grokOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private waitForFetchIdle(): Promise { - if ( - !this.isFetching && - !this.fullFetchQueued && - !this.codexOnlyFetchQueued && - !this.claudeOnlyFetchQueued && - !this.grokOnlyFetchQueued - ) { - return Promise.resolve() - } - // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. - return new Promise((resolve) => { - this.fetchIdleResolvers.push(resolve) - }) - } - - private resolveFetchIdleWaiters(): void { - if ( - this.isFetching || - this.fullFetchQueued || - this.codexOnlyFetchQueued || - this.claudeOnlyFetchQueued || - this.grokOnlyFetchQueued - ) { - return - } - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private beginFetchCycle(): AbortController { - const controller = new AbortController() - this.activeFetchAbortControllers.add(controller) - return controller - } - - private finishFetchCycle(controller: AbortController): void { - this.activeFetchAbortControllers.delete(controller) - } - - private async runWithFetchAbortSignal( - fn: (signal: AbortSignal) => Promise - ): Promise { - const controller = this.beginFetchCycle() - try { - await fn(controller.signal) - return controller.signal - } finally { - this.finishFetchCycle(controller) - } - } - - private abortActiveFetchCycle(): void { - for (const controller of this.activeFetchAbortControllers) { - controller.abort() - } - this.activeFetchAbortControllers.clear() - } - - private clearQueuedFetches(): void { - this.fullFetchQueued = false - this.codexOnlyFetchQueued = false - this.claudeOnlyFetchQueued = false - this.grokOnlyFetchQueued = false - } - - private resolveAndClearFetchIdleWaiters(): void { - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private isSameCodexTarget( - left: NormalizedCodexAccountSelectionTarget, - right: NormalizedCodexAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private isSameClaudeTarget( - left: NormalizedClaudeAccountSelectionTarget, - right: NormalizedClaudeAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private getCodexProvenance( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null - ): string { - const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' - return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` - } - - private getMissingWslCodexHomeResult( - target: NormalizedCodexAccountSelectionTarget - ): ProviderRateLimits | null { - if (target.runtime !== 'wsl') { - return null - } - return { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, - status: 'error' - } - } - - private async fetchCodexResetResultState( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null, - stateBeforeReset: RateLimitState - ): Promise { - const controller = this.beginFetchCycle() - let fresh: ProviderRateLimits - try { - fresh = await fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal: controller.signal - }) - } catch (error) { - fresh = { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: toErrorMessage(error), - status: 'error' - } - } finally { - this.finishFetchCycle(controller) - } - - const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) - const currentCodexHome = this.resolveCodexHome(target) - // Why: a skip has no provenance to compare, so treat it as no longer active - // rather than publishing this result against the system-default lane. - const stillActive = - !currentCodexHome.skip && - this.isSameCodexTarget(this.codexFetchTarget, target) && - this.getCodexProvenance(target, currentCodexHome.homePath) === - this.getCodexProvenance(target, codexHomePath) - if (stillActive) { - // Why: this post-redemption read is newer than every Codex fetch that - // started before it, so invalidate those results before publishing it. - this.codexFetchGeneration += 1 - this.trackActiveFailureStreak('codex', fresh) - this.updateState({ - ...this.state, - codex: this.applyStalePolicy(fresh, this.state.codex) - }) - } - - // Why: the caller must receive the redeemed target even if the global UI - // switched targets while the provider mutation was in flight. - return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } - } - - private shouldAllowCodexPtyFallback(): boolean { - // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. - return process.platform !== 'win32' - } - - private shouldAllowClaudePtyFallback( - authPreparation: ClaudeRuntimeAuthPreparation | undefined - ): boolean { - // Why: Windows hidden PTY support is less reliable than host/WSL shells. - if (process.platform === 'win32') { - return false - } - // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. - return !isSystemDefaultClaudeAuth(authPreparation) - } - - private shouldAllowClaudeUsagePanelSupplement(): boolean { - // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. - return process.platform !== 'win32' - } - - private resolveMiniMaxConfig(): MiniMaxResolvedConfig { - try { - return { - config: this.miniMaxConfigResolver?.() ?? { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: null - } - } catch (error) { - // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. - return { - config: { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: toErrorMessage(error) - } - } - } - - private getMiniMaxCredentialError(message: string): ProviderRateLimits { - return { - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: message, - status: 'error', - usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } - } - } - - // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. - private isRetryAfterActive(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'error' && - limits.usageMetadata?.retryAtMs && - limits.usageMetadata.retryAtMs > Date.now() - ) - } - - // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. - private isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'ok' && - limits.usageMetadata?.source === 'live-session' && - Date.now() - limits.updatedAt < MIN_REFETCH_MS - ) - } - - private shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { - return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) - } - - private resolveClaudeFetchApply( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not - // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. - const current = this.state.claude - if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { - return current - } - return this.applyStalePolicy(fresh, previous) - } - - private rememberClaudeAuthSnapshot( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - claudeGeneration: number, - claudeTarget: NormalizedClaudeAccountSelectionTarget - ): void { - // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. - if ( - claudeGeneration !== this.claudeFetchGeneration || - !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - ) { - return - } - this.lastClaudeAuthSnapshot = { - configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), - provenance: authPreparation?.provenance ?? 'system' - } - } - - /** Live usage windows forwarded from a Claude session's statusLine command. */ - ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { - // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. - const snapshot = this.lastClaudeAuthSnapshot - if (!snapshot) { - // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. - console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { - eventConfigDir: event.configDir - }) - return - } - // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. - if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { - console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { - eventConfigDir: event.configDir, - snapshotConfigDir: snapshot.configDir - }) - return - } - const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) - const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) - if (!freshSession && !freshWeekly) { - return - } - const previous = this.state.claude - // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. - const session = freshSession ?? previous?.session ?? null - const weekly = freshWeekly ?? previous?.weekly ?? null - if ( - previous?.status === 'ok' && - previous.usageMetadata?.source === 'live-session' && - Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && - isSameUsageWindow(previous.session, session) && - isSameUsageWindow(previous.weekly, weekly) - ) { - return - } - this.activeFailureStreakByProvider.claude = 0 - this.updateState({ - ...this.state, - claude: { - provider: 'claude', - session, - weekly, - // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. - // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. - fableWeekly: previous?.fableWeekly ?? null, - updatedAt: Date.now(), - error: null, - status: 'ok', - usageMetadata: { - source: 'live-session', - lastSuccessfulSource: 'live-session', - credentialSource: previous?.usageMetadata?.credentialSource, - authProvenance: snapshot.provenance - } - } - }) - } - - private trackActiveFailureStreak( - provider: ActiveRateLimitProvider, - fresh: ProviderRateLimits - ): void { - if (fresh.status === 'error') { - this.activeFailureStreakByProvider[provider] = Math.min( - this.activeFailureStreakByProvider[provider] + 1, - MAX_ACTIVE_FAILURE_STREAK - ) - return - } - if (fresh.status === 'ok' || fresh.status === 'unavailable') { - this.activeFailureStreakByProvider[provider] = 0 - } - } - - private withFetchingStatus( - current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' - ): ProviderRateLimits { - if (!current) { - return { - provider, - session: null, - weekly: null, - updatedAt: 0, - error: null, - status: 'fetching' - } - } - // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. - if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { - return current - } - return { ...current, status: 'fetching' } - } - - private async runFetchAllCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const codexTarget = this.codexFetchTarget - const previousState = this.state - // Why: a skipped Codex poll must not stop the other providers' cycle, so gate - // only the Codex slot instead of returning early (#STA-4422). - const codexHome = this.resolveCodexHome(codexTarget) - const codexFetchGated = codexHome.skip - const codexHomePath = codexHome.homePath - const codexStateBeforeFetch = - previousState.codex?.status === 'fetching' ? null : previousState.codex - const codexProvenance = codexFetchGated - ? null - : this.getCodexProvenance(codexTarget, codexHomePath) - const codexGeneration = this.codexFetchGeneration - const openCodeGoConfig = this.openCodeGoConfigResolver?.() - const cookie = openCodeGoConfig?.sessionCookie ?? '' - const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const miniMaxConfigResult = this.resolveMiniMaxConfig() - const miniMaxCookie = miniMaxConfigResult.config.sessionCookie - const miniMaxGroupId = miniMaxConfigResult.config.groupId - const miniMaxModels = miniMaxConfigResult.config.models - const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false - // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - // Discard stale data on config change — it belongs to a different session/workspace. - const currentConfigHash = `${cookie}|${workspaceIdOverride}` - const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash - if (opencodeConfigChanged) { - this.lastOpencodeConfigHash = currentConfigHash - this.opencodeFetchGeneration += 1 - } - const opencodeGeneration = this.opencodeFetchGeneration - - const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` - const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash - if (miniMaxConfigChanged) { - this.lastMiniMaxConfigHash = currentMiniMaxConfigHash - this.minimaxFetchGeneration += 1 - } - const miniMaxGeneration = this.minimaxFetchGeneration - - // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude'), - // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. - codex: codexFetchGated - ? codexStateBeforeFetch - : this.withFetchingStatus(previousState.codex, 'codex'), - gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), - opencodeGo: opencodeConfigChanged - ? this.withFetchingStatus(null, 'opencode-go') - : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), - kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), - antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), - minimax: miniMaxConfigChanged - ? this.withFetchingStatus(null, 'minimax') - : this.withFetchingStatus(previousState.minimax, 'minimax'), - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const missingWslCodexHome = - codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) - const grokResultPromise = fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).then( - (value) => ({ status: 'fulfilled', value }) as const, - (reason) => ({ status: 'rejected', reason }) as const - ) - - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - const claudeFetchGated = - !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) - ]) - - if (signal.aborted) { - return - } - - const claude = - claudeResult.status === 'fulfilled' - ? claudeResult.value - : ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const codex = - codexResult.status === 'fulfilled' - ? codexResult.value - : ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const gemini = - geminiResult.status === 'fulfilled' - ? geminiResult.value - : ({ - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) - - const opencodeGo = - opencodeGoResult.status === 'fulfilled' - ? opencodeGoResult.value - : ({ - provider: 'opencode-go', - session: null, - weekly: null, - monthly: null, - updatedAt: Date.now(), - error: - opencodeGoResult.reason instanceof Error - ? opencodeGoResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const kimi = - kimiResult.status === 'fulfilled' - ? kimiResult.value - : ({ - provider: 'kimi', - session: null, - weekly: null, - updatedAt: Date.now(), - error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const miniMax = - miniMaxResult.status === 'fulfilled' - ? miniMaxResult.value - : ({ - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - miniMaxResult.reason instanceof Error - ? miniMaxResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const latestCodexHome = this.resolveCodexHome(codexTarget) - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - // Why: a finishing skip has no provenance, so an in-flight result must never be - // applied as though the target had become the system default (#STA-4422). - const shouldApplyCodex = - !codexFetchGated && - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - const codexBecameUnavailable = - !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration - // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. - const shouldApplyClaude = - !claudeFetchGated && - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration - const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.trackActiveFailureStreak('gemini', gemini) - this.trackActiveFailureStreak('antigravity', antigravity) - if (shouldApplyOpencode) { - this.trackActiveFailureStreak('opencode-go', opencodeGo) - } - this.trackActiveFailureStreak('kimi', kimi) - if (shouldApplyMiniMax) { - this.trackActiveFailureStreak('minimax', miniMax) - } - - // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude, - codex: shouldApplyCodex - ? this.applyStalePolicy(codex, previousState.codex) - : codexBecameUnavailable - ? codexStateBeforeFetch - : this.state.codex, - gemini: this.applyStalePolicy(gemini, previousState.gemini), - opencodeGo: shouldApplyOpencode - ? opencodeConfigChanged - ? opencodeGo - : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) - : this.state.opencodeGo, - kimi: this.applyStalePolicy(kimi, previousState.kimi), - antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), - minimax: shouldApplyMiniMax - ? miniMaxConfigChanged - ? miniMax - : this.applyStalePolicy(miniMax, previousState.minimax) - : this.state.minimax - }) - - const grokResult = await grokResultPromise - if (signal.aborted) { - return - } - const grok = - grokResult.status === 'fulfilled' - ? grokResult.value - : ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const codexTarget = this.codexFetchTarget - const codexGeneration = this.codexFetchGeneration - const codexHome = this.resolveCodexHome(codexTarget) - // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). - if (codexHome.skip) { - if ( - codexGeneration === this.codexFetchGeneration && - this.state.codex?.status === 'fetching' - ) { - this.updateState({ ...this.state, codex: null }) - } - return - } - const codexHomePath = codexHome.homePath - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) - const previousState = this.state - - this.updateState({ - ...previousState, - codex: this.withFetchingStatus(previousState.codex, 'codex') - }) - - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - const codex = await ( - missingWslCodexHome - ? Promise.resolve(missingWslCodexHome) - : fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - }) - ).catch((err): ProviderRateLimits => ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestCodexHome = this.resolveCodexHome(codexTarget) - if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { - this.updateState({ - ...this.state, - codex: previousState.codex?.status === 'fetching' ? null : previousState.codex - }) - return - } - const shouldApplyCodex = - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.updateState({ - ...this.state, - codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex - }) - } - - private async runFetchClaudeOnlyCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const previousState = this.state - - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude') - }) - - const claude = await fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }).catch((err): ProviderRateLimits => ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude - }) - } - - private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const previousState = this.state - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - this.updateState({ - ...previousState, - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const grok = await fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).catch((err): ProviderRateLimits => ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private applyStalePolicy( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Fresh data is fine — use it - if (fresh.status === 'ok') { - return { - ...fresh, - usageMetadata: { - ...fresh.usageMetadata, - lastSuccessfulSource: - fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource - } - } - } - - // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. - if (fresh.status === 'unavailable') { - return fresh - } - - const previousHasData = Boolean( - previous?.session || - previous?.weekly || - previous?.fableWeekly || - previous?.monthly || - (previous?.buckets && previous.buckets.length > 0) - ) - - // No previous data to fall back on - if (!previous || !previousHasData) { - return fresh - } - - // Previous data is too old — don't show stale data - const staleThresholdMs = - fresh.usageMetadata?.failureKind === 'rate-limited' - ? RATE_LIMITED_STALE_THRESHOLD_MS - : STALE_THRESHOLD_MS - if (Date.now() - previous.updatedAt > staleThresholdMs) { - return fresh - } - - // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. - return { - ...previous, - error: fresh.error, - status: 'error', - usageMetadata: { - ...previous.usageMetadata, - ...fresh.usageMetadata, - lastSuccessfulSource: - previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source - } - } - } - - private buildInactiveArray( - cache: Map, - fetching: Set - ): InactiveAccountUsage[] { - const result: InactiveAccountUsage[] = [] - for (const [accountId, limits] of cache) { - result.push({ - accountId, - rateLimits: limits, - updatedAt: limits.updatedAt, - isFetching: fetching.has(accountId) - }) - } - // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. - for (const accountId of fetching) { - if (!cache.has(accountId)) { - result.push({ - accountId, - rateLimits: null, - updatedAt: 0, - isFetching: true - }) - } - } - return result - } - - private updateState(next: InternalRateLimitState): void { - this.state = next - this.pushToRenderer() - } - - private pushToRenderer(): void { - const state = this.getState() - for (const listener of this.stateListeners) { - try { - listener(state) - } catch { - // ignore — one bad listener must not break the others - } - } - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return - } - this.mainWindow.webContents.send('rateLimits:update', state) - } -} +/** + * Coordinates provider quota polling and publishes a stable rate-limit snapshot. + * The implementation is layered by lifecycle, account selection, and fetch policy + * so each module stays small while this path remains the public integration seam. + */ +export class RateLimitService extends RateLimitServiceConfiguration {} diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts new file mode 100644 index 00000000000..9e758a98518 --- /dev/null +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -0,0 +1,182 @@ +import { consumeCodexRateLimitResetCredit } from '../codex-fetcher' +import { RateLimitServiceInactiveAccounts } from './service-inactive-accounts' +import { + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type CodexAccountSelectionTarget, + type ClaudeAccountSelectionTarget, + type RateLimitRuntimeTarget, + type RateLimitState, + type CodexRateLimitResetResult +} from './service-types' + +export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceInactiveAccounts { + async refresh(): Promise { + // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. + await this.fetchAll({ force: true }) + return this.getState() + } + + async refreshIfStale(): Promise { + // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + return this.getState() + } + + async refreshGrok(): Promise { + await this.fetchGrokOnly({ force: true }) + return this.getState() + } + + invalidateMiniMaxCredentialState(): void { + this.minimaxFetchGeneration += 1 + // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. + this.updateState({ + ...this.state, + minimax: this.withFetchingStatus(null, 'minimax') + }) + } + + async refreshForCodexAccountChange( + outgoingAccountId?: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + // Why: weekly-only plans report no session window, so gating on session alone + // dropped their snapshot and left the switcher's inline bars empty. + if ( + outgoingAccountId && + (this.state.codex?.session || this.state.codex?.weekly) && + this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + ) { + this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) + } + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.codex = 0 + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + // Why: the switch must NOT reset the inactive-fetch debounce — re-probing + // every inactive account per switch spawns codex in each credential home + // and endangers rotating refresh tokens; the switcher shows the cached + // snapshot (seeded above for the outgoing account) until the debounce ends. + // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(null, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + this.activeFailureStreakByProvider.codex = 0 + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async consumeCodexRateLimitResetCredit(options: { + idempotencyKey: string + target: RateLimitRuntimeTarget + codexHomePath: string | null + }): Promise { + const codexTarget = normalizeCodexAccountSelectionTarget(options.target) + const codexHomePath = options.codexHomePath + const scopedStateBeforeReset = this.getState() + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + if (missingWslCodexHome) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') + } + try { + const outcome = await consumeCodexRateLimitResetCredit({ + codexHomePath, + idempotencyKey: options.idempotencyKey + }) + const state = await this.fetchCodexResetResultState( + codexTarget, + codexHomePath, + scopedStateBeforeReset + ) + return { outcome, state } + } catch (error) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw error + } + } + + async refreshForClaudeAccountChange( + outgoingAccountId?: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. + if ( + outgoingAccountId && + this.state.claude?.session && + this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + ) { + this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) + } + this.claudeFetchTarget = nextTarget + this.inactiveClaudeAccountsGeneration += 1 + this.pruneInactiveClaudeState() + this.claudeFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.claude = 0 + // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. + this.lastClaudeAuthSnapshot = null + this.lastInactiveClaudeFetchAt = 0 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(null, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + this.claudeFetchTarget = nextTarget + this.claudeFetchGeneration += 1 + this.activeFailureStreakByProvider.claude = 0 + if (targetChanged) { + // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. + this.lastClaudeAuthSnapshot = null + } + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshAfterClaudeLivePtysDrained(): Promise { + // Why: "Waiting for Claude session" can only recover once no live claude + // owns the credentials. Refetch on the last PTY exit instead of leaving + // the stale terminal error up until the failure backoff elapses. + if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { + return + } + this.activeFailureStreakByProvider.claude = 0 + await this.fetchClaudeOnly({ force: true }) + } +} diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts new file mode 100644 index 00000000000..52aa02ccddd --- /dev/null +++ b/src/main/rate-limits/service/service-configuration.ts @@ -0,0 +1,139 @@ +import type { BrowserWindow } from 'electron' +import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store' +import { RateLimitServiceAccountRefresh } from './service-account-refresh' +import { + type CodexAccountSelectionTarget, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAccountSelectionTarget, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type InactiveCodexAccountInfo, + type InactiveClaudeAccountInfo, + type RateLimitState, + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type NetworkProxySettings +} from './service-types' + +export abstract class RateLimitServiceConfiguration extends RateLimitServiceAccountRefresh { + setCodexHomePathResolver(resolver: CodexHomePathResolver): void { + this.codexHomePathResolver = resolver + } + + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { + this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) + } + + setKimiHomeResolver(resolver: KimiHomeResolver): void { + this.kimiHomeResolver = resolver + } + + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { + this.claudeAuthPreparationResolver = resolver + } + + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { + this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) + } + + setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { + this.openCodeGoConfigResolver = resolver + } + + setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { + this.miniMaxConfigResolver = resolver + } + + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { + this.geminiCliOAuthEnabledResolver = resolver + } + + setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { + this.networkProxySettingsResolver = resolver + } + + setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { + this.inactiveClaudeAccountsResolver = resolver + this.inactiveClaudeAccountsGeneration += 1 + } + + setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { + this.inactiveCodexAccountsResolver = resolver + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + } + attach(mainWindow: BrowserWindow): void { + this.detachWindowListeners?.() + this.mainWindow = mainWindow + const refreshOnResume = (): void => { + void this.refreshIfWindowActive() + } + // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. + const detachWindowListeners = (): void => { + mainWindow.removeListener('focus', refreshOnResume) + mainWindow.removeListener('show', refreshOnResume) + mainWindow.removeListener('restore', refreshOnResume) + mainWindow.removeListener('closed', onClosed) + } + const onClosed = (): void => { + detachWindowListeners() + if (this.detachWindowListeners === detachWindowListeners) { + this.detachWindowListeners = null + } + if (this.mainWindow === mainWindow) { + this.mainWindow = null + } + } + mainWindow.on('focus', refreshOnResume) + mainWindow.on('show', refreshOnResume) + mainWindow.on('restore', refreshOnResume) + mainWindow.on('closed', onClosed) + this.detachWindowListeners = detachWindowListeners + } + + start(options: { fetchImmediately?: boolean } = {}): void { + if (options.fetchImmediately !== false) { + void this.fetchAll() + } else { + this.scheduleDeferredStartupRefresh() + } + this.startTimer() + } + + stop(): void { + this.abortActiveFetchCycle() + this.clearQueuedFetches() + this.inactiveClaudeFetching.clear() + this.inactiveCodexFetching.clear() + this.resolveAndClearFetchIdleWaiters() + this.stopTimer() + this.clearDeferredStartupRefresh() + this.detachWindowListeners?.() + this.detachWindowListeners = null + this.mainWindow = null + } + + getState(): RateLimitState { + this.pruneInactiveClaudeState() + this.pruneInactiveCodexState() + return { + ...this.state, + // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. + minimaxCookieConfigured: hasMiniMaxSessionCookie(), + grokAuthConfigured: this.grokAuthConfigured, + claudeTarget: this.claudeFetchTarget, + codexTarget: this.codexFetchTarget, + inactiveClaudeAccounts: this.buildInactiveArray( + this.inactiveClaudeCache, + this.inactiveClaudeFetching + ), + inactiveCodexAccounts: this.buildInactiveArray( + this.inactiveCodexCache, + this.inactiveCodexFetching + ) + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-control.ts b/src/main/rate-limits/service/service-fetch-control.ts new file mode 100644 index 00000000000..cab01551def --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-control.ts @@ -0,0 +1,80 @@ +import { RateLimitServiceState } from './service-state' + +export abstract class RateLimitServiceFetchControl extends RateLimitServiceState { + protected waitForFetchIdle(): Promise { + if ( + !this.isFetching && + !this.fullFetchQueued && + !this.codexOnlyFetchQueued && + !this.claudeOnlyFetchQueued && + !this.grokOnlyFetchQueued + ) { + return Promise.resolve() + } + // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. + return new Promise((resolve) => { + this.fetchIdleResolvers.push(resolve) + }) + } + + protected resolveFetchIdleWaiters(): void { + if ( + this.isFetching || + this.fullFetchQueued || + this.codexOnlyFetchQueued || + this.claudeOnlyFetchQueued || + this.grokOnlyFetchQueued + ) { + return + } + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } + + protected beginFetchCycle(): AbortController { + const controller = new AbortController() + this.activeFetchAbortControllers.add(controller) + return controller + } + + protected finishFetchCycle(controller: AbortController): void { + this.activeFetchAbortControllers.delete(controller) + } + + protected async runWithFetchAbortSignal( + fn: (signal: AbortSignal) => Promise + ): Promise { + const controller = this.beginFetchCycle() + try { + await fn(controller.signal) + return controller.signal + } finally { + this.finishFetchCycle(controller) + } + } + + protected abortActiveFetchCycle(): void { + for (const controller of this.activeFetchAbortControllers) { + controller.abort() + } + this.activeFetchAbortControllers.clear() + } + + protected clearQueuedFetches(): void { + this.fullFetchQueued = false + this.codexOnlyFetchQueued = false + this.claudeOnlyFetchQueued = false + this.grokOnlyFetchQueued = false + } + + protected resolveAndClearFetchIdleWaiters(): void { + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts new file mode 100644 index 00000000000..2c28c519a14 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -0,0 +1,139 @@ +import { RateLimitServiceFetchTargets } from './service-fetch-targets' +import { + LIVE_CLAUDE_INGEST_DEDUPE_MS, + MIN_REFETCH_MS, + isSameUsageWindow, + normalizeClaudeConfigDir, + type ClaudeRuntimeAuthPreparation, + type ClaudeStatusLineRateLimits, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits +} from './service-types' +import { mapClaudeUsageWindow } from '../claude-usage-window' + +export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { + protected getMiniMaxCredentialError(message: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. + protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'error' && + limits.usageMetadata?.retryAtMs && + limits.usageMetadata.retryAtMs > Date.now() + ) + } + + // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. + protected isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'ok' && + limits.usageMetadata?.source === 'live-session' && + Date.now() - limits.updatedAt < MIN_REFETCH_MS + ) + } + + protected shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { + return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) + } + + protected resolveClaudeFetchApply( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not + // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. + const current = this.state.claude + if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { + return current + } + return this.applyStalePolicy(fresh, previous) + } + + protected rememberClaudeAuthSnapshot( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + claudeGeneration: number, + claudeTarget: NormalizedClaudeAccountSelectionTarget + ): void { + // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. + if ( + claudeGeneration !== this.claudeFetchGeneration || + !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + ) { + return + } + this.lastClaudeAuthSnapshot = { + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + provenance: authPreparation?.provenance ?? 'system' + } + } + + /** Live usage windows forwarded from a Claude session's statusLine command. */ + ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { + // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. + const snapshot = this.lastClaudeAuthSnapshot + if (!snapshot) { + // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. + console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { + eventConfigDir: event.configDir + }) + return + } + // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. + if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { + console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { + eventConfigDir: event.configDir, + snapshotConfigDir: snapshot.configDir + }) + return + } + const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) + const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) + if (!freshSession && !freshWeekly) { + return + } + const previous = this.state.claude + // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. + const session = freshSession ?? previous?.session ?? null + const weekly = freshWeekly ?? previous?.weekly ?? null + if ( + previous?.status === 'ok' && + previous.usageMetadata?.source === 'live-session' && + Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && + isSameUsageWindow(previous.session, session) && + isSameUsageWindow(previous.weekly, weekly) + ) { + return + } + this.activeFailureStreakByProvider.claude = 0 + this.updateState({ + ...this.state, + claude: { + provider: 'claude', + session, + weekly, + // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. + // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. + fableWeekly: previous?.fableWeekly ?? null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'live-session', + lastSuccessfulSource: 'live-session', + credentialSource: previous?.usageMetadata?.credentialSource, + authProvenance: snapshot.provenance + } + } + }) + } +} diff --git a/src/main/rate-limits/service/service-fetch-queue.ts b/src/main/rate-limits/service/service-fetch-queue.ts new file mode 100644 index 00000000000..49531ee3fb1 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-queue.ts @@ -0,0 +1,245 @@ +import { RateLimitServiceProviderCycles } from './service-provider-cycles' + +export abstract class RateLimitServiceFetchQueue extends RateLimitServiceProviderCycles { + protected async fetchAll(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.fullFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + shouldContinue = true + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchCodexOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.codexOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + shouldContinue = true + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchClaudeOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.claudeOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchGrokOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.grokOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts new file mode 100644 index 00000000000..2d316294dbc --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -0,0 +1,171 @@ +import { RateLimitServiceResultPolicy } from './service-result-policy' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchKimiRateLimits } from '../kimi-fetcher' +import { + isSystemDefaultClaudeAuth, + type ClaudeRuntimeAuthPreparation, + type CodexAccountSelectionTarget, + type MiniMaxResolvedConfig, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits, + type RateLimitState, + toErrorMessage +} from './service-types' + +export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResultPolicy { + protected resolveCodexHome(target?: CodexAccountSelectionTarget): { + skip: boolean + homePath: string | null + } { + const resolution = this.codexHomePathResolver?.(target) + if (!resolution) { + return { skip: false, homePath: null } + } + return resolution.kind === 'skip' + ? { skip: true, homePath: null } + : { skip: false, homePath: resolution.codexHomePath } + } + + // Why: resolving a WSL home probes wsl.exe, so it must not run before the other + // providers' fetches are started; chaining keeps the no-resolver path immediate. + protected fetchKimiWithResolvedHome(): Promise { + const pendingHome = this.kimiHomeResolver?.() + return pendingHome + ? pendingHome.then((home) => fetchKimiRateLimits({ home })) + : fetchKimiRateLimits({ home: undefined }) + } + + protected isSameCodexTarget( + left: NormalizedCodexAccountSelectionTarget, + right: NormalizedCodexAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected isSameClaudeTarget( + left: NormalizedClaudeAccountSelectionTarget, + right: NormalizedClaudeAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected getCodexProvenance( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null + ): string { + const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' + return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` + } + + protected getMissingWslCodexHomeResult( + target: NormalizedCodexAccountSelectionTarget + ): ProviderRateLimits | null { + if (target.runtime !== 'wsl') { + return null + } + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + + protected async fetchCodexResetResultState( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null, + stateBeforeReset: RateLimitState + ): Promise { + const controller = this.beginFetchCycle() + let fresh: ProviderRateLimits + try { + fresh = await fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal: controller.signal + }) + } catch (error) { + fresh = { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: toErrorMessage(error), + status: 'error' + } + } finally { + this.finishFetchCycle(controller) + } + + const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) + const currentCodexHome = this.resolveCodexHome(target) + // Why: a skip has no provenance to compare, so treat it as no longer active + // rather than publishing this result against the system-default lane. + const stillActive = + !currentCodexHome.skip && + this.isSameCodexTarget(this.codexFetchTarget, target) && + this.getCodexProvenance(target, currentCodexHome.homePath) === + this.getCodexProvenance(target, codexHomePath) + if (stillActive) { + // Why: this post-redemption read is newer than every Codex fetch that + // started before it, so invalidate those results before publishing it. + this.codexFetchGeneration += 1 + this.trackActiveFailureStreak('codex', fresh) + this.updateState({ + ...this.state, + codex: this.applyStalePolicy(fresh, this.state.codex) + }) + } + + // Why: the caller must receive the redeemed target even if the global UI + // switched targets while the provider mutation was in flight. + return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } + } + + protected shouldAllowCodexPtyFallback(): boolean { + // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. + return process.platform !== 'win32' + } + + protected shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { + // Why: Windows hidden PTY support is less reliable than host/WSL shells. + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. + return !isSystemDefaultClaudeAuth(authPreparation) + } + + protected shouldAllowClaudeUsagePanelSupplement(): boolean { + // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. + return process.platform !== 'win32' + } + + protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { + try { + return { + config: this.miniMaxConfigResolver?.() ?? { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: null + } + } catch (error) { + // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. + return { + config: { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: toErrorMessage(error) + } + } + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts new file mode 100644 index 00000000000..7104face38f --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -0,0 +1,215 @@ +import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' +import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { + protected async runFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + const prepared = await this.prepareFetchAllCycle(signal, options) + if (!prepared) { + return + } + const { + claudeTarget, + claudeGeneration, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } = prepared + if (signal.aborted) { + return + } + + const claude = + claudeResult.status === 'fulfilled' + ? claudeResult.value + : ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const codex = + codexResult.status === 'fulfilled' + ? codexResult.value + : ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const gemini = + geminiResult.status === 'fulfilled' + ? geminiResult.value + : ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. + const antigravity = deriveAntigravityRateLimits(gemini) + + const opencodeGo = + opencodeGoResult.status === 'fulfilled' + ? opencodeGoResult.value + : ({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: + opencodeGoResult.reason instanceof Error + ? opencodeGoResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const kimi = + kimiResult.status === 'fulfilled' + ? kimiResult.value + : ({ + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const miniMax = + miniMaxResult.status === 'fulfilled' + ? miniMaxResult.value + : ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + miniMaxResult.reason instanceof Error + ? miniMaxResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const latestCodexHome = this.resolveCodexHome(codexTarget) + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + // Why: a finishing skip has no provenance, so an in-flight result must never be + // applied as though the target had become the system default (#STA-4422). + const shouldApplyCodex = + !codexFetchGated && + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + const codexBecameUnavailable = + !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration + // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. + const shouldApplyClaude = + !claudeFetchGated && + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration + const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.trackActiveFailureStreak('gemini', gemini) + this.trackActiveFailureStreak('antigravity', antigravity) + if (shouldApplyOpencode) { + this.trackActiveFailureStreak('opencode-go', opencodeGo) + } + this.trackActiveFailureStreak('kimi', kimi) + if (shouldApplyMiniMax) { + this.trackActiveFailureStreak('minimax', miniMax) + } + + // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude, + codex: shouldApplyCodex + ? this.applyStalePolicy(codex, previousState.codex) + : codexBecameUnavailable + ? codexStateBeforeFetch + : this.state.codex, + gemini: this.applyStalePolicy(gemini, previousState.gemini), + opencodeGo: shouldApplyOpencode + ? opencodeConfigChanged + ? opencodeGo + : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) + : this.state.opencodeGo, + kimi: this.applyStalePolicy(kimi, previousState.kimi), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), + minimax: shouldApplyMiniMax + ? miniMaxConfigChanged + ? miniMax + : this.applyStalePolicy(miniMax, previousState.minimax) + : this.state.minimax + }) + + const grokResult = await grokResultPromise + if (signal.aborted) { + return + } + const grok = + grokResult.status === 'fulfilled' + ? grokResult.value + : ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts new file mode 100644 index 00000000000..1bbf3bf497d --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -0,0 +1,204 @@ +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import { fetchMiniMaxRateLimits } from '../minimax-fetcher' +import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher' +import { RateLimitServiceFetchPolicy } from './service-fetch-policy' +import type { + ClaudeRuntimeAuthPreparation, + InternalRateLimitState, + NormalizedClaudeAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget, + ProviderRateLimits +} from './service-types' + +export type FetchAllCyclePrepared = { + claudeTarget: NormalizedClaudeAccountSelectionTarget + claudeGeneration: number + claudeAuthPreparation: ClaudeRuntimeAuthPreparation | undefined + claudeProvenance: string + codexTarget: NormalizedCodexAccountSelectionTarget + previousState: InternalRateLimitState + codexFetchGated: boolean + codexStateBeforeFetch: ProviderRateLimits | null + codexProvenance: string | null + codexGeneration: number + opencodeConfigChanged: boolean + opencodeGeneration: number + miniMaxConfigChanged: boolean + miniMaxGeneration: number + claudeFetchGated: boolean + results: [ + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult + ] + grokResultPromise: Promise< + { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } + > +} + +export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { + protected async prepareFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return null + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return null + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const codexTarget = this.codexFetchTarget + const previousState = this.state + // Why: a skipped Codex poll must not stop the other providers' cycle, so gate + // only the Codex slot instead of returning early (#STA-4422). + const codexHome = this.resolveCodexHome(codexTarget) + const codexFetchGated = codexHome.skip + const codexHomePath = codexHome.homePath + const codexStateBeforeFetch = + previousState.codex?.status === 'fetching' ? null : previousState.codex + const codexProvenance = codexFetchGated + ? null + : this.getCodexProvenance(codexTarget, codexHomePath) + const codexGeneration = this.codexFetchGeneration + const openCodeGoConfig = this.openCodeGoConfigResolver?.() + const cookie = openCodeGoConfig?.sessionCookie ?? '' + const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' + const miniMaxConfigResult = this.resolveMiniMaxConfig() + const miniMaxCookie = miniMaxConfigResult.config.sessionCookie + const miniMaxGroupId = miniMaxConfigResult.config.groupId + const miniMaxModels = miniMaxConfigResult.config.models + const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false + // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + // Discard stale data on config change — it belongs to a different session/workspace. + const currentConfigHash = `${cookie}|${workspaceIdOverride}` + const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash + if (opencodeConfigChanged) { + this.lastOpencodeConfigHash = currentConfigHash + this.opencodeFetchGeneration += 1 + } + const opencodeGeneration = this.opencodeFetchGeneration + + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` + const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash + if (miniMaxConfigChanged) { + this.lastMiniMaxConfigHash = currentMiniMaxConfigHash + this.minimaxFetchGeneration += 1 + } + const miniMaxGeneration = this.minimaxFetchGeneration + + // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude'), + // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. + codex: codexFetchGated + ? codexStateBeforeFetch + : this.withFetchingStatus(previousState.codex, 'codex'), + gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), + opencodeGo: opencodeConfigChanged + ? this.withFetchingStatus(null, 'opencode-go') + : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), + kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), + minimax: miniMaxConfigChanged + ? this.withFetchingStatus(null, 'minimax') + : this.withFetchingStatus(previousState.minimax, 'minimax'), + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const missingWslCodexHome = + codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) + const grokResultPromise = fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + const claudeFetchGated = + !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) + + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = + await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) + ]) + + if (signal.aborted) { + return null + } + return { + claudeTarget, + claudeGeneration, + claudeAuthPreparation, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } + } +} diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts new file mode 100644 index 00000000000..5d977991af4 --- /dev/null +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -0,0 +1,246 @@ +import { fetchManagedAccountUsage } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { RateLimitServicePolling } from './service-polling' +import { + INACTIVE_CODEX_PROBE_STAGGER_MS, + INACTIVE_FETCH_DEBOUNCE_MS, + delayUnlessAborted +} from './service-types' + +export abstract class RateLimitServiceInactiveAccounts extends RateLimitServicePolling { + async fetchInactiveClaudeAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveClaudeState() + if (this.inactiveClaudeFetching.size > 0) { + return + } + const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + const fetchGeneration = this.inactiveClaudeAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + + for (const account of accounts) { + this.inactiveClaudeFetching.add(account.id) + } + this.pushToRenderer() + + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + try { + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeCache.delete(account.id) + } + } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { + this.lastInactiveClaudeFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) + } + } + + async fetchInactiveCodexAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveCodexState() + if (this.inactiveCodexFetchInFlight) { + return + } + const accounts = this.inactiveCodexAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. + const fetchGeneration = this.inactiveCodexAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + this.inactiveCodexFetchInFlight = true + + let staggerNextProbe = false + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + if (staggerNextProbe) { + await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) + // Why: the account set can change while the stagger delay runs. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + } + const home = account.resolveHome() + if (home.kind === 'skip') { + continue + } + staggerNextProbe = true + this.inactiveCodexFetching.add(account.id) + this.pushToRenderer() + try { + // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. + // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. + const fresh = await fetchCodexRateLimits({ + codexHomePath: home.managedHomePath, + allowPtyFallback: false, + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexCache.delete(account.id) + } + } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } + } finally { + this.inactiveCodexFetchInFlight = false + this.finishFetchCycle(controller) + } + } + + evictInactiveClaudeCache(accountId: string): void { + this.inactiveClaudeAccountsGeneration += 1 + this.inactiveClaudeCache.delete(accountId) + this.inactiveClaudeFetching.delete(accountId) + this.pushToRenderer() + } + + protected isCurrentInactiveClaudeAccount(accountId: string): boolean { + return (this.inactiveClaudeAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected isCurrentInactiveCodexAccount(accountId: string): boolean { + return (this.inactiveCodexAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected pruneInactiveClaudeState(): void { + const currentIds = new Set( + (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveClaudeCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeCache.delete(accountId) + } + } + for (const accountId of this.inactiveClaudeFetching) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeFetching.delete(accountId) + } + } + } + + protected pruneInactiveCodexState(): void { + const currentIds = new Set( + (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveCodexCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveCodexCache.delete(accountId) + } + } + for (const accountId of this.inactiveCodexFetching) { + if (!currentIds.has(accountId)) { + this.inactiveCodexFetching.delete(accountId) + } + } + } + + evictInactiveCodexCache(accountId: string): void { + // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. + this.inactiveCodexCache.delete(accountId) + this.inactiveCodexFetching.delete(accountId) + this.pushToRenderer() + } +} diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts new file mode 100644 index 00000000000..c861726cc52 --- /dev/null +++ b/src/main/rate-limits/service/service-polling.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFetchQueue } from './service-fetch-queue' +import { + ACTIVE_FAILURE_REFETCH_MS, + DEFERRED_STARTUP_ACTIVE_REFRESH_MS, + INDIVIDUALLY_REFRESHABLE_PROVIDERS, + MAX_ACTIVE_FAILURE_REFETCH_MS, + MIN_REFETCH_MS, + normalizePollingInterval, + type ActiveProviderState, + type ActiveRateLimitProvider, + type ActiveWindowRefreshPlan, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue { + setPollingInterval(ms: number): void { + this.pollInterval = normalizePollingInterval(ms) + if (this.timer) { + this.stopTimer() + this.startTimer() + } + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + protected startTimer(): void { + this.stopTimer() + this.timer = setInterval(() => { + if (!this.shouldBackgroundPoll()) { + return + } + void this.fetchAll() + }, this.pollInterval) + } + + protected stopTimer(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + protected scheduleDeferredStartupRefresh(): void { + this.clearDeferredStartupRefresh() + this.deferredStartupRefreshTimer = setTimeout(() => { + this.deferredStartupRefreshTimer = null + void this.refreshIfWindowActive() + }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) + } + + protected clearDeferredStartupRefresh(): void { + if (this.deferredStartupRefreshTimer) { + clearTimeout(this.deferredStartupRefreshTimer) + this.deferredStartupRefreshTimer = null + } + } + + protected shouldBackgroundPoll(): boolean { + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return false + } + // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). + if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { + return false + } + return this.mainWindow.isFocused() + } + + protected getActiveProviderState(): ActiveProviderState[] { + // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. + const byProvider: Record = { + claude: this.state.claude, + codex: this.state.codex, + gemini: this.state.gemini, + 'opencode-go': this.state.opencodeGo, + kimi: this.state.kimi, + minimax: this.state.minimax, + grok: this.state.grok, + antigravity: this.state.antigravity + } + return Object.entries(byProvider).map(([provider, limits]) => ({ + provider: provider as ActiveRateLimitProvider, + limits + })) + } + + protected getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { + const retryableFailures: ActiveRateLimitProvider[] = [] + for (const { provider, limits } of this.getActiveProviderState()) { + if (!limits || limits.status === 'idle' || limits.status === 'fetching') { + return { kind: 'full' } + } + if (limits.status === 'ok' || limits.status === 'unavailable') { + if (now - limits.updatedAt >= MIN_REFETCH_MS) { + return { kind: 'full' } + } + continue + } + // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. + if (limits.status === 'error') { + // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. + if (this.isRetryAfterActive(limits)) { + continue + } + const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] + const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ? Math.min( + ACTIVE_FAILURE_REFETCH_MS * + 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), + MAX_ACTIVE_FAILURE_REFETCH_MS + ) + : MIN_REFETCH_MS + if (now - lastRetryAt >= throttleMs) { + retryableFailures.push(provider) + } + } + } + + if (retryableFailures.length === 0) { + return { kind: 'none' } + } + return { kind: 'providers', providers: retryableFailures } + } + + protected async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { + if (plan.kind === 'none') { + return + } + if (plan.kind === 'full') { + // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. + // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. + if (!this.isFetching) { + const now = Date.now() + for (const { provider, limits } of this.getActiveProviderState()) { + if (limits?.status === 'error') { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + } + } + await this.fetchAll() + return + } + + // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. + if (this.isFetching) { + return + } + + const now = Date.now() + for (const provider of plan.providers) { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + + const canRefreshIndividually = plan.providers.every((provider) => + INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ) + if (!canRefreshIndividually) { + await this.fetchAll() + return + } + + // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. + if (plan.providers.includes('claude')) { + await this.fetchClaudeOnly() + } + if (plan.providers.includes('codex')) { + await this.fetchCodexOnly() + } + if (plan.providers.includes('grok')) { + await this.fetchGrokOnly() + } + } + + protected async refreshIfWindowActive(): Promise { + if (!this.shouldBackgroundPoll()) { + return + } + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + } +} diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts new file mode 100644 index 00000000000..75c56d96e67 --- /dev/null +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFullCycleApplication } from './service-full-cycle-application' +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFullCycleApplication { + protected async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const codexTarget = this.codexFetchTarget + const codexGeneration = this.codexFetchGeneration + const codexHome = this.resolveCodexHome(codexTarget) + // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). + if (codexHome.skip) { + if ( + codexGeneration === this.codexFetchGeneration && + this.state.codex?.status === 'fetching' + ) { + this.updateState({ ...this.state, codex: null }) + } + return + } + const codexHomePath = codexHome.homePath + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) + const previousState = this.state + + this.updateState({ + ...previousState, + codex: this.withFetchingStatus(previousState.codex, 'codex') + }) + + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + const codex = await ( + missingWslCodexHome + ? Promise.resolve(missingWslCodexHome) + : fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + }) + ).catch((err): ProviderRateLimits => ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestCodexHome = this.resolveCodexHome(codexTarget) + if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { + this.updateState({ + ...this.state, + codex: previousState.codex?.status === 'fetching' ? null : previousState.codex + }) + return + } + const shouldApplyCodex = + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.updateState({ + ...this.state, + codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex + }) + } + + protected async runFetchClaudeOnlyCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return + } + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { + return + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const previousState = this.state + + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude') + }) + + const claude = await fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }).catch((err): ProviderRateLimits => ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + const shouldApplyClaude = + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude + }) + } + + protected async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const previousState = this.state + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + this.updateState({ + ...previousState, + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const grok = await fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).catch((err): ProviderRateLimits => ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts new file mode 100644 index 00000000000..db00053fdcf --- /dev/null +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -0,0 +1,112 @@ +import { RateLimitServiceFetchControl } from './service-fetch-control' +import { + MAX_ACTIVE_FAILURE_STREAK, + RATE_LIMITED_STALE_THRESHOLD_MS, + STALE_THRESHOLD_MS, + type ActiveRateLimitProvider, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetchControl { + protected applyStalePolicy( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Fresh data is fine — use it + if (fresh.status === 'ok') { + return { + ...fresh, + usageMetadata: { + ...fresh.usageMetadata, + lastSuccessfulSource: + fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource + } + } + } + + // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. + if (fresh.status === 'unavailable') { + return fresh + } + + const previousHasData = Boolean( + previous?.session || + previous?.weekly || + previous?.fableWeekly || + previous?.monthly || + (previous?.buckets && previous.buckets.length > 0) + ) + + // No previous data to fall back on + if (!previous || !previousHasData) { + return fresh + } + + // Previous data is too old — don't show stale data + const staleThresholdMs = + fresh.usageMetadata?.failureKind === 'rate-limited' + ? RATE_LIMITED_STALE_THRESHOLD_MS + : STALE_THRESHOLD_MS + if (Date.now() - previous.updatedAt > staleThresholdMs) { + return fresh + } + + // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. + return { + ...previous, + error: fresh.error, + status: 'error', + usageMetadata: { + ...previous.usageMetadata, + ...fresh.usageMetadata, + lastSuccessfulSource: + previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source + } + } + } + + protected trackActiveFailureStreak( + provider: ActiveRateLimitProvider, + fresh: ProviderRateLimits + ): void { + if (fresh.status === 'error') { + this.activeFailureStreakByProvider[provider] = Math.min( + this.activeFailureStreakByProvider[provider] + 1, + MAX_ACTIVE_FAILURE_STREAK + ) + return + } + if (fresh.status === 'ok' || fresh.status === 'unavailable') { + this.activeFailureStreakByProvider[provider] = 0 + } + } + + protected withFetchingStatus( + current: ProviderRateLimits | null, + provider: + | 'claude' + | 'codex' + | 'gemini' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'antigravity' + ): ProviderRateLimits { + if (!current) { + return { + provider, + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. + if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { + return current + } + return { ...current, status: 'fetching' } + } +} diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts new file mode 100644 index 00000000000..c7e9aa4751a --- /dev/null +++ b/src/main/rate-limits/service/service-state.ts @@ -0,0 +1,165 @@ +import type { BrowserWindow } from 'electron' +import type { + InactiveAccountUsage, + ProviderRateLimits, + RateLimitState +} from '../../../shared/rate-limit-types' +import { + type ActiveRateLimitProvider, + type InactiveCodexAccountInfo, + type InternalRateLimitState, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type InactiveClaudeAccountInfo, + type NetworkProxySettings, + DEFAULT_POLL_MS +} from './service-types' +import { readGrokAuthSession } from '../grok-auth' + +export abstract class RateLimitServiceState { + protected state: InternalRateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null + } + protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + protected pollInterval: number = DEFAULT_POLL_MS + protected timer: ReturnType | null = null + protected deferredStartupRefreshTimer: ReturnType | null = null + // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. + protected lastActiveFailureRetryAtByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. + protected activeFailureStreakByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + protected mainWindow: BrowserWindow | null = null + protected detachWindowListeners: (() => void) | null = null + protected isFetching = false + protected fullFetchQueued = false + protected codexOnlyFetchQueued = false + protected claudeOnlyFetchQueued = false + protected grokOnlyFetchQueued = false + protected activeFetchAbortControllers = new Set() + protected fetchIdleResolvers: (() => void)[] = [] + protected codexFetchGeneration = 0 + protected claudeFetchGeneration = 0 + // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. + protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null + protected opencodeFetchGeneration = 0 + protected minimaxFetchGeneration = 0 + protected lastOpencodeConfigHash = '' + protected lastMiniMaxConfigHash = '' + protected codexHomePathResolver: CodexHomePathResolver | null = null + protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + // Why: resolved per cycle — the local-account runtime policy can flip between fetches. + protected kimiHomeResolver: KimiHomeResolver | null = null + protected claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + protected claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null + protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null + protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null + protected networkProxySettingsResolver: (() => NetworkProxySettings) | null = null + protected inactiveClaudeCache = new Map() + protected inactiveCodexCache = new Map() + protected inactiveClaudeFetching = new Set() + protected inactiveCodexFetching = new Set() + protected inactiveCodexFetchInFlight = false + protected lastInactiveClaudeFetchAt = 0 + protected inactiveClaudeAccountsGeneration = 0 + protected lastInactiveCodexFetchAt = 0 + protected inactiveCodexAccountsGeneration = 0 + protected stateListeners = new Set<(state: RateLimitState) => void>() + + constructor() {} + + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + + protected abstract getState(): RateLimitState + + protected buildInactiveArray( + cache: Map, + fetching: Set + ): InactiveAccountUsage[] { + const result: InactiveAccountUsage[] = [] + for (const [accountId, limits] of cache) { + result.push({ + accountId, + rateLimits: limits, + updatedAt: limits.updatedAt, + isFetching: fetching.has(accountId) + }) + } + // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. + for (const accountId of fetching) { + if (!cache.has(accountId)) { + result.push({ + accountId, + rateLimits: null, + updatedAt: 0, + isFetching: true + }) + } + } + return result + } + + protected updateState(next: InternalRateLimitState): void { + this.state = next + this.pushToRenderer() + } + + protected pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return + } + this.mainWindow.webContents.send('rateLimits:update', state) + } +} diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts new file mode 100644 index 00000000000..0b38bd39433 --- /dev/null +++ b/src/main/rate-limits/service/service-types.ts @@ -0,0 +1,163 @@ +import type { ProviderRateLimits } from '../../../shared/rate-limit-types' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home' +import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +import type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type { + CodexRateLimitResetResult, + RateLimitState, + ProviderRateLimits, + InactiveAccountUsage, + RateLimitRuntimeTarget +} from '../../../shared/rate-limit-types' +export type { InactiveClaudeAccountInfo } from '../claude-fetcher' +export type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +export type { NetworkProxySettings } from '../../../shared/network-proxy' +export type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +export type { + ClaudeAccountSelectionTarget, + NormalizedClaudeAccountSelectionTarget +} from '../../claude-accounts/runtime-selection' +export { normalizeClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +export type { + CodexAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget +} from '../../codex-accounts/runtime-selection' +export { normalizeCodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +export type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type InactiveCodexAccountInfo = { + id: string + resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } +} + +export type CodexHomePathResolver = ( + target?: CodexAccountSelectionTarget +) => CodexRateLimitHomeResolution +export type KimiHomeResolver = () => Promise +export type ClaudeAuthPreparationResolver = ( + target?: ClaudeAccountSelectionTarget +) => Promise + +export type OpenCodeGoRateLimitConfig = { + sessionCookie: string + workspaceIdOverride: string +} + +export type MiniMaxRateLimitConfig = { + sessionCookie: string + groupId: string + models: string +} + +export type MiniMaxResolvedConfig = { + config: MiniMaxRateLimitConfig + error: string | null +} + +export type GeminiCliOAuthEnabledResolver = () => boolean +export type ActiveRateLimitProvider = ProviderRateLimits['provider'] +export type ActiveProviderState = { + provider: ActiveRateLimitProvider + limits: ProviderRateLimits | null +} +export type ActiveWindowRefreshPlan = + | { kind: 'none' } + | { kind: 'full' } + | { kind: 'providers'; providers: ActiveRateLimitProvider[] } + +// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. +export const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes +export const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. +export const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. +export const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +export const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. +export const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS +export const MAX_ACTIVE_FAILURE_STREAK = 8 +// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. +export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'grok' +]) +export const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped +// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". +export const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 +// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. +export const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 +export const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open +// Why: each inactive Codex probe spawns a real codex process inside that +// account's live credential home; pace them out instead of bursting every +// account the moment the switcher opens. +export const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 +export const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 + +// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). +export type InternalRateLimitState = { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + gemini: ProviderRateLimits | null + opencodeGo: ProviderRateLimits | null + kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null + minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null +} + +export function normalizePollingInterval(ms: number): number { + if (!Number.isFinite(ms)) { + return DEFAULT_POLL_MS + } + return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) +} + +export function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { + // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. + const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return trimmed || null +} + +export function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +export function isSameUsageWindow( + a: ProviderRateLimits['session'], + b: ProviderRateLimits['session'] +): boolean { + if (!a || !b) { + return a === b + } + return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt +} diff --git a/src/main/runtime/rpc/methods/orchestration-ask-methods.ts b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts new file mode 100644 index 00000000000..fb5194df87d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts @@ -0,0 +1,168 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import { isGroupAddress } from '../../orchestration/groups' +import { AskParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' +import { askRemoteRunHome } from './orchestration-ask-remote' + +export const ORCHESTRATION_ASK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.ask', + params: AskParams, + handler: async ( + params, + { runtime, signal, orchestrationCapability, recordMutationReceipt } + ) => { + // Why: group addresses have no unambiguous first-answer authority. + if (params.to && isGroupAddress(params.to)) { + throw new Error( + 'ask does not support group addresses; use send for non-blocking fan-out questions' + ) + } + + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. + const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) + const paneKey = runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if (remoteAttachment) { + rejectFederatedExplicitTarget(params) + return askRemoteRunHome({ + params: { ...params, timeoutMs }, + runtime, + signal, + orchestrationCapability, + recordMutationReceipt, + from, + paneKey: paneKey as string, + dispatchId: remoteAttachment.dispatch_id, + taskId: remoteAttachment.task_id + }) + } + const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) + if (!activeDispatch) { + throw new OrchestrationError( + 'dispatch_inactive', + 'ask requires an active supervised Dispatch.' + ) + } + if (activeDispatch.capability_hash) { + const authority = db.verifyDispatchCapability({ + dispatchId: activeDispatch.id, + capability: orchestrationCapability, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined + }) + if (!authority.valid) { + throw new OrchestrationError('dispatch_capability_invalid', authority.reason) + } + } + const options = + params.options + ?.split(',') + .map((s) => s.trim()) + .filter(Boolean) ?? [] + let question = params.resume ? db.getQuestion(params.resume) : undefined + if (params.resume) { + if (!question || question.dispatch_id !== activeDispatch.id) { + throw new OrchestrationError( + 'question_not_found', + `Question ${params.resume} does not belong to this active Dispatch.` + ) + } + } else { + const run = db.getRun(activeDispatch.run_id) + if (!run || run.legacy === 1) { + throw new OrchestrationError( + 'run_not_found', + `Run ${activeDispatch.run_id} was not found.` + ) + } + if (params.run && params.run !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` + ) + } + if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` + ) + } + const created = db.createQuestion({ + runId: run.id, + dispatchId: activeDispatch.id, + askerHandle: from, + question: params.question as string, + options + }) + question = created.question + runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) + } + + const questionId = question.message_id + recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const current = db.getQuestion(questionId) + if (!current || current.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its Dispatch is inactive.` + ) + } + if (current.status === 'answered') { + return { + answer: current.answer_body, + messageId: questionId, + answerMessageId: current.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { + timeoutMs: remainingMs, + signal + }) + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-ask-remote.ts b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts new file mode 100644 index 00000000000..4b76e626e87 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts @@ -0,0 +1,129 @@ +import type { z } from 'zod' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import type { AskParams } from './orchestration-schemas' + +export async function askRemoteRunHome(args: { + params: z.infer + runtime: OrcaRuntimeService + signal?: AbortSignal + orchestrationCapability?: string + recordMutationReceipt?: (receipt: unknown) => void + from: string + paneKey: string + dispatchId: string + taskId: string +}): Promise { + const db = args.runtime.getOrchestrationDb() + const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + capability: args.orchestrationCapability, + paneKey: args.paneKey, + processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + const options = + args.params.options + ?.split(',') + .map((option) => option.trim()) + .filter(Boolean) ?? [] + let questionId = args.params.resume + if (questionId) { + const existing = db.getRemoteQuestion(questionId) + if (!existing || existing.dispatch_id !== args.dispatchId) { + throw new OrchestrationError( + 'question_not_found', + `Question ${questionId} does not belong to this remote Dispatch.` + ) + } + } else { + const relay = db.enqueueFederationRelay({ + dispatchId: args.dispatchId, + direction: 'to_home', + kind: 'question', + payload: JSON.stringify({ + from: args.from, + subject: 'Question', + body: args.params.question as string, + type: 'question', + priority: 'normal', + threadId: null, + payload: JSON.stringify({ + taskId: args.taskId, + dispatchId: args.dispatchId, + question: args.params.question, + options + }) + }), + remoteQuestion: true + }) + questionId = relay.message_id + } + args.recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const question = db.getRemoteQuestion(questionId) + if (!question || question.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its remote Dispatch is inactive.` + ) + } + if (question.status === 'answered') { + return { + answer: question.answer_body, + messageId: questionId, + answerMessageId: question.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (args.signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { + timeoutMs: remainingMs, + signal: args.signal + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-direct.ts b/src/main/runtime/rpc/methods/orchestration-check-direct.ts new file mode 100644 index 00000000000..1ac5ddeb3f3 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-direct.ts @@ -0,0 +1,79 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkDirectMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined +}): Promise { + const { params, runtime, db, handle, typeFilter, signal } = args + // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const consumeUnread = !showAll && params.peek !== true + const readAndReturn = () => { + const messages = showAll + ? db.getAllMessagesForHandle(handle, undefined, typeFilter) + : db.getUnreadMessages(handle, typeFilter) + if ( + consumeUnread && + messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', + { effectsApplied: false } + ) + } + let visibleMessages = messages + if (consumeUnread && messages.length > 0) { + // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. + visibleMessages = messages.map((message) => { + const reconciled = reconcileLifecycleMessage(db, message) + return reconciled.action === 'rejected' + ? (db.getMessageById(message.id) ?? message) + : message + }) + db.markAsRead(messages.map((message) => message.id)) + } + if (params.format || params.inject) { + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { messages: visibleMessages, formatted, count: visibleMessages.length } + } + return { messages: visibleMessages, count: visibleMessages.length } + } + + if (signal?.aborted) { + return { messages: [], count: 0 } + } + const result = readAndReturn() + if (result.count > 0 || !params.wait) { + return result + } + // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). + const waitResult = await runtime.waitForMessage(handle, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + if (signal?.aborted) { + return { messages: [], count: 0 } + } + if (waitResult === 'cancelled') { + throw new OrchestrationError( + 'consumer_fenced', + 'This direct mailbox became owned by a Run while the check was waiting.' + ) + } + return readAndReturn() +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-methods.ts b/src/main/runtime/rpc/methods/orchestration-check-methods.ts new file mode 100644 index 00000000000..d07be04d129 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-methods.ts @@ -0,0 +1,79 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { CheckParams } from './orchestration-schemas' +import { parseMessageTypes } from './orchestration-routing' +import { checkRunMailbox } from './orchestration-check-run' +import { checkWorkerMailbox } from './orchestration-check-worker' +import { checkDirectMailbox } from './orchestration-check-direct' + +export const ORCHESTRATION_CHECK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.check', + params: CheckParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } + ) => { + const db = runtime.getOrchestrationDb() + const handle = params.terminal ?? 'unknown' + const typeFilter = parseMessageTypes(params.types) + + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey + const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (params.run || boundRun) { + return checkRunMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + }) + } + + const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey) + const remoteAttachment = + !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if ( + remoteAttachment && + !db.isRemoteAttachmentProcessCurrent({ + dispatchId: remoteAttachment.dispatch_id, + paneKey: paneKey ?? null, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` + ) + } + if (activeDispatch || remoteAttachment) { + return checkWorkerMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + }) + } + return checkDirectMailbox({ params, runtime, db, handle, typeFilter, signal }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-check-run.ts b/src/main/runtime/rpc/methods/orchestration-check-run.ts new file mode 100644 index 00000000000..140b58d1a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-run.ts @@ -0,0 +1,251 @@ +import type { MessageRow, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { interruptedAcknowledgedCheck } from './orchestration-routing' +import { routeAllMailboxPages } from './orchestration-schemas' +import { resolveRunScope } from './orchestration-run-scope' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkRunMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + orchestrationCompatibilityEvidence: RpcContext['orchestrationCompatibilityEvidence'] + recordMutationReceipt: ((receipt: unknown) => void) | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + } = args + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const generation = run.consumer_generation + const address = `run:${run.id}` + runtime.ensureOrchestrationFederationRelay(run.id) + await routeDirectSnapshot(run.id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) + ) + const coordinatorHandle = run.coordinator_handle + if (coordinatorHandle && coordinatorHandle !== handle) { + await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) + ) + } + revalidateLegacyCoordinator?.() + const currentRun = resolveRunScope(runtime, { + runId: run.id, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (currentRun.consumer_generation !== generation) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while routing pending mail.' + ) + } + + const acknowledged = params.ack + ? db.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: generation, + deliveryId: params.ack + }) + : undefined + if (acknowledged) { + recordMutationReceipt?.( + interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') + ) + } + if (params.all || (params.unread === false && !params.peek)) { + const messages = db.getRunMailboxHistory(run.id, 100, typeFilter) + const result = { + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null + } + if (params.format || params.inject) { + return { + ...result, + formatted: messages.map(formatMessageBanner).join('\n\n'), + runId: run.id + } + } + return { ...result, runId: run.id } + } + + const peekResult = (messages: MessageRow[]) => ({ + runId: run.id, + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + }) + const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) + const readDelivery = (wakeTypes?: MessageType[]) => + db.getOrCreateRunDelivery({ runId: run.id, consumerGeneration: generation, wakeTypes }) + let peeked = params.peek ? readPeek() : [] + if (params.peek && peeked.length > 0) { + return peekResult(peeked) + } + let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) + if (current) { + return { + runId: run.id, + deliveryId: current.delivery.id, + messages: current.messages, + count: current.messages.length, + replayed: current.replayed, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format || params.inject + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + if (!params.wait) { + if (params.peek) { + return peekResult([]) + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false + } + } + + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal, + exclusive: true + }) + try { + revalidateLegacyCoordinator?.() + } catch (error) { + if (!acknowledged) { + throw error + } + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + const latestRun = db.getRun(run.id) + if (!latestRun || latestRun.consumer_generation !== generation) { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while waiting.' + ) + } + if (waitResult === 'waiter_exists') { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') + } + throw new OrchestrationError( + 'waiter_exists', + `Run ${run.id} already has an active actionable waiter.` + ) + } + if (waitResult === 'timed_out') { + if (params.peek) { + return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: true, + cancelled: false, + connectionLost: false + } + } + if (waitResult === 'cancelled') { + if (params.peek) { + return { + ...peekResult([]), + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + if (params.peek) { + peeked = readPeek() + return { ...peekResult(peeked), timedOut: false, cancelled: false, connectionLost: false } + } + current = readDelivery(typeFilter) + return { + runId: run.id, + deliveryId: current?.delivery.id ?? null, + messages: current?.messages ?? [], + count: current?.messages.length ?? 0, + replayed: current?.replayed ?? false, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format && current + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-worker.ts b/src/main/runtime/rpc/methods/orchestration-check-worker.ts new file mode 100644 index 00000000000..3d5e68dec75 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-worker.ts @@ -0,0 +1,192 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { routeAllMailboxPages } from './orchestration-schemas' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer +type ActiveDispatch = NonNullable> +type RemoteAttachment = NonNullable< + ReturnType +> + +export async function checkWorkerMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + activeDispatch: ActiveDispatch | undefined + remoteAttachment: RemoteAttachment | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + } = args + const workerMailbox = activeDispatch + ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } + : remoteAttachment + ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } + : undefined + if (!workerMailbox) { + return undefined + } + const address = `dispatch:${workerMailbox.dispatchId}` + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const revalidateWorkerMailbox = async (): Promise => { + if (activeDispatch) { + const current = db.getActiveDispatchForIdentity(handle, paneKey) + if (current?.id === activeDispatch.id) { + return + } + } else if (remoteAttachment && paneKey) { + const current = db.findActiveRemoteAttachmentForPane(paneKey) + if ( + current?.dispatch_id === remoteAttachment.dispatch_id && + db.isRemoteAttachmentProcessCurrent({ + dispatchId: current.dispatch_id, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + return + } + } + const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) + const owningRunId = latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId + if ( + owningRunId && + (!latestDispatch || + (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) + ) { + const throughSequence = db.getLatestUnreadMessageSequence(address) + if (throughSequence !== undefined) { + const routedTypes = new Set() + const routePage = (): { routedCount: number; hasMore: boolean } => { + const routed = db.routeUnreadDispatchMailboxToRunMailbox( + workerMailbox.dispatchId, + owningRunId, + throughSequence + ) + for (const routedType of routed.types) { + routedTypes.add(routedType) + } + return routed + } + const notifyRoutedTypes = (): void => { + for (const routedType of routedTypes) { + runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) + } + routedTypes.clear() + } + try { + await routeAllMailboxPages(routePage, signal) + } catch (error) { + notifyRoutedTypes() + if (error instanceof OrchestrationError && error.code === 'request_aborted') { + setImmediate(() => { + void routeAllMailboxPages(routePage) + .catch(() => undefined) + .finally(notifyRoutedTypes) + }) + } + throw error + } + notifyRoutedTypes() + } + } + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` + ) + } + + if (activeDispatch) { + await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + handle, + throughSequence + ) + ) + const assigneeHandle = activeDispatch.assignee_handle + if (assigneeHandle && assigneeHandle !== handle) { + await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + assigneeHandle, + throughSequence + ) + ) + } + } + await revalidateWorkerMailbox() + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const messages = showAll + ? db.getAllMessagesForHandle(address, 100, typeFilter) + : db.getUnreadMessages(address, typeFilter) + if (!showAll && params.peek !== true && messages.length > 0) { + db.markAsRead(messages.map((message) => message.id)) + } + if (messages.length > 0 || !params.wait) { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages, + count: messages.length, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + await revalidateWorkerMailbox() + if (waitResult === 'timed_out' || waitResult === 'cancelled') { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: [], + count: 0, + timedOut: waitResult === 'timed_out', + cancelled: waitResult === 'cancelled', + connectionLost: waitResult === 'cancelled' && signal?.aborted === true + } + } + const arrived = db.getUnreadMessages(address, typeFilter) + db.markAsRead(arrived.map((message) => message.id)) + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: arrived, + count: arrived.length, + ...(params.format || params.inject + ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts new file mode 100644 index 00000000000..ae21a4e5a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts @@ -0,0 +1,178 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { resolveRunScope } from './orchestration-run-scope' +import { DispatchParams, DispatchShowParams } from './orchestration-schemas' + +export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.dispatch', + params: DispatchParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + legacyCoordinatorRunId, + revalidateLegacyCoordinator + } + ) => { + const db = runtime.getOrchestrationDb() + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (task.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${task.id} was not found in Run ${run.id}.` + ) + } + + // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. + if (params.dryRun) { + const maxDepth = runtime.getNestedWorkerMaxDepth() + const previewDepth = db.resolveChildDispatchDepth( + resolveDispatchCreator(runtime, params.from), + maxDepth + ) + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: 'ctx_dryrun', + canDispatchSubWorkers: previewDepth < maxDepth, + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: params.to ?? 'worker', + devMode: params.devMode, + ...(params.to + ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } + : {}) + }) + return { dispatch: null, injected: false, dryRun: true, preamble } + } + + if (!params.to) { + throw new Error('Missing --to') + } + const to = params.to + + if (task.status !== 'ready') { + throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) + } + + // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. + if (params.inject) { + const hasAgent = await runtime.isTerminalRunningAgent(to) + if (!hasAgent) { + throw new Error(buildInjectRejectionMessage(to)) + } + } + + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) + const assigneePaneKey = + dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined + const processIncarnation = + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined + if (params.inject && (!assigneePaneKey || !processIncarnation)) { + throw new OrchestrationError( + 'stable_pane_required', + `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` + ) + } + + revalidateLegacyCoordinator?.() + const ctx = db.createDispatchContext({ + taskId: params.task, + assigneeHandle: to, + assigneePaneKey, + launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation, + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth() + }) + const dispatchCapability = params.inject + ? db.mintDispatchCapability({ + dispatchId: ctx.id, + paneKey: assigneePaneKey as string, + processIncarnation: processIncarnation as string + }) + : undefined + + // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: ctx.id, + canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: to, + dispatchCapability, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(to) + }) + + let injected = false + if (params.inject) { + try { + await runtime.sendTerminalAgentPrompt(to, preamble) + injected = true + } catch (err) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + throw err + } + } + + // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. + if (params.returnPreamble) { + return { dispatch: ctx, injected, preamble } + } + return { dispatch: ctx, injected } + } + }), + + defineMethod({ + name: 'orchestration.dispatchShow', + params: DispatchShowParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!params.task) { + throw new Error('Missing --task') + } + const ctx = db.getDispatchContext(params.task) + + // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. + if (params.preamble) { + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const workerHandle = ctx?.assignee_handle ?? 'worker' + const preamble = buildDispatchPreamble({ + taskId: task.id, + // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. + dispatchId: ctx?.id ?? 'ctx_preview', + canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle, + devMode: params.devMode, + ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) + }) + return { dispatch: ctx ?? null, preamble } + } + + return { dispatch: ctx ?? null } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-message-methods.ts b/src/main/runtime/rpc/methods/orchestration-message-methods.ts new file mode 100644 index 00000000000..faf89669247 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-message-methods.ts @@ -0,0 +1,219 @@ +import { defineMethod, type RpcMethod } from '../core' +import type { TaskStatus } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' +import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' +import { resolveRunScope } from './orchestration-run-scope' +import { + ReplyParams, + InboxParams, + TaskCreateParams, + TaskListParams, + TaskUpdateParams +} from './orchestration-schemas' + +export const ORCHESTRATION_MESSAGE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reply', + params: ReplyParams, + handler: async ( + params, + { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } + ) => { + const db = runtime.getOrchestrationDb() + const original = db.getMessageById(params.id) + if (!original) { + throw new Error(`Message not found: ${params.id}`) + } + if ( + legacyCoordinatorRunId && + (original.run_id !== legacyCoordinatorRunId || + (params.run !== undefined && params.run !== legacyCoordinatorRunId)) + ) { + throw new OrchestrationError( + 'request_mismatch', + `Message ${params.id} does not belong to this adopted Run.`, + { effectsApplied: false } + ) + } + if ( + original.run_id === ORCHESTRATION_LEGACY_RUN_ID || + original.delivery_contract === 'legacy_direct' || + original.delivery_contract === 'audit_only' + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; no reply was applied.', + { effectsApplied: false } + ) + } + + const question = db.getQuestion(params.id) + if (question) { + const run = resolveRunScope(runtime, { + runId: params.run ?? question.run_id, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const answered = db.answerQuestion({ + messageId: question.message_id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: params.body + }) + const federated = db.getFederatedDispatch(question.dispatch_id) + if (federated) { + db.enqueueFederationRelay({ + dispatchId: question.dispatch_id, + direction: 'to_worker', + kind: 'reply', + payload: JSON.stringify({ + questionId: question.message_id, + answerMessageId: answered.message.id, + body: params.body + }) + }) + runtime.ensureOrchestrationFederationRelay(run.id) + } else { + runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') + } + return { + message: answered.message, + question: answered.question, + duplicate: answered.duplicate + } + } + + db.markAsRead([original.id]) + + const reply = db.insertMessage({ + from: params.from ?? original.to_handle, + to: original.from_handle, + subject: `Re: ${original.subject}`, + body: params.body, + threadId: original.thread_id ?? original.id, + runId: original.run_id + }) + + runtime.notifyMessageArrived(reply.to_handle, reply.type) + return { message: reply } + } + }), + + defineMethod({ + name: 'orchestration.inbox', + params: InboxParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). + const messages = params.terminal + ? db.getAllMessagesForHandle(params.terminal, params.limit) + : db.getInbox(params.limit) + return { messages, count: messages.length } + } + }), + + defineMethod({ + name: 'orchestration.taskCreate', + params: TaskCreateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const creatorAuthority = params.callerTerminalHandle + ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + : null + const task = db.createTask({ + spec: params.spec, + taskTitle: params.taskTitle, + displayName: params.displayName, + deps, + parentId: params.parent, + createdByTerminalHandle: params.callerTerminalHandle, + ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation + ? { + createdByPaneKey: creatorAuthority.paneKey, + createdByProcessIncarnation: creatorAuthority.processIncarnation, + createdByRunGeneration: run.consumer_generation + } + : {}), + runId: run.id + }) + return { task } + } + }), + + defineMethod({ + name: 'orchestration.taskList', + params: TaskListParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const explicitRun = params.run ? db.getRun(params.run) : undefined + const run = + explicitRun?.legacy === 1 + ? explicitRun + : resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: params.run === undefined, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. + const joined = db.listTasksWithDispatch({ + status: params.status as TaskStatus, + ready: params.ready, + runId: run.id + }) + const tasks = joined.map((row) => { + const { assignee_handle, dispatch_id, ...base } = row + if (base.status === 'dispatched') { + return { ...base, assignee_handle, dispatch_id } + } + return base + }) + return { + runId: run.id, + legacyReadOnly: run.legacy === 1, + tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, + count: tasks.length + } + } + }), + + defineMethod({ + name: 'orchestration.taskUpdate', + params: TaskUpdateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const existing = db.getTask(params.id) + if (!existing || existing.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${params.id} was not found in Run ${run.id}.` + ) + } + const task = db.updateTaskStatus(params.id, params.status, params.result) + if (!task) { + throw new Error(`Task not found: ${params.id}`) + } + return { task } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-reset-methods.ts b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts new file mode 100644 index 00000000000..d98fc4719b9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts @@ -0,0 +1,27 @@ +import { defineMethod, type RpcMethod } from '../core' +import { ResetParams } from './orchestration-schemas' + +export const ORCHESTRATION_RESET_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reset', + params: ResetParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (params.all) { + runtime.stopOrchestrationFederationRelay() + db.resetAll() + return { reset: 'all' } + } + if (params.tasks) { + runtime.stopOrchestrationFederationRelay() + db.resetTasks() + return { reset: 'tasks' } + } + if (params.messages) { + db.resetMessages() + return { reset: 'messages' } + } + throw new Error('Invalid reset scope') + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-routing.ts b/src/main/runtime/rpc/methods/orchestration-routing.ts new file mode 100644 index 00000000000..0722f19b44e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-routing.ts @@ -0,0 +1,135 @@ +import type { MessageType } from '../../orchestration/db' +import type { RunRow } from '../../orchestration/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { MESSAGE_TYPES } from '../../orchestration/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { LEGACY_CONTRACT_VERSION } from '../../orchestration/db' + +export function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { + const types = rawTypes + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) as MessageType[] | undefined + const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) + if (invalidTypes && invalidTypes.length > 0) { + throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) + } + return types && types.length > 0 ? types : undefined +} + +export function resolveMessageRun( + runtime: OrcaRuntimeService, + params: { + from?: string + senderPaneKey?: string + to?: string + runId?: string + payload?: string + } +): { run: RunRow | undefined; dispatchId: string | undefined } { + const db = runtime.getOrchestrationDb() + let dispatchId: string | undefined + if (params.payload) { + try { + const payload: unknown = JSON.parse(params.payload) + if ( + payload && + typeof payload === 'object' && + !Array.isArray(payload) && + typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' + ) { + dispatchId = (payload as { dispatchId: string }).dispatchId + } + } catch { + // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. + } + } + if (!dispatchId && params.to?.startsWith('dispatch:')) { + dispatchId = params.to.slice('dispatch:'.length) + } + + const dispatch = dispatchId + ? db.getDispatchContextById(dispatchId) + : params.from + ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) + : undefined + if (params.to?.startsWith('dispatch:') && !dispatch) { + throw new OrchestrationError( + 'dispatch_not_found', + `Dispatch ${dispatchId ?? ''} was not found.` + ) + } + const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined + const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id + let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined + + if (!run && params.from) { + const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) + run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + } + if (resolvedRunId && (!run || run.legacy === 1)) { + throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) + } + if (run && targetRunId && targetRunId !== run.id) { + throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) + } + if (run && dispatch && dispatch.run_id !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` + ) + } + return { run, dispatchId: dispatch?.id ?? dispatchId } +} + +export function legacyWorkerDeliveryContract( + runtime: OrcaRuntimeService, + runId: string | undefined, + recipient: string +): 'legacy_direct' | undefined { + if (!runId) { + return undefined + } + if (!recipient.startsWith('dispatch:')) { + return runtime + .getOrchestrationDb() + .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) + ? 'legacy_direct' + : undefined + } + const dispatch = runtime + .getOrchestrationDb() + .getDispatchContextById(recipient.slice('dispatch:'.length)) + return dispatch?.run_id === runId && + dispatch.contract_version === LEGACY_CONTRACT_VERSION && + (dispatch.status === 'pending' || dispatch.status === 'dispatched') + ? 'legacy_direct' + : undefined +} + +export function interruptedAcknowledgedCheck( + runId: string, + acknowledged: string, + reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' +): Record { + return { + runId, + deliveryId: null, + messages: [], + count: 0, + acknowledged, + timedOut: false, + cancelled: false, + connectionLost: false, + waitInterrupted: reason + } +} + +export function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { + if (params.to || params.run) { + throw new OrchestrationError( + 'invalid_argument', + 'Federated Dispatch messages route to their Run home; omit --to and --run.' + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-schemas.ts b/src/main/runtime/rpc/methods/orchestration-schemas.ts new file mode 100644 index 00000000000..d1023827fee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-schemas.ts @@ -0,0 +1,272 @@ +import { z } from 'zod' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' +import type { TaskStatus } from '../../orchestration/db' +import { isGroupAddress } from '../../orchestration/groups' +import { MESSAGE_TYPES } from '../../orchestration/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export const TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] + +export async function routeAllMailboxPages( + routePage: () => { routedCount: number; hasMore: boolean }, + signal?: AbortSignal +): Promise { + while (true) { + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + const page = routePage() + if (!page.hasMore) { + return + } + await yieldToEventLoop() + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + } +} + +const SEND_MESSAGE_TYPE_ERROR = [ + `Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`, + 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' +].join(' ') + +export type DispatchMutationMessageType = + | 'worker_done' + | 'heartbeat' + | 'escalation' + | 'decision_gate' + +export function isDispatchMutationMessageType( + type: string | undefined +): type is DispatchMutationMessageType { + return ( + type === 'worker_done' || + type === 'heartbeat' || + type === 'escalation' || + type === 'decision_gate' + ) +} + +export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +export function parseRemoteWorkerPayload(payload: string | undefined): Record { + if (!payload) { + return {} + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') + } +} + +export function parseMessageTaskId(payload: string | undefined): string | undefined { + if (!payload) { + return undefined + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? typeof (parsed as { taskId?: unknown }).taskId === 'string' + ? (parsed as { taskId: string }).taskId + : undefined + : undefined + } catch { + return undefined + } +} + +export function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { + return value === 'succeeded' || value === 'failed' +} + +export const SendParams = z + .object({ + to: OptionalString, + subject: requiredString('Missing --subject'), + from: OptionalString, + body: OptionalString, + type: z + .enum(MESSAGE_TYPES, { + error: SEND_MESSAGE_TYPE_ERROR + }) + .optional(), + priority: z.enum(['normal', 'high', 'urgent']).optional(), + threadId: OptionalString, + payload: OptionalString, + // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. + senderPaneKey: OptionalString, + run: OptionalString, + waitForLifecycleSettlement: OptionalBoolean, + devMode: OptionalBoolean + }) + .superRefine((params, ctx) => { + if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { + return + } + // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: getLifecycleGroupRecipientError(params.type), + path: ['to'] + }) + }) + +export const CheckParams = z + .object({ + terminal: OptionalString, + terminalPaneKey: OptionalString, + unread: OptionalBoolean, + peek: OptionalBoolean, + // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). + all: OptionalBoolean, + types: OptionalString, + format: OptionalBoolean, + // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. + inject: OptionalBoolean, + ack: OptionalString, + compatibilityAck: OptionalString, + compatibilityQuestionAck: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + run: OptionalString, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber + }) + .superRefine((params, ctx) => { + // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. + const modes = [ + params.unread === true, + params.peek === true, + params.all === true || (params.unread === false && params.peek !== true) + ].filter(Boolean) + if (modes.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose at most one message read mode: --unread, --peek, or --all.' + }) + } + }) + +export const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString, + run: OptionalString +}) + +export const InboxParams = z.object({ + limit: OptionalFiniteNumber, + // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). + terminal: OptionalString +}) + +export const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + taskTitle: OptionalString, + displayName: OptionalString, + deps: OptionalString, + parent: OptionalString, + callerTerminalHandle: OptionalString, + run: OptionalString +}) + +export const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean, + // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. + brief: OptionalBoolean, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const TaskUpdateParams = z.object({ + id: requiredString('Missing --id'), + status: z + .unknown() + .transform((v) => { + if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { + return v as TaskStatus + } + return '' + }) + .pipe( + z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { + message: 'Missing --status' + }) + ), + result: OptionalString, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const DispatchParams = z.object({ + task: requiredString('Missing --task'), + // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. + to: OptionalString, + from: OptionalString, + inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, + devMode: OptionalBoolean, + run: OptionalString +}) + +export const DispatchShowParams = z.object({ + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +export const AskParams = z + .object({ + to: OptionalString, + question: OptionalString, + resume: OptionalString, + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString, + run: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() + }) + .superRefine((params, ctx) => { + if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one of --question or --resume.' + }) + } + }) + +export const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) diff --git a/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts new file mode 100644 index 00000000000..40f8abf08ee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts @@ -0,0 +1,83 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' +import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { SendParams } from './orchestration-schemas' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +/** Delivers coordinator control mail to a federated worker when `to` names its exact Dispatch. */ +export function sendFederatedControlMail(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + messageRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + } = args + const dispatchId = to.startsWith('dispatch:') ? to.slice('dispatch:'.length) : undefined + const federatedTarget = + dispatchId && to === `dispatch:${dispatchId}` ? db.getFederatedDispatch(dispatchId) : undefined + if (!federatedTarget || !dispatchId) { + return undefined + } + if (federatedTarget.protocol_version < ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION) { + throw new OrchestrationError( + 'capability_unsupported', + `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` + ) + } + if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { + throw new OrchestrationError( + 'dispatch_inactive', + `Federated Dispatch ${dispatchId} is not active.` + ) + } + if (params.type === 'worker_done' || params.type === 'heartbeat') { + throw new OrchestrationError( + 'invalid_argument', + 'Coordinator-to-worker control mail cannot report worker lifecycle.' + ) + } + revalidateLegacyCoordinator?.() + const relay = db.enqueueFederationRelay({ + dispatchId, + direction: 'to_worker', + kind: 'control_message', + payload: encodeFederatedControlMessage({ + from, + subject: params.subject, + body: params.body ?? '', + type: (params.type ?? 'status') as MessageType, + priority: (params.priority ?? 'normal') as MessagePriority, + threadId: params.threadId ?? null, + payload: params.payload ?? null + }) + }) + runtime.ensureOrchestrationFederationRelay(messageRunId) + return withSendWarnings({ + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'worker', + accepted: true + } + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-group.ts b/src/main/runtime/rpc/methods/orchestration-send-group.ts new file mode 100644 index 00000000000..aa3c8d47788 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-group.ts @@ -0,0 +1,131 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { resolveGroupAddress } from '../../orchestration/groups' +import { resolveBareOrchestrationRecipient } from './orchestration-recipient-routing' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { SendParams } from './orchestration-schemas' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export async function sendGroupMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + groupAddress: string + senderPaneKey: string | undefined + senderRunId: string | undefined + explicitRunId: string | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + recordMutationReceipt: ((receipt: unknown) => void) | undefined + withSendWarnings: SendReceipt +}): Promise { + const { + params, + runtime, + db, + from, + groupAddress, + senderPaneKey, + senderRunId, + explicitRunId, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } = args + // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). + const { terminals } = await runtime.listTerminals(undefined, undefined, { + includeVisualLayouts: false + }) + const handles = resolveGroupAddress(groupAddress, from, terminals, (handle: string) => + runtime.getAgentStatusForHandle(handle) + ) + if (handles.length === 0) { + throw new Error(`No recipients resolved for group address: ${groupAddress}`) + } + + const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() + const resolvedRecipients = handles.map((handle) => ({ + handle, + resolution: resolveBareOrchestrationRecipient({ + runtime, + db, + handle, + senderRunId, + explicitRunId, + legacyAdoptedMailboxOwner + }) + })) + const deliverableRecipients = resolvedRecipients.filter( + ( + recipient + ): recipient is typeof recipient & { + resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } + } => recipient.resolution.ok + ) + const senderRecipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: from, + senderRunId, + legacyAdoptedMailboxOwner + }) + const senderMailboxKey = senderRecipient.ok + ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` + : undefined + const seenMailboxes = new Set() + const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { + const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` + if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { + return false + } + seenMailboxes.add(mailboxKey) + return true + }) + if (uniqueRecipients.length === 0) { + throw new OrchestrationError( + 'terminal_not_found', + `No recipient of ${groupAddress} resolved to a live terminal or durable Run/Dispatch mailbox.` + ) + } + + revalidateLegacyCoordinator?.() + const threadId = params.threadId ?? `thread_${Date.now()}` + const messages = db.insertMessages( + uniqueRecipients.map(({ resolution }) => ({ + from, + to: resolution.to, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId, + payload: params.payload, + senderPaneKey, + runId: resolution.runId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + resolution.runId ?? legacyCoordinatorRunId, + resolution.to + ) + })) + ) + const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => + resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] + ) + const receipt = { + messages, + recipients: messages.length, + ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) + } + recordMutationReceipt?.(receipt) + for (const message of messages) { + runtime.notifyMessageArrived(message.to_handle, message.type) + } + return receipt +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-methods.ts b/src/main/runtime/rpc/methods/orchestration-send-methods.ts new file mode 100644 index 00000000000..d780d6a5a5e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-methods.ts @@ -0,0 +1,188 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { isGroupAddress } from '../../orchestration/groups' +import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' +import { + SendParams, + isWorkerReportOutcome, + parseRemoteWorkerPayload +} from './orchestration-schemas' +import { resolveMessageRun } from './orchestration-routing' +import { + assertDispatchMailboxDeliverable, + resolveBareOrchestrationRecipient, + type SendRecipientWarning +} from './orchestration-recipient-routing' +import { sendRemoteMessage } from './orchestration-send-remote' +import { sendPointToPointMessage } from './orchestration-send-point-to-point' +import { sendGroupMessage } from './orchestration-send-group' +import { sendFederatedControlMail } from './orchestration-send-control-mail' + +export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.send', + params: SendParams, + handler: async ( + params, + { + runtime, + orchestrationCapability, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityCallerAuthority, + recordMutationReceipt, + signal + } + ) => { + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + const attestedCaller = + orchestrationCompatibilityCallerAuthority?.terminalHandle === from + ? orchestrationCompatibilityCallerAuthority + : undefined + // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. + const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = senderPaneKey + ? db.findActiveRemoteAttachmentForPane(senderPaneKey) + : undefined + if (remoteAttachment && senderPaneKey) { + return sendRemoteMessage({ + params, + runtime, + db, + from, + senderPaneKey, + remoteAttachment, + processIncarnation: + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + orchestrationCapability, + signal + }) + } + + const routing = resolveMessageRun(runtime, { + from, + senderPaneKey, + to: params.to, + runId: params.run, + payload: params.payload + }) + if ( + params.type === 'worker_done' && + !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'worker_done requires outcome=succeeded|failed for a current Dispatch.' + ) + } + if (params.to?.startsWith('task:')) { + throw new OrchestrationError( + 'invalid_argument', + 'Task recipients are intentionally unsupported; use run: or dispatch:.' + ) + } + + let to = params.to + if ( + routing.run && + (!to || + ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) + ) { + to = `run:${routing.run.id}` + } + if (!to) { + throw new OrchestrationError( + 'run_required', + 'No recipient or active Dispatch Run could be resolved. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } + + const sendWarnings: SendRecipientWarning[] = [] + let messageRunId = routing.run?.id + if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + const recipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: to, + senderRunId: routing.run?.id, + explicitRunId: params.run + }) + if (!recipient.ok) { + throw new OrchestrationError(recipient.code, recipient.message) + } + to = recipient.to + messageRunId = recipient.runId + if (recipient.warning) { + sendWarnings.push(recipient.warning) + } + } + const withSendWarnings = ( + receipt: T + ): T & { warnings?: SendRecipientWarning[] } => + sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt + + if (!isGroupAddress(to)) { + const addressedDispatchId = to.startsWith('dispatch:') + ? to.slice('dispatch:'.length) + : undefined + const federatedTarget = + addressedDispatchId && to === `dispatch:${addressedDispatchId}` + ? db.getFederatedDispatch(addressedDispatchId) + : undefined + // Federated targets perform their own liveness check before relaying. + if (addressedDispatchId && !federatedTarget) { + assertDispatchMailboxDeliverable(db, addressedDispatchId) + } + const federatedControl = sendFederatedControlMail({ + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + }) + if (federatedControl !== undefined) { + return federatedControl + } + return sendPointToPointMessage({ + params, + runtime, + db, + from, + to, + dispatchId: routing.dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + processIncarnation: + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + revalidateLegacyCoordinator, + withSendWarnings + }) + } + return sendGroupMessage({ + params, + runtime, + db, + from, + groupAddress: to, + senderPaneKey, + senderRunId: routing.run?.id, + explicitRunId: params.run, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt, + withSendWarnings + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts new file mode 100644 index 00000000000..3988dc90af8 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts @@ -0,0 +1,187 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { isDispatchMutationMessageType, parseMessageTaskId } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export function sendPointToPointMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + dispatchId: string | undefined + messageRunId: string | undefined + senderPaneKey: string | undefined + legacyCoordinatorRunId: string | undefined + orchestrationCapability: string | undefined + processIncarnation: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + processIncarnation, + revalidateLegacyCoordinator, + withSendWarnings + } = args + // Point-to-point — existing single-recipient behavior + revalidateLegacyCoordinator?.() + const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) : undefined + const messageType = (params.type ?? 'status') as MessageType + const msg = db.insertMessage({ + from, + to, + subject: params.subject, + body: params.body, + type: messageType, + priority: params.priority as MessagePriority, + threadId: params.threadId, + payload: dispatch + ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) + : params.payload, + senderPaneKey, + runId: messageRunId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + messageRunId ?? legacyCoordinatorRunId, + to + ) + }) + if (isDispatchMutationMessageType(msg.type)) { + const taskId = parseMessageTaskId(params.payload) + const capabilityBacked = Boolean(dispatch?.capability_hash) + const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' + const authority = resolveLifecycleAuthority({ + db, + dispatch, + from, + paneKey: senderPaneKey, + processIncarnation, + capability: orchestrationCapability, + taskId, + capabilityBacked, + coordinatorMutation + }) + if (!authority.valid) { + const rejection = + db.convertLifecycleMessageToRejection(msg.id, authority.code, authority.reason) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ + message: rejection, + lifecycle: { action: 'rejected', code: authority.code, reason: authority.reason } + }) + } + } + + // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. + if (msg.type === 'worker_done' || msg.type === 'heartbeat') { + const reconciled = reconcileLifecycleMessage(db, msg) + // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. + if (reconciled.action === 'suppressed') { + return withSendWarnings({ message: msg }) + } + if (reconciled.action === 'rejected') { + const rejection = db.getMessageById(msg.id) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ message: rejection, lifecycle: reconciled }) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings( + msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } + ) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings({ message: msg }) +} + +type LifecycleAuthority = { + valid: boolean + code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' + reason: string +} + +function resolveLifecycleAuthority(args: { + db: OrchestrationDb + dispatch: ReturnType + from: string + paneKey: string | undefined + processIncarnation: string | undefined + capability: string | undefined + taskId: string | undefined + capabilityBacked: boolean + coordinatorMutation: boolean +}): LifecycleAuthority { + const { + db, + dispatch, + from, + paneKey, + processIncarnation, + capability, + taskId, + capabilityBacked, + coordinatorMutation + } = args + if (!dispatch) { + return { + valid: !coordinatorMutation, + code: 'sender_not_assignee', + reason: 'No active Dispatch belongs to this message sender.' + } + } + if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { + return { + valid: false, + code: 'task_dispatch_mismatch', + reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` + } + } + if (capabilityBacked) { + const authority = db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability, + paneKey, + processIncarnation + }) + return { + valid: authority.valid, + code: 'dispatch_capability_invalid', + reason: authority.valid ? '' : authority.reason + } + } + if (dispatch.process_incarnation) { + return { + valid: db.isDispatchProcessCurrent({ + dispatchId: dispatch.id, + paneKey: paneKey ?? null, + processIncarnation: processIncarnation ?? null + }), + code: 'sender_not_assignee', + reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` + } + } + return { + valid: + !coordinatorMutation || + db.isDispatchMessageSender({ dispatchId: dispatch.id, handle: from, paneKey }), + code: 'sender_not_assignee', + reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-remote.ts b/src/main/runtime/rpc/methods/orchestration-send-remote.ts new file mode 100644 index 00000000000..9243b977d2e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-remote.ts @@ -0,0 +1,114 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { z } from 'zod' +import { parseRemoteWorkerPayload } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' + +type SendParamsInput = z.infer + +type RemoteAttachment = { + dispatch_id: string + protocol_version: number +} + +export async function sendRemoteMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + senderPaneKey: string + remoteAttachment: RemoteAttachment + processIncarnation?: string | null + orchestrationCapability?: string + signal?: AbortSignal +}): Promise { + const { params, runtime, db, from, senderPaneKey, remoteAttachment } = args + rejectFederatedExplicitTarget(params) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: remoteAttachment.dispatch_id, + capability: args.orchestrationCapability, + paneKey: senderPaneKey, + processIncarnation: args.processIncarnation ?? null + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + + const type = (params.type ?? 'status') as MessageType + const payload = parseRemoteWorkerPayload(params.payload) + if ( + typeof payload.dispatchId === 'string' && + payload.dispatchId !== remoteAttachment.dispatch_id + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` + ) + } + const outcome = + type === 'worker_done' && (payload.outcome === 'succeeded' || payload.outcome === 'failed') + ? payload.outcome + : undefined + if (type === 'worker_done' && !outcome) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote worker_done requires outcome=succeeded|failed.' + ) + } + + const supportsLifecycleSettlement = + remoteAttachment.protocol_version >= + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION + const relay = db.enqueueFederationRelay({ + dispatchId: remoteAttachment.dispatch_id, + direction: 'to_home', + kind: type, + payload: JSON.stringify({ + from, + subject: params.subject, + body: params.body ?? '', + type, + priority: params.priority ?? 'normal', + threadId: params.threadId ?? null, + payload: bindCoordinatorMutationPayload(type, params.payload, remoteAttachment.dispatch_id) + }), + ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) + }) + const lifecycle = + outcome && supportsLifecycleSettlement + ? await waitForFederatedLifecycleSettlement(runtime, relay.dispatch_id, relay.sequence, { + timeoutMs: 30_000, + signal: args.signal + }) + : outcome + ? { + action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), + authority: 'worker_server_legacy' as const + } + : undefined + if (outcome && supportsLifecycleSettlement && !lifecycle) { + throw new OrchestrationError( + 'operation_unknown', + 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' + ) + } + return { + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'run_home', + accepted: true + }, + ...(lifecycle ? { lifecycle } : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 2d5e7b6a796..fab5feba812 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,2057 +1,26 @@ -/* eslint-disable max-lines -- Why: RPC method definitions co-locate param schemas with handlers; splitting by method would scatter the shared enums and Zod transforms without reducing complexity. */ -import { z } from 'zod' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { defineMethod, type RpcMethod } from '../core' -import { resolveDispatchCreator } from './orchestration-dispatch-creator' -import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' -import { - LEGACY_CONTRACT_VERSION, - type MessageRow, - type MessageType, - type MessagePriority, - type TaskStatus -} from '../../orchestration/db' -import { MESSAGE_TYPES } from '../../orchestration/types' -import { buildDispatchPreamble } from '../../orchestration/preamble' -import { formatMessageBanner } from '../../orchestration/formatter' -import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' -import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' -import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' -import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' -import { - ORCHESTRATION_LEGACY_RUN_ID, - orchestrationSkillRecoveryData -} from '../../../../shared/orchestration-rpc-contract' -import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' -import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' -import { - assertDispatchMailboxDeliverable, - resolveBareOrchestrationRecipient, - type SendRecipientWarning -} from './orchestration-recipient-routing' -import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' -import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' -import { resolveRunScope } from './orchestration-run-scope' +import type { RpcMethod } from '../core' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration-federation-methods' import { ORCHESTRATION_MUTATION_REQUEST_METHODS } from './orchestration-mutation-request-show' -import { OrchestrationError } from '../../orchestration/orchestration-error' -import type { OrcaRuntimeService } from '../../orca-runtime' -import type { RunRow } from '../../orchestration/types' -import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' -import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' -import { - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION -} from '../../../../shared/protocol-version' - -const TASK_STATUSES: TaskStatus[] = [ - 'pending', - 'ready', - 'dispatched', - 'completed', - 'failed', - 'blocked' -] - -async function routeAllMailboxPages( - routePage: () => { routedCount: number; hasMore: boolean }, - signal?: AbortSignal -): Promise { - while (true) { - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - const page = routePage() - if (!page.hasMore) { - return - } - await yieldToEventLoop() - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - } -} - -type DispatchMutationMessageType = 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' - -const SEND_MESSAGE_TYPE_ERROR = [ - `Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`, - 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' -].join(' ') - -function isDispatchMutationMessageType( - type: string | undefined -): type is DispatchMutationMessageType { - return ( - type === 'worker_done' || - type === 'heartbeat' || - type === 'escalation' || - type === 'decision_gate' - ) -} - -function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - -function parseRemoteWorkerPayload(payload: string | undefined): Record { - if (!payload) { - return {} - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') - } -} - -function parseMessageTaskId(payload: string | undefined): string | undefined { - if (!payload) { - return undefined - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? typeof (parsed as { taskId?: unknown }).taskId === 'string' - ? (parsed as { taskId: string }).taskId - : undefined - : undefined - } catch { - return undefined - } -} - -function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { - return value === 'succeeded' || value === 'failed' -} - -const SendParams = z - .object({ - to: OptionalString, - subject: requiredString('Missing --subject'), - from: OptionalString, - body: OptionalString, - type: z - .enum(MESSAGE_TYPES, { - error: SEND_MESSAGE_TYPE_ERROR - }) - .optional(), - priority: z.enum(['normal', 'high', 'urgent']).optional(), - threadId: OptionalString, - payload: OptionalString, - // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. - senderPaneKey: OptionalString, - run: OptionalString, - waitForLifecycleSettlement: OptionalBoolean, - devMode: OptionalBoolean - }) - .superRefine((params, ctx) => { - if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { - return - } - // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: getLifecycleGroupRecipientError(params.type), - path: ['to'] - }) - }) - -const CheckParams = z - .object({ - terminal: OptionalString, - terminalPaneKey: OptionalString, - unread: OptionalBoolean, - peek: OptionalBoolean, - // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). - all: OptionalBoolean, - types: OptionalString, - format: OptionalBoolean, - // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. - inject: OptionalBoolean, - ack: OptionalString, - compatibilityAck: OptionalString, - compatibilityQuestionAck: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - run: OptionalString, - wait: OptionalBoolean, - timeoutMs: OptionalFiniteNumber - }) - .superRefine((params, ctx) => { - // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. - const modes = [ - params.unread === true, - params.peek === true, - params.all === true || (params.unread === false && params.peek !== true) - ].filter(Boolean) - if (modes.length > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose at most one message read mode: --unread, --peek, or --all.' - }) - } - }) - -const ReplyParams = z.object({ - id: requiredString('Missing --id'), - body: requiredString('Missing --body'), - from: OptionalString, - run: OptionalString -}) - -const InboxParams = z.object({ - limit: OptionalFiniteNumber, - // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). - terminal: OptionalString -}) - -const TaskCreateParams = z.object({ - spec: requiredString('Missing --spec'), - taskTitle: OptionalString, - displayName: OptionalString, - deps: OptionalString, - parent: OptionalString, - callerTerminalHandle: OptionalString, - run: OptionalString -}) - -const TaskListParams = z.object({ - status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), - ready: OptionalBoolean, - // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. - brief: OptionalBoolean, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const TaskUpdateParams = z.object({ - id: requiredString('Missing --id'), - status: z - .unknown() - .transform((v) => { - if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { - return v as TaskStatus - } - return '' - }) - .pipe( - z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { - message: 'Missing --status' - }) - ), - result: OptionalString, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const DispatchParams = z.object({ - task: requiredString('Missing --task'), - // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. - to: OptionalString, - from: OptionalString, - inject: OptionalBoolean, - dryRun: OptionalBoolean, - returnPreamble: OptionalBoolean, - devMode: OptionalBoolean, - run: OptionalString -}) - -const DispatchShowParams = z.object({ - task: OptionalString, - preamble: OptionalBoolean, - from: OptionalString, - devMode: OptionalBoolean -}) - -const AskParams = z - .object({ - to: OptionalString, - question: OptionalString, - resume: OptionalString, - options: OptionalString, - timeoutMs: OptionalFiniteNumber, - from: OptionalString, - run: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() - }) - .superRefine((params, ctx) => { - if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one of --question or --resume.' - }) - } - }) - -const ResetParams = z - .object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean - }) - .superRefine((params, ctx) => { - const selectedScopeCount = [params.all, params.tasks, params.messages].filter( - (scope) => scope === true - ).length - if (selectedScopeCount !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' - }) - } - }) - -function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { - const types = rawTypes - ?.split(',') - .map((type) => type.trim()) - .filter(Boolean) as MessageType[] | undefined - const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) - if (invalidTypes && invalidTypes.length > 0) { - throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) - } - return types && types.length > 0 ? types : undefined -} - -function resolveMessageRun( - runtime: OrcaRuntimeService, - params: { - from?: string - senderPaneKey?: string - to?: string - runId?: string - payload?: string - } -): { run: RunRow | undefined; dispatchId: string | undefined } { - const db = runtime.getOrchestrationDb() - let dispatchId: string | undefined - if (params.payload) { - try { - const payload: unknown = JSON.parse(params.payload) - if ( - payload && - typeof payload === 'object' && - !Array.isArray(payload) && - typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' - ) { - dispatchId = (payload as { dispatchId: string }).dispatchId - } - } catch { - // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. - } - } - if (!dispatchId && params.to?.startsWith('dispatch:')) { - dispatchId = params.to.slice('dispatch:'.length) - } - - const dispatch = dispatchId - ? db.getDispatchContextById(dispatchId) - : params.from - ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) - : undefined - if (params.to?.startsWith('dispatch:') && !dispatch) { - throw new OrchestrationError( - 'dispatch_not_found', - `Dispatch ${dispatchId ?? ''} was not found.` - ) - } - const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined - const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id - let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined - - if (!run && params.from) { - const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) - run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - } - if (resolvedRunId && (!run || run.legacy === 1)) { - throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) - } - if (run && targetRunId && targetRunId !== run.id) { - throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) - } - if (run && dispatch && dispatch.run_id !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` - ) - } - return { run, dispatchId: dispatch?.id ?? dispatchId } -} - -function legacyWorkerDeliveryContract( - runtime: OrcaRuntimeService, - runId: string | undefined, - recipient: string -): 'legacy_direct' | undefined { - if (!runId) { - return undefined - } - if (!recipient.startsWith('dispatch:')) { - return runtime - .getOrchestrationDb() - .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) - ? 'legacy_direct' - : undefined - } - const dispatch = runtime - .getOrchestrationDb() - .getDispatchContextById(recipient.slice('dispatch:'.length)) - return dispatch?.run_id === runId && - dispatch.contract_version === LEGACY_CONTRACT_VERSION && - (dispatch.status === 'pending' || dispatch.status === 'dispatched') - ? 'legacy_direct' - : undefined -} - -function interruptedAcknowledgedCheck( - runId: string, - acknowledged: string, - reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' -): Record { - return { - runId, - deliveryId: null, - messages: [], - count: 0, - acknowledged, - timedOut: false, - cancelled: false, - connectionLost: false, - waitInterrupted: reason - } -} - -function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { - if (params.to || params.run) { - throw new OrchestrationError( - 'invalid_argument', - 'Federated Dispatch messages route to their Run home; omit --to and --run.' - ) - } -} +import { ORCHESTRATION_SEND_METHODS } from './orchestration-send-methods' +import { ORCHESTRATION_CHECK_METHODS } from './orchestration-check-methods' +import { ORCHESTRATION_MESSAGE_METHODS } from './orchestration-message-methods' +import { ORCHESTRATION_DISPATCH_METHODS } from './orchestration-dispatch-methods' +import { ORCHESTRATION_ASK_METHODS } from './orchestration-ask-methods' +import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' +import { ORCHESTRATION_RESET_METHODS } from './orchestration-reset-methods' export const ORCHESTRATION_METHODS: RpcMethod[] = [ ...ORCHESTRATION_RUN_METHODS, ...ORCHESTRATION_WORKER_METHODS, ...ORCHESTRATION_FEDERATION_METHODS, ...ORCHESTRATION_MUTATION_REQUEST_METHODS, - defineMethod({ - name: 'orchestration.send', - params: SendParams, - handler: async ( - params, - { - runtime, - orchestrationCapability, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - orchestrationCompatibilityCallerAuthority, - recordMutationReceipt, - signal - } - ) => { - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - const attestedCaller = - orchestrationCompatibilityCallerAuthority?.terminalHandle === from - ? orchestrationCompatibilityCallerAuthority - : undefined - // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. - const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = senderPaneKey - ? db.findActiveRemoteAttachmentForPane(senderPaneKey) - : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - const processIncarnation = - attestedCaller?.processIncarnation ?? runtime.getTerminalProcessIncarnation(from) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: remoteAttachment.dispatch_id, - capability: orchestrationCapability, - paneKey: senderPaneKey ?? null, - processIncarnation - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const type = (params.type ?? 'status') as MessageType - const payload = parseRemoteWorkerPayload(params.payload) - if ( - typeof payload.dispatchId === 'string' && - payload.dispatchId !== remoteAttachment.dispatch_id - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` - ) - } - const outcome = - type === 'worker_done' && - (payload.outcome === 'succeeded' || payload.outcome === 'failed') - ? payload.outcome - : undefined - if (type === 'worker_done' && !outcome) { - throw new OrchestrationError( - 'invalid_argument', - 'Remote worker_done requires outcome=succeeded|failed.' - ) - } - const supportsLifecycleSettlement = - remoteAttachment.protocol_version >= - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION - const relay = db.enqueueFederationRelay({ - dispatchId: remoteAttachment.dispatch_id, - direction: 'to_home', - kind: type, - payload: JSON.stringify({ - from, - subject: params.subject, - body: params.body ?? '', - type, - priority: params.priority ?? 'normal', - threadId: params.threadId ?? null, - payload: bindCoordinatorMutationPayload( - type, - params.payload, - remoteAttachment.dispatch_id - ) - }), - ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) - }) - const lifecycle = - outcome && supportsLifecycleSettlement - ? await waitForFederatedLifecycleSettlement( - runtime, - relay.dispatch_id, - relay.sequence, - { - timeoutMs: 30_000, - signal - } - ) - : outcome - ? { - action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), - authority: 'worker_server_legacy' as const - } - : undefined - if (outcome && supportsLifecycleSettlement && !lifecycle) { - throw new OrchestrationError( - 'operation_unknown', - 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' - ) - } - return { - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'run_home', - accepted: true - }, - ...(lifecycle ? { lifecycle } : {}) - } - } - const routing = resolveMessageRun(runtime, { - from, - senderPaneKey, - to: params.to, - runId: params.run, - payload: params.payload - }) - if ( - params.type === 'worker_done' && - !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) - ) { - throw new OrchestrationError( - 'invalid_argument', - 'worker_done requires outcome=succeeded|failed for a current Dispatch.' - ) - } - if (params.to?.startsWith('task:')) { - throw new OrchestrationError( - 'invalid_argument', - 'Task recipients are intentionally unsupported; use run: or dispatch:.' - ) - } - let to = params.to - if ( - routing.run && - (!to || - ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) - ) { - to = `run:${routing.run.id}` - } - if (!to) { - throw new OrchestrationError( - 'run_required', - 'No recipient or active Dispatch Run could be resolved. No effects were applied.', - orchestrationSkillRecoveryData() - ) - } - - const sendWarnings: SendRecipientWarning[] = [] - let messageRunId = routing.run?.id - if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { - const recipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: to, - senderRunId: routing.run?.id, - explicitRunId: params.run - }) - if (!recipient.ok) { - throw new OrchestrationError(recipient.code, recipient.message) - } - to = recipient.to - messageRunId = recipient.runId - if (recipient.warning) { - sendWarnings.push(recipient.warning) - } - } - const withSendWarnings = ( - receipt: T - ): T & { - warnings?: SendRecipientWarning[] - } => (sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt) - - if (!isGroupAddress(to)) { - const addressedDispatchId = to.startsWith('dispatch:') - ? to.slice('dispatch:'.length) - : undefined - const federatedTarget = - addressedDispatchId && to === `dispatch:${addressedDispatchId}` - ? db.getFederatedDispatch(addressedDispatchId) - : undefined - if (addressedDispatchId && !federatedTarget) { - assertDispatchMailboxDeliverable(db, addressedDispatchId) - } - if (federatedTarget && addressedDispatchId) { - const dispatchId = addressedDispatchId - if ( - federatedTarget.protocol_version < - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION - ) { - throw new OrchestrationError( - 'capability_unsupported', - `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` - ) - } - if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { - throw new OrchestrationError( - 'dispatch_inactive', - `Federated Dispatch ${dispatchId} is not active.` - ) - } - if (params.type === 'worker_done' || params.type === 'heartbeat') { - throw new OrchestrationError( - 'invalid_argument', - 'Coordinator-to-worker control mail cannot report worker lifecycle.' - ) - } - revalidateLegacyCoordinator?.() - const relay = db.enqueueFederationRelay({ - dispatchId, - direction: 'to_worker', - kind: 'control_message', - payload: encodeFederatedControlMessage({ - from, - subject: params.subject, - body: params.body ?? '', - type: (params.type ?? 'status') as MessageType, - priority: (params.priority ?? 'normal') as MessagePriority, - threadId: params.threadId ?? null, - payload: params.payload ?? null - }) - }) - runtime.ensureOrchestrationFederationRelay(messageRunId) - return withSendWarnings({ - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'worker', - accepted: true - } - }) - } - // Point-to-point — existing single-recipient behavior - revalidateLegacyCoordinator?.() - const dispatch = routing.dispatchId - ? db.getDispatchContextById(routing.dispatchId) - : undefined - const messageType = (params.type ?? 'status') as MessageType - const msg = db.insertMessage({ - from, - to, - subject: params.subject, - body: params.body, - type: messageType, - priority: params.priority as MessagePriority, - threadId: params.threadId, - payload: dispatch - ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) - : params.payload, - senderPaneKey, - runId: messageRunId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - messageRunId ?? legacyCoordinatorRunId, - to - ) - }) - const dispatchMutationMessage = isDispatchMutationMessageType(msg.type) - if (dispatchMutationMessage) { - const processIncarnation = - attestedCaller?.processIncarnation ?? - runtime.getTerminalProcessIncarnation(from) ?? - undefined - const taskId = parseMessageTaskId(params.payload) - const capabilityBacked = Boolean(dispatch?.capability_hash) - const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' - let authority: { - valid: boolean - code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' - reason: string - } - if (!dispatch) { - authority = { - valid: !coordinatorMutation, - code: 'sender_not_assignee', - reason: 'No active Dispatch belongs to this message sender.' - } - } else if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { - authority = { - valid: false, - code: 'task_dispatch_mismatch', - reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` - } - } else if (capabilityBacked) { - const capabilityAuthority = db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability: orchestrationCapability, - paneKey: senderPaneKey, - processIncarnation - }) - authority = { - valid: capabilityAuthority.valid, - code: 'dispatch_capability_invalid', - reason: capabilityAuthority.valid ? '' : capabilityAuthority.reason - } - } else if (dispatch.process_incarnation) { - authority = { - valid: db.isDispatchProcessCurrent({ - dispatchId: dispatch.id, - paneKey: senderPaneKey ?? null, - processIncarnation: processIncarnation ?? null - }), - code: 'sender_not_assignee', - reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` - } - } else { - authority = { - valid: - !coordinatorMutation || - db.isDispatchMessageSender({ - dispatchId: dispatch.id, - handle: from, - paneKey: senderPaneKey - }), - code: 'sender_not_assignee', - reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` - } - } - if (!authority.valid) { - const code = authority.code - const rejection = - db.convertLifecycleMessageToRejection(msg.id, code, authority.reason) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ - message: rejection, - lifecycle: { - action: 'rejected', - code, - reason: authority.reason - } - }) - } - } - // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. - if (msg.type === 'worker_done' || msg.type === 'heartbeat') { - const reconciled = reconcileLifecycleMessage(db, msg) - // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. - if (reconciled.action === 'suppressed') { - return withSendWarnings({ message: msg }) - } - if (reconciled.action === 'rejected') { - const rejection = db.getMessageById(msg.id) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ message: rejection, lifecycle: reconciled }) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings( - msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } - ) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings({ message: msg }) - } - - // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). - const { terminals } = await runtime.listTerminals(undefined, undefined, { - includeVisualLayouts: false - }) - const handles = resolveGroupAddress(to, from, terminals, (handle: string) => - runtime.getAgentStatusForHandle(handle) - ) - - if (handles.length === 0) { - throw new Error(`No recipients resolved for group address: ${to}`) - } - - const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() - const resolvedRecipients = handles.map((handle) => ({ - handle, - resolution: resolveBareOrchestrationRecipient({ - runtime, - db, - handle, - senderRunId: routing.run?.id, - explicitRunId: params.run, - legacyAdoptedMailboxOwner - }) - })) - const deliverableRecipients = resolvedRecipients.filter( - ( - recipient - ): recipient is typeof recipient & { - resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } - } => recipient.resolution.ok - ) - const senderRecipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: from, - senderRunId: routing.run?.id, - legacyAdoptedMailboxOwner - }) - const senderMailboxKey = senderRecipient.ok - ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` - : undefined - const seenMailboxes = new Set() - const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { - const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` - if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { - return false - } - seenMailboxes.add(mailboxKey) - return true - }) - if (uniqueRecipients.length === 0) { - throw new OrchestrationError( - 'terminal_not_found', - `No recipient of ${to} resolved to a live terminal or durable Run/Dispatch mailbox.` - ) - } - - revalidateLegacyCoordinator?.() - const threadId = params.threadId ?? `thread_${Date.now()}` - const messages = db.insertMessages( - uniqueRecipients.map(({ resolution }) => ({ - from, - to: resolution.to, - subject: params.subject, - body: params.body, - type: params.type as MessageType, - priority: params.priority as MessagePriority, - threadId, - payload: params.payload, - senderPaneKey, - runId: resolution.runId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - resolution.runId ?? legacyCoordinatorRunId, - resolution.to - ) - })) - ) - const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => - resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] - ) - const receipt = { - messages, - recipients: messages.length, - ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) - } - recordMutationReceipt?.(receipt) - for (const message of messages) { - runtime.notifyMessageArrived(message.to_handle, message.type) - } - return receipt - } - }), - - defineMethod({ - name: 'orchestration.check', - params: CheckParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - signal, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - recordMutationReceipt - } - ) => { - const db = runtime.getOrchestrationDb() - const handle = params.terminal ?? 'unknown' - const typeFilter = parseMessageTypes(params.types) - const routeDirectSnapshot = async ( - runId: string, - directHandle: string, - routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } - ): Promise => { - const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) - if (throughSequence !== undefined) { - await routeAllMailboxPages(() => routePage(throughSequence), signal) - } - } - - // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. - const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - if (params.run || boundRun) { - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const generation = run.consumer_generation - const address = `run:${run.id}` - runtime.ensureOrchestrationFederationRelay(run.id) - await routeDirectSnapshot(run.id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) - ) - const coordinatorHandle = run.coordinator_handle - if (coordinatorHandle && coordinatorHandle !== handle) { - await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) - ) - } - revalidateLegacyCoordinator?.() - const currentRun = resolveRunScope(runtime, { - runId: run.id, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (currentRun.consumer_generation !== generation) { - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while routing pending mail.' - ) - } - - const acknowledged = params.ack - ? db.acknowledgeRunDelivery({ - runId: run.id, - consumerGeneration: generation, - deliveryId: params.ack - }) - : undefined - if (acknowledged) { - recordMutationReceipt?.( - interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') - ) - } - if (params.all || (params.unread === false && !params.peek)) { - const history = db.getRunMailboxHistory(run.id, 100, typeFilter) - const messages = history - const result = { - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null - } - if (params.format || params.inject) { - return { - ...result, - formatted: messages.map(formatMessageBanner).join('\n\n'), - runId: run.id - } - } - return { ...result, runId: run.id } - } - - const peekResult = (messages: MessageRow[]) => ({ - runId: run.id, - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - }) - const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) - const readDelivery = (wakeTypes?: MessageType[]) => - db.getOrCreateRunDelivery({ - runId: run.id, - consumerGeneration: generation, - wakeTypes - }) - let peeked = params.peek ? readPeek() : [] - if (params.peek && peeked.length > 0) { - return peekResult(peeked) - } - let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) - if (current) { - return { - runId: run.id, - deliveryId: current.delivery.id, - messages: current.messages, - count: current.messages.length, - replayed: current.replayed, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format || params.inject - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - if (!params.wait) { - if (params.peek) { - return peekResult([]) - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false - } - } - - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal, - exclusive: true - }) - try { - revalidateLegacyCoordinator?.() - } catch (error) { - if (!acknowledged) { - throw error - } - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - const latestRun = db.getRun(run.id) - if (!latestRun || latestRun.consumer_generation !== generation) { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while waiting.' - ) - } - if (waitResult === 'waiter_exists') { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') - } - throw new OrchestrationError( - 'waiter_exists', - `Run ${run.id} already has an active actionable waiter.` - ) - } - if (waitResult === 'timed_out') { - if (params.peek) { - return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: true, - cancelled: false, - connectionLost: false - } - } - if (waitResult === 'cancelled') { - if (params.peek) { - return { - ...peekResult([]), - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - - if (params.peek) { - peeked = readPeek() - return { - ...peekResult(peeked), - timedOut: false, - cancelled: false, - connectionLost: false - } - } - current = readDelivery(typeFilter) - return { - runId: run.id, - deliveryId: current?.delivery.id ?? null, - messages: current?.messages ?? [], - count: current?.messages.length ?? 0, - replayed: current?.replayed ?? false, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format && current - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - const remoteAttachment = - !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if ( - remoteAttachment && - !db.isRemoteAttachmentProcessCurrent({ - dispatchId: remoteAttachment.dispatch_id, - paneKey: paneKey ?? null, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` - ) - } - const workerMailbox = activeDispatch - ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } - : remoteAttachment - ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } - : undefined - if (workerMailbox) { - const address = `dispatch:${workerMailbox.dispatchId}` - const revalidateWorkerMailbox = async (): Promise => { - if (activeDispatch) { - const current = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - if (current?.id === activeDispatch.id) { - return - } - } else if (remoteAttachment && paneKey) { - const current = db.findActiveRemoteAttachmentForPane(paneKey) - if ( - current?.dispatch_id === remoteAttachment.dispatch_id && - db.isRemoteAttachmentProcessCurrent({ - dispatchId: current.dispatch_id, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - return - } - } - const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) - const owningRunId = - latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId - if ( - owningRunId && - (!latestDispatch || - (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) - ) { - const throughSequence = db.getLatestUnreadMessageSequence(address) - if (throughSequence !== undefined) { - const routedTypes = new Set() - const routePage = (): { routedCount: number; hasMore: boolean } => { - const routed = db.routeUnreadDispatchMailboxToRunMailbox( - workerMailbox.dispatchId, - owningRunId, - throughSequence - ) - for (const routedType of routed.types) { - routedTypes.add(routedType) - } - return routed - } - const notifyRoutedTypes = (): void => { - for (const routedType of routedTypes) { - runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) - } - routedTypes.clear() - } - try { - await routeAllMailboxPages(routePage, signal) - } catch (error) { - notifyRoutedTypes() - if (error instanceof OrchestrationError && error.code === 'request_aborted') { - setImmediate(() => { - void routeAllMailboxPages(routePage) - .catch(() => undefined) - .finally(notifyRoutedTypes) - }) - } - throw error - } - notifyRoutedTypes() - } - } - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` - ) - } - if (activeDispatch) { - await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - handle, - throughSequence - ) - ) - const assigneeHandle = activeDispatch.assignee_handle - if (assigneeHandle && assigneeHandle !== handle) { - await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - assigneeHandle, - throughSequence - ) - ) - } - } - await revalidateWorkerMailbox() - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const messages = showAll - ? db.getAllMessagesForHandle(address, 100, typeFilter) - : db.getUnreadMessages(address, typeFilter) - if (!showAll && params.peek !== true && messages.length > 0) { - db.markAsRead(messages.map((message) => message.id)) - } - if (messages.length > 0 || !params.wait) { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages, - count: messages.length, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - await revalidateWorkerMailbox() - if (waitResult === 'timed_out' || waitResult === 'cancelled') { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: [], - count: 0, - timedOut: waitResult === 'timed_out', - cancelled: waitResult === 'cancelled', - connectionLost: waitResult === 'cancelled' && signal?.aborted === true - } - } - const arrived = db.getUnreadMessages(address, typeFilter) - db.markAsRead(arrived.map((message) => message.id)) - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: arrived, - count: arrived.length, - ...(params.format || params.inject - ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const consumeUnread = !showAll && params.peek !== true - - const readAndReturn = () => { - const messages = showAll - ? db.getAllMessagesForHandle(handle, undefined, typeFilter) - : db.getUnreadMessages(handle, typeFilter) - - if ( - consumeUnread && - messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', - { effectsApplied: false } - ) - } - - let visibleMessages = messages - if (consumeUnread && messages.length > 0) { - // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. - visibleMessages = messages.map((message) => { - const reconciled = reconcileLifecycleMessage(db, message) - return reconciled.action === 'rejected' - ? (db.getMessageById(message.id) ?? message) - : message - }) - db.markAsRead(messages.map((m) => m.id)) - } - - if (params.format || params.inject) { - const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') - return { messages: visibleMessages, formatted, count: visibleMessages.length } - } - - return { messages: visibleMessages, count: visibleMessages.length } - } - - if (signal?.aborted) { - return { messages: [], count: 0 } - } - const result = readAndReturn() - if (result.count > 0 || !params.wait) { - return result - } - - // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). - const waitResult = await runtime.waitForMessage(handle, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - if (signal?.aborted) { - return { messages: [], count: 0 } - } - if (waitResult === 'cancelled') { - throw new OrchestrationError( - 'consumer_fenced', - 'This direct mailbox became owned by a Run while the check was waiting.' - ) - } - return readAndReturn() - } - }), - - defineMethod({ - name: 'orchestration.reply', - params: ReplyParams, - handler: async ( - params, - { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } - ) => { - const db = runtime.getOrchestrationDb() - const original = db.getMessageById(params.id) - if (!original) { - throw new Error(`Message not found: ${params.id}`) - } - if ( - legacyCoordinatorRunId && - (original.run_id !== legacyCoordinatorRunId || - (params.run !== undefined && params.run !== legacyCoordinatorRunId)) - ) { - throw new OrchestrationError( - 'request_mismatch', - `Message ${params.id} does not belong to this adopted Run.`, - { effectsApplied: false } - ) - } - if ( - original.run_id === ORCHESTRATION_LEGACY_RUN_ID || - original.delivery_contract === 'legacy_direct' || - original.delivery_contract === 'audit_only' - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; no reply was applied.', - { effectsApplied: false } - ) - } - - const question = db.getQuestion(params.id) - if (question) { - const run = resolveRunScope(runtime, { - runId: params.run ?? question.run_id, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const answered = db.answerQuestion({ - messageId: question.message_id, - runId: run.id, - consumerGeneration: run.consumer_generation, - body: params.body - }) - const federated = db.getFederatedDispatch(question.dispatch_id) - if (federated) { - db.enqueueFederationRelay({ - dispatchId: question.dispatch_id, - direction: 'to_worker', - kind: 'reply', - payload: JSON.stringify({ - questionId: question.message_id, - answerMessageId: answered.message.id, - body: params.body - }) - }) - runtime.ensureOrchestrationFederationRelay(run.id) - } else { - runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') - } - return { - message: answered.message, - question: answered.question, - duplicate: answered.duplicate - } - } - - db.markAsRead([original.id]) - - const reply = db.insertMessage({ - from: params.from ?? original.to_handle, - to: original.from_handle, - subject: `Re: ${original.subject}`, - body: params.body, - threadId: original.thread_id ?? original.id, - runId: original.run_id - }) - - runtime.notifyMessageArrived(reply.to_handle, reply.type) - return { message: reply } - } - }), - - defineMethod({ - name: 'orchestration.inbox', - params: InboxParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). - const messages = params.terminal - ? db.getAllMessagesForHandle(params.terminal, params.limit) - : db.getInbox(params.limit) - return { messages, count: messages.length } - } - }), - - defineMethod({ - name: 'orchestration.taskCreate', - params: TaskCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const creatorAuthority = params.callerTerminalHandle - ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) - : null - const task = db.createTask({ - spec: params.spec, - taskTitle: params.taskTitle, - displayName: params.displayName, - deps, - parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle, - ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation - ? { - createdByPaneKey: creatorAuthority.paneKey, - createdByProcessIncarnation: creatorAuthority.processIncarnation, - createdByRunGeneration: run.consumer_generation - } - : {}), - runId: run.id - }) - return { task } - } - }), - - defineMethod({ - name: 'orchestration.taskList', - params: TaskListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const explicitRun = params.run ? db.getRun(params.run) : undefined - const run = - explicitRun?.legacy === 1 - ? explicitRun - : resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: params.run === undefined, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. - const joined = db.listTasksWithDispatch({ - status: params.status as TaskStatus, - ready: params.ready, - runId: run.id - }) - const tasks = joined.map((row) => { - const { assignee_handle, dispatch_id, ...base } = row - if (base.status === 'dispatched') { - return { ...base, assignee_handle, dispatch_id } - } - return base - }) - return { - runId: run.id, - legacyReadOnly: run.legacy === 1, - tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, - count: tasks.length - } - } - }), - - defineMethod({ - name: 'orchestration.taskUpdate', - params: TaskUpdateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const existing = db.getTask(params.id) - if (!existing || existing.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${params.id} was not found in Run ${run.id}.` - ) - } - const task = db.updateTaskStatus(params.id, params.status, params.result) - if (!task) { - throw new Error(`Task not found: ${params.id}`) - } - return { task } - } - }), - - defineMethod({ - name: 'orchestration.dispatch', - params: DispatchParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - legacyCoordinatorRunId, - revalidateLegacyCoordinator - } - ) => { - const db = runtime.getOrchestrationDb() - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (task.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${task.id} was not found in Run ${run.id}.` - ) - } - - // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. - if (params.dryRun) { - const maxDepth = runtime.getNestedWorkerMaxDepth() - const previewDepth = db.resolveChildDispatchDepth( - resolveDispatchCreator(runtime, params.from), - maxDepth - ) - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: 'ctx_dryrun', - canDispatchSubWorkers: previewDepth < maxDepth, - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: params.to ?? 'worker', - devMode: params.devMode, - ...(params.to - ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } - : {}) - }) - return { dispatch: null, injected: false, dryRun: true, preamble } - } - - if (!params.to) { - throw new Error('Missing --to') - } - const to = params.to - - if (task.status !== 'ready') { - throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) - } - - // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. - if (params.inject) { - const hasAgent = await runtime.isTerminalRunningAgent(to) - if (!hasAgent) { - throw new Error(buildInjectRejectionMessage(to)) - } - } - - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) - const assigneePaneKey = - dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined - const processIncarnation = - dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation - ? dispatchAuthority.processIncarnation - : undefined - if (params.inject && (!assigneePaneKey || !processIncarnation)) { - throw new OrchestrationError( - 'stable_pane_required', - `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` - ) - } - - revalidateLegacyCoordinator?.() - const ctx = db.createDispatchContext({ - taskId: params.task, - assigneeHandle: to, - assigneePaneKey, - launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, - processIncarnation, - creator: resolveDispatchCreator(runtime, params.from), - maxDepth: runtime.getNestedWorkerMaxDepth() - }) - const dispatchCapability = params.inject - ? db.mintDispatchCapability({ - dispatchId: ctx.id, - paneKey: assigneePaneKey as string, - processIncarnation: processIncarnation as string - }) - : undefined - - // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: ctx.id, - canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: to, - dispatchCapability, - devMode: params.devMode, - cliCommand: runtime.getTerminalOrchestrationCliCommand(to) - }) - - let injected = false - if (params.inject) { - try { - await runtime.sendTerminalAgentPrompt(to, preamble) - injected = true - } catch (err) { - db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) - throw err - } - } - - // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. - if (params.returnPreamble) { - return { dispatch: ctx, injected, preamble } - } - return { dispatch: ctx, injected } - } - }), - - defineMethod({ - name: 'orchestration.dispatchShow', - params: DispatchShowParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (!params.task) { - throw new Error('Missing --task') - } - const ctx = db.getDispatchContext(params.task) - - // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. - if (params.preamble) { - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const workerHandle = ctx?.assignee_handle ?? 'worker' - const preamble = buildDispatchPreamble({ - taskId: task.id, - // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. - dispatchId: ctx?.id ?? 'ctx_preview', - canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle, - devMode: params.devMode, - ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) - }) - return { dispatch: ctx ?? null, preamble } - } - - return { dispatch: ctx ?? null } - } - }), - - defineMethod({ - name: 'orchestration.ask', - params: AskParams, - handler: async ( - params, - { runtime, signal, orchestrationCapability, recordMutationReceipt } - ) => { - // Why: group addresses have no unambiguous first-answer authority. - if (params.to && isGroupAddress(params.to)) { - throw new Error( - 'ask does not support group addresses; use send for non-blocking fan-out questions' - ) - } - - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. - const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) - const paneKey = runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - return askRemoteRunHome({ - params: { ...params, timeoutMs }, - runtime, - signal, - orchestrationCapability, - recordMutationReceipt, - from, - paneKey: paneKey as string, - dispatchId: remoteAttachment.dispatch_id, - taskId: remoteAttachment.task_id - }) - } - const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) - if (!activeDispatch) { - throw new OrchestrationError( - 'dispatch_inactive', - 'ask requires an active supervised Dispatch.' - ) - } - if (activeDispatch.capability_hash) { - const authority = db.verifyDispatchCapability({ - dispatchId: activeDispatch.id, - capability: orchestrationCapability, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined - }) - if (!authority.valid) { - throw new OrchestrationError('dispatch_capability_invalid', authority.reason) - } - } - const options = - params.options - ?.split(',') - .map((s) => s.trim()) - .filter(Boolean) ?? [] - let question = params.resume ? db.getQuestion(params.resume) : undefined - if (params.resume) { - if (!question || question.dispatch_id !== activeDispatch.id) { - throw new OrchestrationError( - 'question_not_found', - `Question ${params.resume} does not belong to this active Dispatch.` - ) - } - } else { - const run = db.getRun(activeDispatch.run_id) - if (!run || run.legacy === 1) { - throw new OrchestrationError( - 'run_not_found', - `Run ${activeDispatch.run_id} was not found.` - ) - } - if (params.run && params.run !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` - ) - } - if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` - ) - } - const created = db.createQuestion({ - runId: run.id, - dispatchId: activeDispatch.id, - askerHandle: from, - question: params.question as string, - options - }) - question = created.question - runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) - } - - const questionId = question.message_id - recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const current = db.getQuestion(questionId) - if (!current || current.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its Dispatch is inactive.` - ) - } - if (current.status === 'answered') { - return { - answer: current.answer_body, - messageId: questionId, - answerMessageId: current.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { - timeoutMs: remainingMs, - signal - }) - } - } - }), - + ...ORCHESTRATION_SEND_METHODS, + ...ORCHESTRATION_CHECK_METHODS, + ...ORCHESTRATION_MESSAGE_METHODS, + ...ORCHESTRATION_DISPATCH_METHODS, + ...ORCHESTRATION_ASK_METHODS, ...ORCHESTRATION_GATE_METHODS, - - defineMethod({ - name: 'orchestration.reset', - params: ResetParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (params.all) { - runtime.stopOrchestrationFederationRelay() - db.resetAll() - return { reset: 'all' } - } - if (params.tasks) { - runtime.stopOrchestrationFederationRelay() - db.resetTasks() - return { reset: 'tasks' } - } - if (params.messages) { - db.resetMessages() - return { reset: 'messages' } - } - throw new Error('Invalid reset scope') - } - }) + ...ORCHESTRATION_RESET_METHODS ] - -async function askRemoteRunHome(args: { - params: z.infer - runtime: OrcaRuntimeService - signal?: AbortSignal - orchestrationCapability?: string - recordMutationReceipt?: (receipt: unknown) => void - from: string - paneKey: string - dispatchId: string - taskId: string -}): Promise { - const db = args.runtime.getOrchestrationDb() - const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: args.dispatchId, - capability: args.orchestrationCapability, - paneKey: args.paneKey, - processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const options = - args.params.options - ?.split(',') - .map((option) => option.trim()) - .filter(Boolean) ?? [] - let questionId = args.params.resume - if (questionId) { - const existing = db.getRemoteQuestion(questionId) - if (!existing || existing.dispatch_id !== args.dispatchId) { - throw new OrchestrationError( - 'question_not_found', - `Question ${questionId} does not belong to this remote Dispatch.` - ) - } - } else { - const relay = db.enqueueFederationRelay({ - dispatchId: args.dispatchId, - direction: 'to_home', - kind: 'question', - payload: JSON.stringify({ - from: args.from, - subject: 'Question', - body: args.params.question as string, - type: 'question', - priority: 'normal', - threadId: null, - payload: JSON.stringify({ - taskId: args.taskId, - dispatchId: args.dispatchId, - question: args.params.question, - options - }) - }), - remoteQuestion: true - }) - questionId = relay.message_id - } - args.recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const question = db.getRemoteQuestion(questionId) - if (!question || question.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its remote Dispatch is inactive.` - ) - } - if (question.status === 'answered') { - return { - answer: question.answer_body, - messageId: questionId, - answerMessageId: question.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (args.signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { - timeoutMs: remainingMs, - signal: args.signal - }) - } -} diff --git a/src/main/startup/branch-rename-hook.ts b/src/main/startup/branch-rename-hook.ts new file mode 100644 index 00000000000..ce33ab0b451 --- /dev/null +++ b/src/main/startup/branch-rename-hook.ts @@ -0,0 +1,128 @@ +import { existsSync } from 'node:fs' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename' +import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output' +import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename' +import { moveWorktree } from '../git/worktree' +import { mainProcessState as state } from './main-process-state' + +const ENABLE_FIRST_WORK_FOLDER_RENAME = false + +export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined +}): void { + const store = state.store + const runtime = state.runtime + if (!store || !runtime) { + return + } + void maybeAutoRenameBranchOnFirstWork( + { + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + state: event.payload.state, + prompt: event.payload.prompt, + assistantMessage: event.payload.lastAssistantMessage, + isReplay: event.isReplay + }, + { + getSettings: () => store.getSettings(), + getRepo: (repoId) => store.getRepo(repoId), + getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), + getCurrentDisplayName: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name + : store.getWorktreeMeta(worktreeId)?.displayName + }, + getFolderWorkspacePath: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath + : undefined + }, + isPendingFirstAgentMessageRename: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === + true + : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true + }, + canRenameOrcaCreatedBranch: (worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true + }, + setDisplayName: (worktreeId, displayName) => { + rememberBranchRenameFailureOutput(worktreeId, null) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + store.updateFolderWorkspace(scope.folderWorkspaceId, { + name: displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + runtime.notifyFolderWorkspaceChanged() + return + } + store.setWorktreeMeta(worktreeId, { + displayName, + // The first-agent title is an intentional user-facing label; keep it stable after the + // generated branch is renamed and across subsequent catalog refreshes. + displayNameIsPinned: true, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + }, + renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME + ? (worktreeId, newLeaf) => + renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { + getRepo: (repoId) => store.getRepo(repoId), + getSettings: () => store.getSettings(), + migrateWorktreeIdentity: (oldId, newId) => + store.migrateWorktreeIdentity(oldId, newId), + notifyWorktreeRenamed: (repoId, oldId, newId) => + runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), + pathExists: async (candidate) => existsSync(candidate), + moveWorktree + }) + : undefined, + setRenameError: (worktreeId, error, failureOutput) => { + rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const current = store.getFolderWorkspace( + scope.folderWorkspaceId + )?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.updateFolderWorkspace(scope.folderWorkspaceId, { + firstAgentMessageRenameError: error + }) + runtime.notifyFolderWorkspaceChanged() + return + } + const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) + }, + resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), + onRenamed: (repoIdOrWorktreeId) => { + if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { + runtime.notifyFolderWorkspaceChanged() + return + } + runtime.notifyBranchRenamed(repoIdOrWorktreeId) + } + } + ) +} diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts new file mode 100644 index 00000000000..3b94c00b11d --- /dev/null +++ b/src/main/startup/codex-launch-preparation.ts @@ -0,0 +1,95 @@ +import { app } from 'electron' +import type { CodexHomeLaunchContext } from '../ipc/pty' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { codexHookService } from '../codex/hook-service' +import { getDefaultWslDistro } from '../wsl' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexRuntimeHomeForLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: CodexHomeLaunchContext +): Promise { + const runtimeHome = state.codexRuntimeHome + if (!runtimeHome) { + throw new Error('Codex runtime home service is not initialized') + } + if ( + target?.runtime !== 'wsl' && + launchContext?.launchAgent === 'codex' && + launchContext.workspacePath + ) { + try { + // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. + await markCodexProjectTrusted(launchContext.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) + } + } + const ensureRealHomeHooksIfSelected = async (): Promise => { + if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { + return false + } + // Why (flag ON, system default): the hook entry must exist — appended last + // and trusted by codex's own app-server grant — in the real ~/.codex before + // the pane spawns. An incapable grant flips the lane gate so the launch + // below falls back to the managed home instead of a status-blind pane. + await ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(state.store?.getSettings()), + userDataPath: app.getPath('userData') + }) + return true + } + let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — + // the fallbacks below all key off `null`, which means "system default", so + // swallowing the refusal would launch the wrong account (#STA-4422). + let runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + if (runtimeHomePath === null && !realHomeHooksPrepared) { + // Why: launch prep can reject an untrusted managed home and clear its + // selection. Establish hook capability for that newly selected lane, then + // re-resolve if the capability gate rejects it. + realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + if (realHomeHooksPrepared) { + runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + } + } + if (runtimeHomePath === null && target?.runtime !== 'wsl') { + // Why: Codex runs on the user's real ~/.codex; the managed-home hook + // install below would target a home Codex never reads on this lane. + return null + } + const hookTarget = + target?.runtime === 'wsl' + ? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() } + : target + const hooksEnabled = isAgentStatusHooksEnabled(state.store?.getSettings()) + try { + // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. + const status = await codexHookService.prepareRuntimeHomeForLaunch( + runtimeHomePath, + hookTarget, + hooksEnabled + ) + if (status.state === 'error') { + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + status.detail + ) + } + } catch (error) { + // Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting. + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + error + ) + } + return runtimeHomePath +} diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts new file mode 100644 index 00000000000..65a2c6f6781 --- /dev/null +++ b/src/main/startup/codex-session-resume-launch.ts @@ -0,0 +1,98 @@ +import { app } from 'electron' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' +import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' +import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { codexHookService } from '../codex/hook-service' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexSessionResumeForLaunch(args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}): Promise { + const runtimeHome = state.codexRuntimeHome + const store = state.store + if (args.target.runtime === 'wsl' || !runtimeHome || !store) { + return null + } + const systemHomePath = getSystemCodexHomePath() + const trustedHomes = [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()] + const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + const preparation = await prepareCodexSessionResume({ + sessionId: args.providerSession.id, + transcriptPath: args.providerSession.transcriptPath, + trustedCodexHomes: trustedHomes, + getSelectedAccountCodexHome: () => selectedAccountCodexHome, + systemCodexHomePath: systemHomePath, + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), + resolveVerifiedResumeHome: async (sessionSource) => { + let migrated = { useRealCodexHome: false } + try { + migrated = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + executionHostId: 'local', + filePath: sessionSource.transcriptPath, + codexHome: sessionSource.homePath + }, + { + isHostSystemDefaultRealHome: () => runtimeHome.isHostSystemDefaultRealHome(), + systemCodexHomePath: systemHomePath + } + ) + } catch (error) { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + throw error + } + console.warn( + '[codex-session-resume] Legacy rollout migration failed; using origin home:', + error + ) + } + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + if (args.workspacePath) { + try { + await markCodexProjectTrusted(args.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + } + } + const isSystemHome = + normalizeRuntimePathForComparison(resumeHome) === + normalizeRuntimePathForComparison(systemHomePath) + const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + try { + if (isSystemHome) { + await ensureRealHomeCodexHookState({ + hooksEnabled, + userDataPath: app.getPath('userData') + }) + } else if (hooksEnabled) { + await codexHookService.installForLaunchPrep(resumeHome) + } else { + await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome) + } + } catch (error) { + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + return resumeHome + } + }) + return preparation.outcome === 'resume' + ? { + ...preparation, + reconcileSharedRuntimeAuth: + normalizeRuntimePathForComparison(preparation.codexHomePath) === + normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) + } + : preparation +} diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 232626ca9f5..4747f797cf9 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -775,9 +775,9 @@ describe('safe graphics mode startup switches', () => { // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. - it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {') + it('calls the throttling opt-out outside the GPU-fallback gate in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const gateStart = mainSource.indexOf('if (!state.gpuFallbackActiveThisLaunch) {') expect(gateStart).toBeGreaterThanOrEqual(0) const gateEnd = mainSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) @@ -789,14 +789,20 @@ describe('safe graphics mode startup switches', () => { // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready // top-level block and never move into the whenReady callback, where appendSwitch is a silent // no-op — the same invisible failure as parking it behind the GPU gate. - it('appends the throttling opt-out before app ready in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const readyStart = mainSource.indexOf('void app.whenReady()') + it('appends the throttling opt-out before app ready in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const entrySource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') + const preflightEnd = mainSource.indexOf('\n return true') + const readyStart = entrySource.indexOf('void app.whenReady()') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightEnd).toBeGreaterThan(0) expect(readyStart).toBeGreaterThan(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeLessThan(readyStart) const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') expect(callIndex).toBeGreaterThan(0) - expect(callIndex).toBeLessThan(readyStart) + expect(callIndex).toBeLessThan(preflightEnd) }) // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 58339246287..e432ed383d2 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -4,17 +4,35 @@ import { describe, expect, it } from 'vitest' describe('startup ordering', () => { it('passes the startup barrier into PTY handlers without blocking window creation', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachStart = source.indexOf('attachMainWindowServices(') - const attachEnd = source.indexOf('rateLimits.attach(window)', attachStart) - const attachBlock = source.slice(attachStart, attachEnd) + const attachSource = readFileSync( + join(process.cwd(), 'src/main/window/attach-main-window-services.ts'), + 'utf8' + ) + const startupSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const runtimeLaunchSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const attachStart = attachSource.indexOf('export function attachMainWindowServices(') + const attachEnd = attachSource.indexOf(' registerSshHandlers(', attachStart) + const attachBlock = attachSource.slice(attachStart, attachEnd) // Why: anchor on the destructure head only — the settled-result variable's name is not the // contract, and pinning it turns a rename into a cryptic `expected -1` failure here. - const desktopStart = source.indexOf('const [win') + const desktopStart = runtimeLaunchSource.indexOf('async function launchDesktopMode(') // Why: anchor on code, not a comment — the previous comment anchor was silently reworded, so // this was -1 and sliced to EOF, letting the assertions below pass against never-run code. - const desktopEnd = source.indexOf("win.once('show'", desktopStart) - const desktopStartup = source.slice(desktopStart, desktopEnd) + const desktopEnd = runtimeLaunchSource.indexOf( + '\nexport async function initializeMainProcessRuntimeLaunch', + desktopStart + ) + const desktopStartup = runtimeLaunchSource.slice(desktopStart, desktopEnd) // Why: bound every anchor, not just the desktop pair — an unresolved one slices to EOF. expect(attachStart).toBeGreaterThanOrEqual(0) @@ -22,14 +40,20 @@ describe('startup ordering', () => { expect(desktopStart).toBeGreaterThanOrEqual(0) expect(desktopEnd).toBeGreaterThan(desktopStart) - expect(attachBlock).toContain('awaitLocalPtyStartup: () => localPtyStartupReady') - expect(attachBlock).toContain( - 'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady' + expect(coreSource).toContain('awaitLocalPtyStartup: () => state.localPtyStartupReady') + expect(coreSource).toContain( + 'awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady' ) - expect(source).toContain( + expect(attachBlock).toContain('awaitLocalPtyStartup: options?.awaitLocalPtyStartup') + expect(attachBlock).toContain( + 'awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup' + ) + expect(startupSource).toContain( 'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)' ) - expect(source).toContain('localPtyStartupReady = services.then((value) => value.localPtyReady)') + expect(startupSource).toContain( + 'localPtyStartupReady = services.then((value) => value.localPtyReady)' + ) const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())') const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') @@ -50,25 +74,43 @@ describe('startup ordering', () => { }) it('resolves the browser hosting identity with nothing awaited before it', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyIndex = source.indexOf('app.whenReady().then(') - const initIndex = source.indexOf('initializeBrowserClientHostId(') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const initReadyIndex = entrySource.indexOf('initializeMainProcessReady({') + const profileIndex = foundationSource.indexOf('const profile = ensureActiveOrcaProfile()') + const initIndex = foundationSource.indexOf( + 'initializeBrowserClientHostId(profile.profileDirectory)' + ) expect(readyIndex).toBeGreaterThanOrEqual(0) - expect(initIndex).toBeGreaterThan(readyIndex) + expect(initReadyIndex).toBeGreaterThan(readyIndex) + expect(profileIndex).toBeGreaterThanOrEqual(0) + expect(initIndex).toBeGreaterThan(profileIndex) // Why nothing may be awaited first: the identity is stamped into the renderer's argv when the // window is created, and a suspension here lets a window be created against a process-local // stand-in that the durable id then contradicts. The constraint is positional, so only a source // census can hold it — no behavioural test distinguishes "resolved" from "resolved in time". - expect(source.slice(readyIndex, initIndex)).not.toMatch(/\bawait\b/) + expect(foundationSource.slice(profileIndex, initIndex)).not.toMatch(/\bawait\b/) // Why the count: a second call site would leave the ordering claim above ambiguous. - expect(source.split('initializeBrowserClientHostId(')).toHaveLength(2) + expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) }) it('requires daemon authority before restored-subagent liveness runs', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const sweepStart = source.indexOf('function reapRestoredSubagentsWithoutLiveAgent()') - const sweepEnd = source.indexOf('function startTerminalRuntimeStartupServices()', sweepStart) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const sweepStart = source.indexOf( + 'export async function reapRestoredSubagentsWithoutLiveAgent()' + ) + const sweepEnd = source.indexOf( + 'export function startTerminalRuntimeStartupServices()', + sweepStart + ) const sweep = source.slice(sweepStart, sweepEnd) expect(sweepStart).toBeGreaterThanOrEqual(0) @@ -79,47 +121,53 @@ describe('startup ordering', () => { }) it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") - const barrierEnd = source.indexOf("'app:startupDiagnostic'", barrierStart) - const barrier = source.slice(barrierStart, barrierEnd) - const reconciliationStart = source.indexOf( - 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + const barrierSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), + 'utf8' ) - const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) - const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveEnd = source.indexOf('return', serveReady) - const desktopWindowStart = source.indexOf( - 'const desktopStartup = startWindowsDesktopBeforeShellPathReady(' + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' ) - const desktopWindowJoin = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveEnd + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' ) - const serveStartup = source.slice(serveStart, serveEnd) - const desktopStartup = source.slice(reconciliationStart, serveStart) + const barrierStart = barrierSource.indexOf( + "ipcMain.handle('app:awaitFirstWindowStartupServices'" + ) + const barrierEnd = barrierSource.indexOf("'app:startupDiagnostic'", barrierStart) + const barrier = barrierSource.slice(barrierStart, barrierEnd) + const reconciliationStart = foundationSource.indexOf( + 'state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + ) + const serveStart = runtimeSource.indexOf('async function launchServeMode(') + const serveEnd = runtimeSource.indexOf('\nasync function launchDesktopMode', serveStart) + const serveStartup = runtimeSource.slice(serveStart, serveEnd) + const desktopStart = runtimeSource.indexOf( + " if (process.platform === 'win32' && app.isPackaged && !serveOptions)" + ) + const desktopEnd = runtimeSource.indexOf(" app.on('activate'", desktopStart) + const desktopStartup = runtimeSource.slice(desktopStart, desktopEnd) expect(barrierStart).toBeGreaterThanOrEqual(0) expect(barrierEnd).toBeGreaterThan(barrierStart) expect(reconciliationStart).toBeGreaterThanOrEqual(0) - expect(serveStart).toBeGreaterThan(reconciliationStart) + expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveEnd).toBeGreaterThan(serveStart) - // Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call - // would steal this anchor, collapse desktopStartup to '', and pass the negative check below. - expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) - expect(desktopWindowStart).toBeLessThan(serveStart) - expect(desktopWindowJoin).toBeGreaterThan(serveEnd) - expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') - expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') - expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + expect(serveStartup).toContain('await state.managedWslCliStartupBarrierReady') + expect(serveStartup).not.toContain('await state.managedWslCliReconciliationReady') + expect(serveStartup.indexOf('await state.managedWslCliStartupBarrierReady')).toBeLessThan( serveStartup.indexOf('await runtimeRpc.start()') ) - expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).not.toContain('await state.managedWslCliReconciliationReady') expect(desktopStartup).toContain( "process.platform === 'win32' && app.isPackaged && !serveOptions" ) expect(desktopStartup).toContain( - 'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })' + 'openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true })' ) expect(desktopStartup).toContain('bindServices: bindTerminalRuntimeStartupServices') expect(desktopStartup).toContain('shellPathReady,') @@ -129,31 +177,32 @@ describe('startup ordering', () => { expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'") expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({') expect(barrier).toContain('localPtyProviderStartupReady,') - expect(barrier).toContain('await runtime?.refreshRestoredOrchestrationAuthority()') + expect(barrier).toContain('await state.runtime?.refreshRestoredOrchestrationAuthority()') expect(barrier).toContain( - 'return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' + 'return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' ) }) it('reconciles retained Codex homes after authoritative daemon inventory', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') - const retainedPaneGateIndex = source.indexOf( + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const startupStart = source.indexOf('export function startTerminalRuntimeStartupServices()') + expect(startupStart).toBeGreaterThanOrEqual(0) + const startup = source.slice(startupStart) + const daemonInitIndex = startup.indexOf('await initDaemonPtyProvider(signal') + const retainedPaneGateIndex = startup.indexOf( 'hasRecordedManagedHostCodexPane()', daemonInitIndex ) - const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) - const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' - const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) - const hookReconciliationIndex = source.indexOf( + const inventoryIndex = startup.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) + const reconciliation = 'state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' + const reconciliationIndex = startup.indexOf(reconciliation, inventoryIndex) + const hookReconciliationIndex = startup.indexOf( 'reconcileRetainedCodexHookHomes({', inventoryIndex ) - const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) - const desktopIndex = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveIndex - ) expect(daemonInitIndex).toBeGreaterThanOrEqual(0) expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) @@ -162,35 +211,51 @@ describe('startup ordering', () => { expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) - expect(serveIndex).toBeGreaterThan(reconciliationIndex) - expect(desktopIndex).toBeGreaterThan(serveIndex) - expect(source.split(reconciliation)).toHaveLength(2) + // The call is intentionally kept after the authoritative inventory; anchoring on the state + // receiver avoids matching any prose that mentions the same operation. + expect(startup).toContain(reconciliation) }) it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const serveSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) // Why: the barrier fails open, so the serve-ready payload must carry the // reconciliation state and the bounded wait must be traceable via a milestone. - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') - const readyEnd = source.indexOf('pairing: pairing.available', readyStart) - const readyPayload = source.slice(readyStart, readyEnd) + const readyStart = serveSource.indexOf('await state.serveReadinessPublisher.publish(') + const readyEnd = serveSource.indexOf('pairing: pairing.available', readyStart) + const readyPayload = serveSource.slice(readyStart, readyEnd) // Why: unbounded, a renamed pairing key slices to EOF and the status only has to survive // somewhere later in the file — not in the serve-ready payload this test is about. expect(readyStart).toBeGreaterThanOrEqual(0) expect(readyEnd).toBeGreaterThan(readyStart) - expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus') + expect(readyPayload).toContain( + 'managedWslCliReconciliation: state.managedWslCliReconciliationStatus' + ) - expect(source).toContain("managedWslCliReconciliationStatus = 'pending'") - expect(source).toContain("managedWslCliReconciliationStatus = 'settled'") - expect(source).toContain("managedWslCliReconciliationStatus = 'failed'") - expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'pending'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'settled'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'failed'") + expect(runtimeSource).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") }) it('notifies the serve supervisor only after publishing readiness', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const readyStart = source.indexOf('await state.serveReadinessPublisher.publish(') const supervisorReady = source.indexOf('notifyServeSupervisorReady(', readyStart) expect(readyStart).toBeGreaterThanOrEqual(0) @@ -198,7 +263,10 @@ describe('startup ordering', () => { }) it('does not run the rate-limit quota fetch before the first window can show results', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) const attachIndex = source.indexOf('rateLimits.attach(window)') const startIndex = source.indexOf('rateLimits.start({ fetchImmediately: false })') @@ -207,60 +275,67 @@ describe('startup ordering', () => { }) it('wires bounded teardown state to reporting but not recovery or close behavior', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const scopeStart = source.indexOf('function getExpectedTeardownScope(') - const scopeEnd = source.indexOf('function markRecoveryReloadInFlight(', scopeStart) - const scope = source.slice(scopeStart, scopeEnd) - const windowStart = source.indexOf('const window = createMainWindow(store, {') - const windowEnd = source.indexOf('onRendererRecoveryExhausted:', windowStart) - const windowOptions = source.slice(windowStart, windowEnd) - const recorderStart = source.indexOf('function recordProcessGoneCrash(') - const recorderEnd = source.indexOf('function shutdownWatchersOnce(', recorderStart) - const recorder = source.slice(recorderStart, recorderEnd) + const lifecycleSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-lifecycle-flags.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) - expect(scopeStart).toBeGreaterThanOrEqual(0) - expect(scopeEnd).toBeGreaterThan(scopeStart) - expect(scope).toContain('resolveExpectedTeardownScope({') - expect(scope).toContain('includeSystemSessionEnd') - expect(windowStart).toBeGreaterThanOrEqual(0) - expect(windowEnd).toBeGreaterThan(windowStart) - expect(windowOptions).toContain('getIsQuitting: () => isQuitting') - expect(windowOptions).toContain( + expect(lifecycleSource).toContain('export function getExpectedTeardownScope(') + expect(lifecycleSource).toContain('resolveExpectedTeardownScope({') + expect(lifecycleSource).toContain('includeSystemSessionEnd') + expect(windowSource).toContain('const window = createMainWindow(store, {') + expect(windowSource).toContain('getIsQuitting: () => state.isQuitting') + expect(windowSource).toContain( 'expectedTeardown: getExpectedTeardownScope(webContentsId, false)' ) - expect(recorderStart).toBeGreaterThanOrEqual(0) - expect(recorderEnd).toBeGreaterThan(recorderStart) - expect(recorder).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') + expect(lifecycleSource).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') }) it('attaches renderer services before starting the TCC prompt watcher', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachIndex = source.indexOf('attachMainWindowServices(') - const tccNoticeIndex = source.indexOf('initTccPromptNotice(window', attachIndex) - const quitAbortStart = source.indexOf('onQuitAborted:') - const quitAbortEnd = source.indexOf('onRendererProcessGone:', quitAbortStart) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) + const quitSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) + const attachIndex = coreSource.indexOf('attachMainWindowServices(') + const tccNoticeIndex = coreSource.indexOf('initTccPromptNotice(window', attachIndex) + const quitAbortStart = windowSource.indexOf('onQuitAborted:') + const quitAbortEnd = windowSource.indexOf('onRendererProcessGone:', quitAbortStart) expect(attachIndex).toBeGreaterThanOrEqual(0) expect(tccNoticeIndex).toBeGreaterThan(attachIndex) - expect(source.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( + expect(coreSource.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( 'deferWatchUntilReadyToShow: true' ) - expect(source.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') - expect(source).toContain("process.once('exit', stopTccPromptNotice)") - const willQuitStart = source.indexOf("app.on('will-quit'") - const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) - expect(source.slice(willQuitStart, windowAllClosedStart)).toContain('stopTccPromptNotice()') - expect(source.slice(0, willQuitStart)).not.toContain('stopTccPromptNoticeForQuit') + expect(windowSource.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') + expect(quitSource).toContain("process.once('exit', stopTccPromptNotice)") + const willQuitStart = quitSource.indexOf("app.on('will-quit'") + expect(quitSource.slice(willQuitStart)).toContain('stopTccPromptNotice()') + expect(quitSource).not.toContain('stopTccPromptNoticeForQuit') }) it('keeps the power bridge through vetoable before-quit and disposes after commit', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const beforeQuitStart = source.indexOf("app.on('before-quit'") const willQuitStart = source.indexOf("app.on('will-quit'", beforeQuitStart) const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const beforeQuit = source.slice(beforeQuitStart, willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) - const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(e)') + const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(event)') const disposeIndex = willQuit.indexOf('unsubscribeSystemResumeBroadcast?.()') expect(beforeQuitStart).toBeGreaterThanOrEqual(0) @@ -272,7 +347,10 @@ describe('startup ordering', () => { }) it('joins structured agent sessions to the committed quit barrier', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, willQuitEnd) @@ -286,7 +364,10 @@ describe('startup ordering', () => { }) it('joins agent-browser cleanup before the committed quit exits', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) @@ -309,8 +390,11 @@ describe('startup ordering', () => { }) it('registers repeatable serve signal handling before headless startup completes', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const signalHandlers = source.indexOf('registerServeSignalHandlers(process', serveStart) const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) @@ -320,22 +404,34 @@ describe('startup ordering', () => { }) it('starts the automation scheduler before headless serve reports ready', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveReturn = source.indexOf('return', serveReady) + const serveEnd = source.indexOf('\nasync function launchDesktopMode', serveStart) const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart) - const automationStart = source.indexOf('automations.start()', serveStart) - const desktopSetWebContents = source.indexOf('automations.setWebContents(window.webContents)') - const desktopAutomationStart = source.indexOf('automations.start()', desktopSetWebContents + 1) + const automationStart = source.indexOf('state.automations?.start()', serveStart) + const desktopSetWebContents = windowSource.indexOf( + 'automations.setWebContents(window.webContents)' + ) + const desktopAutomationStart = windowSource.indexOf( + 'automations.start()', + desktopSetWebContents + 1 + ) expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveReady).toBeGreaterThan(serveStart) - expect(serveReturn).toBeGreaterThan(serveReady) + expect(serveEnd).toBeGreaterThan(serveReady) expect(runtimeRpcStart).toBeGreaterThan(serveStart) expect(automationStart).toBeGreaterThan(runtimeRpcStart) expect(automationStart).toBeLessThan(serveReady) - expect(automationStart).toBeLessThan(serveReturn) + expect(automationStart).toBeLessThan(serveEnd) expect(desktopSetWebContents).toBeGreaterThanOrEqual(0) expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents) }) @@ -345,28 +441,35 @@ describe('startup ordering', () => { // scope that accessor throws by design, so every `orca serve` process on macOS died at startup // before it could listen. serve-update-handoff.test.ts mocks the resolver, so only ordering // catches this; serve-update-handoff.app-environment.test.ts pins the throw it depends on. - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const install = 'installServeSupervisorDisconnectQuit(isServeMode)' + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const install = 'installServeSupervisorDisconnectQuit(state.isServeMode)' const appEnvironmentIndex = source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') const dataPathIndex = source.indexOf('initDataPath()') const installIndex = source.indexOf(install) + // Why this anchor: preflight returns early when the lock is lost, so this gate is the split's + // equivalent of the old `if (hasSingleInstanceLock)` block head. + const lockGateIndex = source.indexOf('if (!hasLock) {') expect(source.split(install).length - 1, `${install} should appear exactly once`).toBe(1) expect(appEnvironmentIndex).toBeGreaterThanOrEqual(0) expect(dataPathIndex).toBeGreaterThan(appEnvironmentIndex) expect(installIndex).toBeGreaterThan(dataPathIndex) + expect(lockGateIndex).toBeGreaterThanOrEqual(0) + expect(installIndex).toBeGreaterThan(lockGateIndex) - // Why also pin it synchronous: 'disconnect' cannot be delivered while this module is still - // evaluating, which is the whole reason deferring it is free. Parked behind an await — say + // Why also pin it synchronous: 'disconnect' cannot be delivered while preflight is still + // running, which is the whole reason deferring it is free. Parked behind an await — say // inside app.whenReady() — the ordering above still holds but a parent that dies in the gap // leaves the serve process orphaned on its port, which is the failure this handler prevents. - expect(installIndex).toBeLessThan(source.indexOf('void app.whenReady().then(')) - expect(installIndex).toBeGreaterThan(source.indexOf('if (hasSingleInstanceLock) {')) - // Why only statements at block indentation: the span now covers unrelated helper functions, - // and an `await` inside one of those bodies is not what this guards against — the risk is this - // call itself being parked behind one. + expect(source).toContain('export function runMainProcessPreflight(') + // Why only statements at block indentation: the span covers unrelated helper bodies, and an + // `await` inside one of those is not what this guards against — the risk is this call itself + // being parked behind one. const blockStatements = source - .slice(source.indexOf('if (hasSingleInstanceLock) {'), installIndex) + .slice(lockGateIndex, installIndex) .split('\n') .filter((line) => /^ {2}\S/.test(line) && !line.trim().startsWith('//')) .join('\n') diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts new file mode 100644 index 00000000000..45f825ba141 --- /dev/null +++ b/src/main/startup/gpu-lifecycle.ts @@ -0,0 +1,177 @@ +import { app, type BrowserWindow } from 'electron' +import { relaunchApp } from '../app-relaunch' +import { destroySystemTray } from '../tray/system-tray' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' +import { + clearGpuFallbackMarker, + readActiveGpuFallbackMarker, + writeGpuFallbackMarker, + type WindowsGpuFallbackEnvironment +} from './gpu-fallback-marker' +import { + handleGpuFallbackRecoveredLaunch, + promptForGpuFallbackRecoveredLaunch +} from '../crash-reporting/gpu-fallback-recovered-launch' +import { promptForGpuFallbackRestart } from '../crash-reporting/gpu-fallback-restart-prompt' +import { engageGpuFallbackAfterCrashBurst } from '../crash-reporting/gpu-fallback-engagement' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { mainProcessState as state, gpuFallbackEnvironment } from './main-process-state' +import { createGpuAccelerationAboutPanelOptions } from '../menu/gpu-acceleration-about-panel' + +export function updateGpuAccelerationAboutPanel(): void { + app.setAboutPanelOptions( + createGpuAccelerationAboutPanelOptions({ + appName: app.name, + appVersion: app.getVersion(), + platform: process.platform, + gpuFallbackActive: state.gpuFallbackActiveThisLaunch, + gpuFeatureStatus: state.gpuFeatureStatus + }) + ) +} + +function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { + const environment = gpuFallbackEnvironment() + return environment.platform === 'win32' ? { ...environment, platform: 'win32' } : null +} + +function persistGpuFallbackMarker( + userDataPath: string, + info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } +): boolean { + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return false + } + try { + writeGpuFallbackMarker(userDataPath, info, environment) + return true + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return false + } +} + +/** Apply persisted software-rendering switches before Electron consumes its command line. */ +export function maybeApplyGpuFallbackForThisLaunch(): void { + if (state.isServeMode || process.platform !== 'win32') { + return + } + const marker = readActiveGpuFallbackMarker(app.getPath('userData'), gpuFallbackEnvironment()) + if (!marker) { + return + } + state.activeGpuFallbackMarker = marker + app.disableHardwareAcceleration() + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + state.gpuFallbackActiveThisLaunch = true + recordCrashBreadcrumb('gpu_fallback_applied', { + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') + }) +} + +export async function presentGpuFallbackRecoveredLaunchPrompt( + window: BrowserWindow +): Promise { + const marker = state.activeGpuFallbackMarker + if (!marker || marker.userConfirmed || window.isDestroyed() || state.isQuitting) { + return + } + state.activeGpuFallbackMarker = null + const userDataPath = app.getPath('userData') + await handleGpuFallbackRecoveredLaunch({ + isQuitting: () => state.isQuitting, + prompt: () => promptForGpuFallbackRecoveredLaunch(window), + confirmSafeGraphics: () => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: marker.engagedAt, + crashesInWindow: marker.crashesInWindow, + userConfirmed: true + }) + }, + clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), + onSafeGraphicsKept: () => + recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { + crashesInWindow: marker.crashesInWindow + }), + restartWithHardware: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', { + mode: 'hardware-retry', + crashesInWindow: marker.crashesInWindow + }) + destroySystemTray() + app.exit(0) + } + }) +} + +export async function handleGpuChildCrash( + reason: string, + exitCode: number | null, + crashedAt: number +): Promise { + if (state.gpuFallbackActiveThisLaunch || state.isQuitting || state.isServeMode) { + return + } + const result = state.gpuCrashFallbackTracker.recordGpuCrash(crashedAt) + if (!result.shouldEngageFallback) { + return + } + const fallbackData = { processReason: reason, exitCode, crashesInWindow: result.crashesInWindow } + const userDataPath = app.getPath('userData') + await engageGpuFallbackAfterCrashBurst( + { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, + { + isQuitting: () => state.isQuitting, + onEngaged: (engagement) => + recordCrashBreadcrumb('gpu_fallback_engaged', { + reason: engagement.reason, + exitCode: engagement.exitCode, + crashesInWindow: engagement.crashesInWindow + }), + persistMarker: (engagement) => + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: false + }), + confirmMarker: (engagement) => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: true + }) + }, + clearMarker: () => clearGpuFallbackMarker(userDataPath), + promptForRestart: () => + promptForGpuFallbackRestart( + state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + ), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show restart prompt:', error), + onRestartDeferred: () => + recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), + restartIntoSafeGraphics: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', fallbackData) + destroySystemTray() + app.exit(0) + } + } + ) +} + +export function registerGpuLifecycleHandlers(): void { + app.on('gpu-info-update', () => { + state.gpuFeatureStatus = app.getGPUFeatureStatus() + state.gpuCrashDiagnostics?.warm() + if (app.isReady()) { + updateGpuAccelerationAboutPanel() + } + }) +} diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index 2263cdbf94f..e866a5d1926 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -21,9 +21,12 @@ describe('headless PTY registry hydration ordering', () => { }) it('hydrates Electron serve after provider and handler readiness but before RPC', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serve = source.indexOf('if (serveOptions) {') - const provider = source.indexOf('await localPtyProviderStartupReady', serve) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serve = source.indexOf('async function launchServeMode(') + const provider = source.indexOf('await state.localPtyProviderStartupReady', serve) const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', provider) const rpc = source.indexOf('await runtimeRpc.start()', handlersAndHydration) const readiness = source.indexOf('await printServeReady(serveOptions)', rpc) diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts index 5e6fbfdf367..6d46d52679d 100644 --- a/src/main/startup/host-port-bootstrap-wiring.test.ts +++ b/src/main/startup/host-port-bootstrap-wiring.test.ts @@ -17,7 +17,11 @@ import { describe, expect, it } from 'vitest' * startup, so there is no seam to assert against at runtime. */ describe('host port bootstrap wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const INSTALLS = [ 'setAppEnvironment(new ElectronAppEnvironment())', @@ -37,18 +41,23 @@ describe('host port bootstrap wiring', () => { } }) - it('installs every port before the runtime, the PTY handlers, or any window exists', () => { - // Why these three: they are the first things that resolve a path, seal a credential, - // or register against an injected surface. - const firstUse = Math.min( - ...['new OrcaRuntimeService(', 'registerHeadlessPtyRuntime(', 'function openMainWindow('] - .map((marker) => source.indexOf(marker)) - .filter((index) => index >= 0) - ) - expect(firstUse).toBeGreaterThan(0) - + it('installs every port during preflight before it hands off to ready services', () => { + // Why: the ready phase creates the runtime, PTY handlers, and windows. Keeping all host-port + // installs in the preflight phase preserves process-level defaults for both desktop and serve. + const preflightStart = source.indexOf('export function runMainProcessPreflight(') + const preflightReturn = source.indexOf('\n return true', preflightStart) + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightStart).toBeGreaterThanOrEqual(0) + expect(preflightReturn).toBeGreaterThan(preflightStart) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect(source.indexOf(install), `${install} must run before first use`).toBeLessThan(firstUse) + const installIndex = source.indexOf(install) + expect(installIndex, `${install} should run in preflight`).toBeGreaterThan(preflightStart) + expect(installIndex, `${install} should run before preflight completes`).toBeLessThan( + preflightReturn + ) } }) @@ -58,7 +67,7 @@ describe('host port bootstrap wiring', () => { // port is a window where an early path resolve either kills the process — which is what took // down every macOS `orca serve` — or caches the pre-override directory for the whole session. // Keeping the four statements adjacent is what makes that window zero rather than merely small. - const decide = source.indexOf('configureDevUserDataPath(is.dev)') + const decide = source.indexOf('configureDevUserDataPath(isDev)') const install = source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') const capture = source.indexOf('initDataPath()') @@ -73,7 +82,7 @@ describe('host port bootstrap wiring', () => { .filter((line) => line.length > 0 && !line.startsWith('//')) expect(statements).toEqual([ - 'configureDevUserDataPath(is.dev)', + 'configureDevUserDataPath(isDev)', 'configureOrcaUserDataPathEnv()', 'setAppEnvironment(new ElectronAppEnvironment())' ]) @@ -82,12 +91,12 @@ describe('host port bootstrap wiring', () => { it('installs the ports at process level, not per window', () => { // Why: installing per window registered the PTY surfaces against no-ops on the // serve path, where no window ever opens. Caught in CI by the SSH docker E2E. - const openWindow = source.indexOf('function openMainWindow(') + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect( - source.indexOf(install, openWindow), - `${install} must not be re-installed per window` - ).toBe(-1) + expect(source.split(install).length - 1, `${install} should be owned by preflight`).toBe(1) } }) }) diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts new file mode 100644 index 00000000000..4a47d2c2ec2 --- /dev/null +++ b/src/main/startup/main-process-account-services.ts @@ -0,0 +1,151 @@ +import { app } from 'electron' +import { RateLimitService } from '../rate-limits/service' +import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import { CodexAccountService } from '../codex-accounts/service' +import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import { ClaudeAccountService } from '../claude-accounts/service' +import { KeybindingService } from '../keybindings/keybinding-service' +import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill' +import { startCodexSessionIndexHealInBackground } from '../codex/codex-session-index-heal' +import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' +import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target' +import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target' +import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' +import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' +import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' +import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' +import { isRealHomeCodexHookLaneUsable } from '../codex/codex-real-home-hook-install' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { browserManager } from '../browser/browser-manager' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAccountServices(): void { + const store = state.store + if (!store || !state.claudeUsage || !state.codexUsage || !state.openCodeUsage) { + throw new Error('Usage stores must be initialized before account services') + } + state.rateLimits = new RateLimitService() + state.codexRuntimeHome = new CodexRuntimeHomeService(store) + void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) + state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) + setSystemCodexHomeHookSweepSuppressed( + () => + state.codexRuntimeHome !== null && + state.codexRuntimeHome.isHostSystemDefaultRealHome() && + isAgentStatusHooksEnabled(state.store?.getSettings()) + ) + state.codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => + state.codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, + isQuitting: () => state.isQuitting, + resolveSystemCodexHomePathOverride: () => + resolveHostCodexSessionSourceHome(store.getSettings()), + prepareScheduledRun: (scanDates) => + state.codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), + finishScheduledRun: () => state.codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), + startBackfill: startCodexSessionBackfillInBackground, + startIndexHeal: startCodexSessionIndexHealInBackground + }) + state.codexAccounts = new CodexAccountService(store, state.rateLimits, state.codexRuntimeHome, { + onHostSystemDefaultSelected: state.codexSessionMigration.requestRun + }) + state.codexSessionMigration.scheduleInitialRun() + state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) + state.rateLimits.setCodexHomePathResolver((target) => + state.codexRuntimeHome!.prepareForRateLimitFetch(target) + ) + state.rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + state.rateLimits.setKimiHomeResolver(() => + resolveKimiHome(getKimiRuntimeTarget(store.getSettings())) + ) + state.rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( + state.rateLimits, + store.getSettings() + ) + store.onSettingsChanged((updates, settings) => { + void syncAccountRuntimeTargets(updates, settings).catch((error) => + console.warn('[rate-limits] Failed to apply account runtime target:', error) + ) + }) + state.rateLimits.setClaudeAuthPreparationResolver((target) => + state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) + ) + // Live Claude sessions publish quota windows through their status-line hook; + // consume those snapshots before falling back to OAuth polling. + agentHookServer.setClaudeStatusLineListener((event) => { + state.rateLimits!.ingestLiveClaudeRateLimits(event) + }) + state.rateLimits.setOpenCodeGoConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: settings.opencodeSessionCookie, + workspaceIdOverride: settings.opencodeWorkspaceId + } + }) + state.rateLimits.setMiniMaxConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: readMiniMaxSessionCookie() ?? '', + groupId: settings.minimaxGroupId, + models: settings.minimaxUsageModels + } + }) + state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) + state.keybindings = new KeybindingService({ + homePath: app.getPath('home'), + getLegacyOverrides: () => store.getSettings().keybindings, + legacyTabSwitchSeed: { + isPending: () => store.getSettings().tabSwitchKeybindingSeed === 'pending', + markSeeded: () => store.updateSettings({ tabSwitchKeybindingSeed: 'done' }) + } + }) + browserManager.setSettingsResolver(() => ({ keybindings: state.keybindings?.getOverrides() })) + state.rateLimits.setInactiveClaudeAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeClaudeRuntimeSelection(settings).host, + ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.claudeManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + managedAuthPath: account.managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + wslLinuxAuthPath: account.wslLinuxAuthPath + })) + }) + state.rateLimits.setInactiveCodexAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeCodexRuntimeSelection(settings).host, + ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.codexManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + resolveHome: () => { + const resolved = + state.codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) + return resolved.kind === 'ready' + ? { kind: 'ready' as const, managedHomePath: resolved.homePath } + : { kind: 'skip' as const } + } + })) + }) +} diff --git a/src/main/startup/main-process-automations.ts b/src/main/startup/main-process-automations.ts new file mode 100644 index 00000000000..77092ed66b8 --- /dev/null +++ b/src/main/startup/main-process-automations.ts @@ -0,0 +1,99 @@ +import { AutomationService } from '../automations/service' +import { createHeadlessAutomationOutputSnapshotBuffer } from '../automations/headless-dispatch' +import { buildHeadlessAutomationWorktreeCreateArgs } from '../automations/headless-workspace-create' +import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAutomations(): AutomationService { + const store = state.store + const runtime = state.runtime + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + if (!store || !runtime || !claudeUsage || !codexUsage) { + throw new Error('Runtime and usage stores must be initialized before automations') + } + const service = new AutomationService(store, { + claudeUsage, + codexUsage, + terminalObserver: createRuntimeAutomationRunTerminalObserver(runtime), + onAutomationsChanged: (payload) => runtime.notifyAutomationsChanged(payload), + allowRemoteHostScheduling: state.isServeMode, + headlessDispatcher: state.isServeMode + ? async ({ automation, run, target }) => { + const terminalSnapshotLimit = 2_000 + let terminalHandle: string + let terminalSessionId: string | null = null + let terminalPaneKey: string | null = null + let terminalPtyId: string | null = null + let workspaceId: string + let workspaceDisplayName: string | null = null + if (automation.workspaceMode === 'new_per_run') { + const created = await runtime.createManagedWorktree( + buildHeadlessAutomationWorktreeCreateArgs({ automation, run, repo: target.repo }) + ) + terminalHandle = created.startupTerminal?.handle ?? '' + terminalSessionId = created.startupTerminal?.tabId ?? null + terminalPaneKey = created.startupTerminal?.paneKey ?? null + terminalPtyId = created.startupTerminal?.ptyId ?? null + workspaceId = created.worktree.id + workspaceDisplayName = created.worktree.displayName ?? null + if (!terminalHandle) { + throw new Error( + created.warning || + 'Automation workspace was created, but no agent terminal started.' + ) + } + } else { + if (!automation.workspaceId) { + throw new Error('The target workspace is no longer available.') + } + const terminal = await runtime.launchAgentTerminal(`id:${automation.workspaceId}`, { + agent: automation.agentId, + prompt: automation.prompt, + title: run.title + }) + terminalHandle = terminal.handle + terminalSessionId = terminal.tabId ?? null + terminalPaneKey = terminal.paneKey ?? null + terminalPtyId = terminal.ptyId ?? null + workspaceId = terminal.worktreeId + const worktree = await runtime.showManagedWorktree(`id:${workspaceId}`) + workspaceDisplayName = worktree.displayName ?? null + } + const completion = (async () => { + const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle' }) + const read = await runtime.readTerminal(terminalHandle, { + limit: terminalSnapshotLimit + }) + const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() + snapshotBuffer.append(read.tail.join('\n')) + if (wait.satisfied) { + return { + status: 'completed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: null + } + } + return { + status: 'dispatch_failed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: wait.blockedReason + ? `Automation agent is blocked: ${wait.blockedReason}.` + : 'Automation agent did not report completion.' + } + })() + return { + workspaceId, + workspaceDisplayName, + terminalSessionId, + terminalPaneKey, + terminalPtyId, + completion + } + } + : undefined + }) + state.automations = service + runtime.setAutomationService(service) + return service +} diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts new file mode 100644 index 00000000000..b6e494c1ad0 --- /dev/null +++ b/src/main/startup/main-process-i18n-menu.ts @@ -0,0 +1,94 @@ +import { app, BrowserWindow } from 'electron' +import { ensureMainI18n, setMainUiLanguage } from '../i18n/main-i18n' +import { + registerAppMenu, + rebuildAppMenu, + getNextDefaultOnAppearanceSettingValue +} from '../menu/register-app-menu' +import { zoomDashboardPopoutIfFocused } from '../window/dashboard-popout-window' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { mainProcessState as state } from './main-process-state' +import { + openSettingsFromSystemMenu, + runUserInitiatedUpdateCheck, + sendOpenCrashReport, + sendOpenFeatureTour, + sendOpenSetupGuide +} from './main-window-actions' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeMainProcessI18nAndMenu(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before menu') + } + await ensureMainI18n() + await setMainUiLanguage(store.getSettings().uiLanguage) + logStartupMilestone('i18n-ready') + registerAppMenu({ + appMenuLabel: state.devInstanceIdentity?.name ?? app.name, + onCheckForUpdates: (options) => { + ensureAutoUpdaterConfigured() + runUserInitiatedUpdateCheck(options) + }, + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + state.expectedRendererReload.mark(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onOpenSettings: openSettingsFromSystemMenu, + onOpenSetupGuide: (targetWindow) => { + recordCrashBreadcrumb('setup_guide_opened') + sendOpenSetupGuide(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenCrashReport: (targetWindow) => { + recordCrashBreadcrumb('crash_report_opened') + sendOpenCrashReport(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenFeatureTour: (targetWindow) => { + recordCrashBreadcrumb('feature_tour_opened') + sendOpenFeatureTour(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onZoomIn: () => { + if (!zoomDashboardPopoutIfFocused('in')) { + state.mainWindow?.webContents.send('terminal:zoom', 'in') + } + }, + onZoomOut: () => { + if (!zoomDashboardPopoutIfFocused('out')) { + state.mainWindow?.webContents.send('terminal:zoom', 'out') + } + }, + onZoomReset: () => { + if (!zoomDashboardPopoutIfFocused('reset')) { + state.mainWindow?.webContents.send('terminal:zoom', 'reset') + } + }, + onToggleLeftSidebar: () => state.mainWindow?.webContents.send('ui:toggleLeftSidebar'), + onToggleRightSidebar: () => state.mainWindow?.webContents.send('ui:toggleRightSidebar'), + onToggleAppearance: (key) => { + if (key === 'statusBarVisible') { + state.mainWindow?.webContents.send('ui:toggleStatusBar') + return + } + const current = store.getSettings() + const next = getNextDefaultOnAppearanceSettingValue(current[key]) + store.updateSettings({ [key]: next }, { notifyListeners: true }) + rebuildAppMenu() + }, + getAppearanceState: () => { + const settings = store.getSettings() + const ui = store.getUI() + return { + showTasksButton: settings.showTasksButton !== false, + showAutomationsButton: settings.showAutomationsButton !== false, + showMobileButton: settings.showMobileButton !== false, + showTitlebarAppName: settings.showTitlebarAppName !== false, + statusBarVisible: ui.statusBarVisible !== false + } + }, + getKeybindings: () => state.keybindings?.getOverrides() + }) +} diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts new file mode 100644 index 00000000000..a948e4fb13f --- /dev/null +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -0,0 +1,47 @@ +import { ipcMain } from 'electron' +import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' + +export function registerMainProcessIpcHandlers(): void { + ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + }) + ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + await state.runtime?.prepareStructuredAgentSessionStartupRestoration() + }) + ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => + recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: state.firstWindowStartupServicesReady, + managedWslCliStartupBarrierReady: state.managedWslCliStartupBarrierReady, + localPtyProviderStartupReady: state.localPtyProviderStartupReady, + reconcile: async () => { + await state.runtime?.refreshRestoredOrchestrationAuthority() + return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) + }, + onDeferredRecoveryError: (error) => { + console.warn('[orchestration] legacy worker provider-ready recovery failed', error) + } + }) + ) + ipcMain.handle('ui:consumePendingOpenSettings', (event) => + state.pendingOpenSettings.matches(event.sender.id, { consume: true }) + ) + ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + ipcMain.handle( + 'app:startupDiagnostic', + (_event, event: string, details?: Record) => { + if (!state.startupDiagnosticsEnabled || !event.startsWith('renderer-')) { + return + } + logStartupMilestone(event, details && typeof details === 'object' ? details : {}) + } + ) +} diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts new file mode 100644 index 00000000000..0521f600605 --- /dev/null +++ b/src/main/startup/main-process-observers.ts @@ -0,0 +1,132 @@ +import { app } from 'electron' +import { join } from 'node:path' +import { AgentAwakeService } from '../agent-awake-service' +import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' +import { registerSystemResumeBroadcast } from '../system-resume-broadcast' +import { agentHookServer, type AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' +import { createHookStatusSessionTabsInvalidator } from '../agent-hooks/hook-status-session-tabs-invalidation' +import { initTelemetry, track } from '../telemetry/client' +import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' +import { initObservability } from '../observability' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { recoverPendingSkillTransactions } from '../skills/skill-transaction-startup-recovery' +import { initCohortClassifier } from '../telemetry/cohort-classifier' +import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier' +import { StatsCollector } from '../stats/collector' +import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder' +import { ClaudeUsageStore } from '../claude-usage/store' +import { CodexUsageStore } from '../codex-usage/store' +import { OpenCodeUsageStore } from '../opencode-usage/store' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessObservers(): void { + const store = state.store + const runtime = state.runtime + if (!store) { + throw new Error('Store must be initialized before observers') + } + state.unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() + state.agentAwakeService = new AgentAwakeService() + state.agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) + state.agentAwakeService.setStatuses([]) + const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() + const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { + const ownedIdentities = identities.map((identity) => ({ + ...identity, + worktreeId: + identity.worktreeId ?? + runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? + undefined + })) + for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { + runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) + } + } + state.publishProviderSessionChanges = publishProviderSessionChanges + const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { + state.agentAwakeService?.setStatuses(statuses) + }) + const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( + (sessions) => publishProviderSessionChanges(sessions) + ) + const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() + const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { + if (hookStatusChangedSessionTabs(enriched)) { + runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) + } + }) + const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { + const clearedPaneKeys = + 'paneKey' in clear + ? [clear.paneKey] + : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) + for (const paneKey of clearedPaneKeys) { + hookStatusChangedSessionTabs.forgetPane(paneKey) + runtime?.touchMobileSessionTabsForPane(paneKey) + } + }) + state.unsubscribeAgentAwakeStatusChanges = () => { + unsubscribeStatusChanges() + unsubscribeProviderSessionChanges() + unsubscribeHookStatusSessionTabs() + unsubscribeHookStatusClear() + } + initTelemetry(store) + if (state.hangDetection) { + track('main_thread_hang_detected', { + unresponsive_ms: Math.round(state.hangDetection.unresponsiveMs), + self_recovered: state.hangDetection.selfRecovered + }) + } + setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { + track('codex_trust_grant', { + outcome, + host_kind: hostKind, + lane, + ...(reason !== undefined ? { fallback_reason: reason } : {}), + ...(errorClass !== undefined ? { error_class: errorClass } : {}), + ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) + }) + }) + initObservability() + recordDurableCrashBreadcrumb('main_process_lifecycle_started', { + packaged: app.isPackaged, + platform: process.platform + }) + state.skillTransactionRecovery = recoverPendingSkillTransactions( + join(app.getPath('userData'), 'skill-installs') + ) + void state.skillTransactionRecovery + .then((report) => { + const result = report as { + scanned: number + recovered: number + failures: { code: string }[] + truncated: boolean + } + if (result.scanned || result.failures.length || result.truncated) { + console.info('[skills] startup transaction recovery:', { + scanned: result.scanned, + recovered: result.recovered, + failures: result.failures.map((failure) => failure.code), + truncated: result.truncated + }) + } + }) + .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) + initCohortClassifier(store) + initOnboardingCohortClassifier(store) + state.stats = new StatsCollector() + const agentSessionRecorder = new AgentSessionTransitionRecorder(state.stats) + agentHookServer.subscribeEnrichedStatus((enriched) => agentSessionRecorder.onStatus(enriched)) + agentHookServer.subscribePaneStatusClear((clear) => agentSessionRecorder.onCleared(clear)) + state.claudeUsage = new ClaudeUsageStore(store) + state.codexUsage = new CodexUsageStore(store) + state.openCodeUsage = new OpenCodeUsageStore(store) +} diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts new file mode 100644 index 00000000000..5a71cc6f8b3 --- /dev/null +++ b/src/main/startup/main-process-plugins.ts @@ -0,0 +1,158 @@ +import { app, BrowserWindow } from 'electron' +import { performance } from 'node:perf_hooks' +import { PluginService } from '../plugins/plugin-service' +import { PluginKillListService } from '../plugins/plugin-kill-list-service' +import { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import { PluginBundledBootstrapCoordinator } from '../plugins/plugin-bundled-bootstrap-coordinator' +import { getPluginsDataDir } from '../plugins/plugin-discovery' +import { resolveBundledPluginRoot } from '../plugins/plugin-bundled-bootstrap' +import { resolvePluginHostEntryPath } from '../plugins/plugin-host-process' +import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' +import { setPluginServiceForRpc } from '../runtime/rpc/methods/plugins' +import { + normalizePluginConsents, + normalizePluginIdList +} from '../../shared/plugins/plugin-consent-state' +import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n' +import { rebuildAppMenu } from '../menu/register-app-menu' +import { logStartupMilestone } from './startup-diagnostics' +import { agentHookServer } from '../agent-hooks/server' +import { emitPluginWorktreeLifecycle } from './main-process-pty-startup' +import { mainProcessState as state } from './main-process-state' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): Promise { + const store = state.store + const keybindings = state.keybindings + if (!store || !keybindings) { + throw new Error('Store and keybindings must be initialized before plugins') + } + const pluginSystemStartupStartedAt = performance.now() + state.pluginKillListService = new PluginKillListService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')) + }) + await state.pluginKillListService.initialize() + state.pluginMarketplaceService = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')), + getKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null + }) + const requestOfficialMarketplaceSeed = (): void => { + if (store.getSettings().pluginSystemEnabled !== true) { + return + } + void state.pluginMarketplaceService + ?.seedOfficialSource() + .catch((error) => + console.warn('[plugins] failed to configure the official marketplace:', error) + ) + } + state.pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace: state.pluginMarketplaceService, + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null + }) + state.pluginService = new PluginService({ + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isPluginSystemEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + getDisabledPlugins: () => normalizePluginIdList(state.store?.getSettings().disabledPlugins), + getPluginConsents: () => normalizePluginConsents(state.store?.getSettings().pluginConsents), + getDevPluginPaths: () => normalizePluginIdList(state.store?.getSettings().devPluginPaths), + getKeybindings: () => state.keybindings?.getOverrides() ?? {}, + getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null, + hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) + }) + const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ + root: resolveBundledPluginRoot({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }), + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null, + refreshPlugins: () => state.pluginService?.refresh() ?? Promise.resolve() + }) + const requestBundledPluginBootstrap = (): void => { + void bundledPluginBootstrap + .request() + .then((result) => { + for (const failure of result?.errors ?? []) { + console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) + } + }) + .catch((error) => console.warn('[plugins] failed to bootstrap bundled plugins:', error)) + } + state.pluginKillListService.onChanged(() => { + void state.pluginService + ?.reconcileActivationState() + .catch((error) => + console.warn('[plugins] failed to apply plugin safety-list refresh:', error) + ) + }) + store.onSettingsChanged((updates) => { + if (updates.pluginSystemEnabled === true) { + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + } + if (app.isPackaged && updates.pluginSystemEnabled === true) { + void state.pluginKillListService + ?.refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + }) + setPluginServiceForRpc(state.pluginService, { + applyConsent: (request) => + applyPluginConsent({ store, pluginService: state.pluginService!, ...request }), + applyEnablement: (pluginKey, enabled) => + applyPluginEnablement({ store, pluginService: state.pluginService!, pluginKey, enabled }) + }) + void state.pluginService + .initialize() + .then(() => { + logStartupMilestone('plugin-system-initialized', { + durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), + installedPlugins: state.pluginService?.getDiscovered().length ?? 0 + }) + }) + .catch((error) => console.warn('[plugins] failed to initialize plugin service:', error)) + if (app.isPackaged && store.getSettings().pluginSystemEnabled === true) { + void state.pluginKillListService + .refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + state.pluginService.onChanged((event) => { + if ( + event.contentPacksChanged && + setMainPluginLanguagePacks(state.pluginService?.contentPacks.languagePacks.list() ?? []) + ) { + void setMainUiLanguage(store.getSettings().uiLanguage).then(() => rebuildAppMenu()) + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('plugins:changed', event) + } + } + }) + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + agentHookServer.subscribeEnrichedStatus((enriched) => { + if (enriched.restoredUnconfirmed) { + return + } + state.pluginService?.emitEvent('agent.status.changed', { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt + }) + }) + runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle) +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts new file mode 100644 index 00000000000..a8a887bd896 --- /dev/null +++ b/src/main/startup/main-process-preflight.ts @@ -0,0 +1,323 @@ +import { app, ipcMain, powerMonitor, session } from 'electron' +import { is } from '@electron-toolkit/utils' +import os from 'node:os' +import { join } from 'node:path' +import { maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' +import { maybeRedirectPackagedCliEntryLaunch } from './packaged-cli-entry-redirect' +import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' +import { + configureDevUserDataPath, + configureElectronNetworkCompatibility, + configureOrcaUserDataPathEnv, + disableUnsupportedChromiumFeatures, + enableMainProcessGpuFeatures, + installDevParentDisconnectQuit, + installDevParentSignalQuit, + installDevParentWatchdog, + patchPackagedProcessPath +} from './configure-process' +import { installServeSupervisorDisconnectQuit } from '../serve-update-handoff' +import { + installUncaughtPipeErrorGuard, + installUnhandledRejectionLogging +} from './main-process-error-guards' +import { hydrateShellPath, mergePathSegments } from './hydrate-shell-path' +import { configureRemoteServerUpdater } from '../runtime/remote-server-updater' +import { + getRemoteServerUpdaterSnapshot, + checkForRemoteServerUpdate, + downloadRemoteServerUpdate, + installRemoteServerUpdate, + isQuittingForUpdate +} from '../updater' +import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' +import { enableRendererHeapHeadroom } from './renderer-heap-headroom' +import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' +import { startEventLoopStallProbe } from './event-loop-stall-probe' +import { startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe' +import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation' +import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' +import { createServeDesktopActivationGate } from './serve-desktop-activation' +import { + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, + acquireSingleInstanceLock, + logSingleInstanceLockBypass, + logSingleInstanceLockFailure, + SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE +} from './single-instance-lock' +import { setAppEnvironment } from '../../shared/app-environment' +import { ElectronAppEnvironment } from '../host/electron-app-environment' +import { setSecretStore } from '../../shared/secret-store' +import { ElectronSecretStore } from '../host/electron-secret-store' +import { setPtyHostBindings } from '../ipc/pty-host-bindings' +import { electronRuntimeDesktopSurface } from '../host/electron-runtime-desktop-surface' +import { setRuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' +import { electronRuntimeBrowserCommandsFactory } from '../host/electron-browser-commands' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { electronHttpClient } from '../host/electron-http-client' +import { setMainHttpClient } from '../network/http-client' +import { electronSpeechServiceFactories } from '../host/electron-speech-services' +import { setSpeechServiceFactories } from '../speech/speech-runtime-service' +import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' +import { desktopWorktreeWatcherRemoval } from '../ipc/filesystem-watcher' +import { setDefaultProxySessionResolver } from '../network/proxy-settings' +import { initDataPath, getCanonicalUserDataPath } from '../persistence' +import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default' +import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence' +import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { initStatsPath } from '../stats/collector' +import { initClaudeUsagePath } from '../claude-usage/store' +import { initCodexUsagePath } from '../codex-usage/store' +import { initOpenCodeUsagePath } from '../opencode-usage/store' +import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol' +import { startCrashpadCapture } from '../crash-reporting/crashpad-capture' +import { CrashReportStore } from '../crash-reporting/crash-report-store' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' +import { optOutOfHiddenPageWakeUpThrottling } from './configure-process' +import { ensureVirtualDisplayForHeadlessServe } from './ensure-virtual-display' +import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' +import { mainProcessState as state } from './main-process-state' +import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' + +export type MainProcessPreflightOptions = { + focusExistingWindow: () => void + requestDesktopActivation: (argv?: readonly string[]) => void +} + +/** Performs all module-scope work that must happen before Electron's ready event. */ +export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. + // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. + // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its + // command-name lookup finds a port number and strands the launch in an in-process serve. The + // packaged-CLI one matches on the entry path instead, so order cannot affect it either way. + const packagedRedirect = maybeRedirectPackagedCliEntryLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (packagedRedirect.redirected) { + app.exit(packagedRedirect.status) + } + const appImageRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (appImageRedirect.redirected) { + app.exit(appImageRedirect.status) + } + // Why: extracted AppRun / binary launches can land CLI-form `serve` args on the + // Electron process without the CLI rewrite that injects `--serve` (#12677). + // Guarded so a normal GUI launch keeps its original argv array identity. + if (argvRequestsServeMode(process.argv)) { + process.argv = normalizeServeModeArgv(process.argv) + } + state.isServeMode = process.argv.includes('--serve') + if (state.isServeMode) { + reserveServeStdoutForReadiness() + } + state.devInstanceIdentity = getDevInstanceIdentity(is.dev) + state.devAgentHookEndpointNamespace = state.devInstanceIdentity.isDev + ? state.devInstanceIdentity.appUserModelId + : undefined + state.desktopActivationGate = createServeDesktopActivationGate({ + initialState: state.isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + options.focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) + }) + installUncaughtPipeErrorGuard() + // Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). + installUnhandledRejectionLogging() + // Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. + process.env.ORCA_APP_VERSION = app.getVersion() + configureRemoteServerUpdater({ + getSnapshot: getRemoteServerUpdaterSnapshot, + check: checkForRemoteServerUpdate, + download: downloadRemoteServerUpdate, + install: installRemoteServerUpdate + }) + patchPackagedProcessPath() + // Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. + if (app.isPackaged && process.platform !== 'win32') { + void hydrateShellPath().then((result) => { + if (result.ok) { + mergePathSegments(result.segments) + } else { + // Why: on failure the seeded fallbacks stay in front. For an nvm user that is + // now their `default` version rather than the newest install, so it is usually + // survivable — but it is still not what their shell would have resolved. Name + // the reason so it shows up in a log bundle instead of as a missing CLI. + console.warn( + `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` + ) + } + }) + } + const isDev = is.dev + configureDevUserDataPath(isDev) + configureOrcaUserDataPathEnv() + // Why these four lines are one step (#16761): the two above decide where userData lives, and + // everything below may resolve a path. Installing the accessor any later leaves a window where an + // early resolve either throws — which is what killed `orca serve` — or, worse, memoizes the + // pre-override directory and silently writes user state to the wrong place for the whole session. + // Safe this early: ElectronAppEnvironment holds no state and calls `app` lazily per accessor, so it + // changes no timing, and initDataPath only joins strings. + setAppEnvironment(new ElectronAppEnvironment()) + // Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) + // changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. + initDataPath() + state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('before-single-instance-lock', { + version: app.getVersion(), + packaged: app.isPackaged, + platform: process.platform, + osRelease: os.release(), + userData: app.getPath('userData'), + e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + }) + startEventLoopStallProbe() + } + // Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). + // Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache + // look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. + startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) + // Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. + // Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). + const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + if (bypass) { + // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. + logSingleInstanceLockBypass() + } + const hasLock = skip || bypass || acquireSingleInstanceLock(app, options.requestDesktopActivation) + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('single-instance-lock-result', { + acquired: hasLock, + bypassed: bypass, + skippedForDev: skip + }) + } + if (!hasLock) { + // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. + logSingleInstanceLockFailure() + // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). + app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + return false + } + // Why first in this block: the accessor throws until installed and everything below may read a + // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so + // installing here changes no timing, in particular not the pre-ready Keychain service-name + // resolution. The app-environment port and the userData capture install earlier still, next to + // the path decision they depend on. + setSecretStore(new ElectronSecretStore()) + // Why at process level, not per-window: pty.ts registers against injected surfaces so + // it can load without electron, and an Electron main process always has ipcMain — + // whether a window exists is irrelevant. Installing this in attachMainWindowServices + // meant `orca serve` registered its PTY handlers against no-ops before any window + // attached, so a paired desktop owner never received them. + setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) + // Why also at process level: the runtime's notification, window-lookup and + // tab-create-reply channel are desktop-only. A Node host installs none and the + // runtime routes notifications to paired clients instead. + setRuntimeDesktopSurface(electronRuntimeDesktopSurface) + // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser + // cluster into the graph. The desktop installs it; a Node host installs none and every + // browser RPC rejects, which capability filtering already tells clients about. + setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) + // Why here: proxy-settings only needed electron for `session.defaultSession`. The + // desktop supplies it; a Node host has no Chromium proxy config to consult, so the + // environment variables are the whole answer there. + setDefaultProxySessionResolver(() => session.defaultSession) + // Why here: integrations use Chromium's network stack on the desktop. A Node host + // falls back to the platform default, which is a real behavioural difference (proxy + // read from the environment, Node's user agent) rather than a transparent swap. + setMainHttpClient(electronHttpClient) + // Why here: constructing the speech services is what pulls Electron's streaming net + // request in. A host without them rejects speech calls rather than pretending. + setSpeechServiceFactories(electronSpeechServiceFactories) + setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) + // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. + const shouldCoupleToDevParent = isDev && !state.isServeMode + installDevParentDisconnectQuit(shouldCoupleToDevParent) + installDevParentWatchdog(shouldCoupleToDevParent) + installDevParentSignalQuit(shouldCoupleToDevParent) + // Why not at module scope with the other lifetime couplings (#16761): this resolves the handoff + // path, so it throws until setAppEnvironment() above installs the accessor — which killed every + // `orca serve` process before it could listen. After initDataPath() specifically, so the + // path-equality check against the CLI's env var uses the dir captured before app.setName(). + // Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module + // finishes evaluating, so moving this behind an await would open a real orphan window. + installServeSupervisorDisconnectQuit(state.isServeMode) + // Why here: initDataPath above gives the canonical userData path for the record file; the write + // itself lands for the next launch (see macos-press-and-hold-default.ts). + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) + // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. + initSessionParseCachePersistence({ + filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), + appVersion: app.getVersion() + }) + initOrcaProfilePaths() + // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. + initStatsPath() + initClaudeUsagePath() + initCodexUsagePath() + initOpenCodeUsagePath() + // Why: Electron resolves the macOS safeStorage Keychain service name + // (" Safe Storage") before `ready`, so the setName in whenReady is + // too late to move it — dev otherwise lands on the package.json name. Dev-only + // so a packaged build keeps deriving the key from its own CFBundleName. + // Safe here: dev always pins userData via app.setPath (configure-process.ts), + // so setName cannot shift the paths captured just above. + if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { + app.setName(state.devInstanceIdentity.appName) + } + // Why: Electron freezes the privileged scheme table at ready, so the doc-preview + // scheme must be declared here or its webview loses fetch/secure-origin privileges. + registerDocPreviewSchemePrivileges() + // Why: must precede app.whenReady() so Crashpad is installed before the + // first renderer spawns; a CHECK before this point is still exit-code-only. + startCrashpadCapture() + state.crashReports = CrashReportStore.fromUserData() + state.gpuCrashDiagnostics = + process.platform === 'win32' + ? new GpuCrashDiagnosticsRecorder({ + provider: { + getGPUInfo: (infoType) => app.getGPUInfo(infoType), + getGPUFeatureStatus: () => app.getGPUFeatureStatus() + }, + recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) + }) + : null + recordCrashBreadcrumb('app_started', { + packaged: app.isPackaged, + platform: process.platform, + ...getMainProcessLifecycleIdentity() + }) + disableUnsupportedChromiumFeatures() + // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. + optOutOfHiddenPageWakeUpThrottling() + configureElectronNetworkCompatibility() + enableRendererHeapHeadroom() + maybeApplyGpuFallbackForThisLaunch() + if (!state.gpuFallbackActiveThisLaunch) { + enableMainProcessGpuFeatures() + } + // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. + state.headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ + isServeMode: state.isServeMode + }) + initializeSyntheticTitleRuntime() + registerGpuLifecycleHandlers() + return true +} diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts new file mode 100644 index 00000000000..e847646da51 --- /dev/null +++ b/src/main/startup/main-process-pty-startup.ts @@ -0,0 +1,195 @@ +import { app } from 'electron' +import { classifyError } from '../telemetry/classify-error' +import { track } from '../telemetry/client' +import { getPtyIdForPaneKey } from '../ipc/pty' +import { + getDaemonProvider, + initDaemonPtyProvider, + listLiveDaemonPtyIds +} from '../daemon/daemon-init' +import { + getCodexPaneAccount, + hasAnyRecordedLegacyWslCodexPane, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, + reconcileCodexPaneAccountsWithLivePtys, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { reconcileRetainedCodexHookHomes } from '../codex/retained-codex-hook-state' +import { codexHookService } from '../codex/hook-service' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { + indexPersistedPaneKeyPtyIds, + isLocalExecutionHost, + resolveAgentWorkspaceExecutionHostId, + sweepRestoredSubagentsWithoutLiveAgent +} from '../agent-hooks/restored-subagent-liveness-sweep' +import { startFirstWindowStartupServices } from './first-window-startup-services' +import { logStartupMilestone } from './startup-diagnostics' +import type { WindowsDesktopStartupServices } from './windows-desktop-shell-path-startup' +import type { RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' +import { mainProcessState as state } from './main-process-state' + +export function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + state.pluginService?.emitEvent( + event.kind === 'created' ? 'worktree.created' : 'worktree.removed', + event.kind === 'created' + ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } + : { worktreeId: event.worktreeId, path: event.path } + ) +} + +export function handleCodexHomePtySpawned(args: { + id: string + codexHomePath: string | null + reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +}): void { + if (args.reattached && args.startedSequence !== undefined) { + const paneAccount = getCodexPaneAccount(args.id) + const homeRoute = + args.reattachedHomeRoute !== undefined + ? (args.reattachedHomeRoute ?? undefined) + : paneAccount?.homeRoute + if (state.codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { + state.codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) + return + } + } + const fullScanRequired = + state.codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { + reattached: args.reattached, + launchEnv: args.launchEnv + }) ?? null + if (fullScanRequired !== null) { + state.codexSessionMigration?.beginLaunch( + args.id, + args.reattached === true || fullScanRequired, + args.startedAt, + args.startedSequence + ) + } +} + +export function handlePtyExit(id: string, exitSequence: number): void { + state.codexSessionMigration?.finishLaunch(id, exitSequence) +} + +export async function reapRestoredSubagentsWithoutLiveAgent(): Promise { + const store = state.store + if (!store) { + return + } + const provider = getDaemonProvider() + if (!provider) { + return + } + const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( + store.getWorkspaceSession().terminalLayoutsByTabId ?? {} + ) + await sweepRestoredSubagentsWithoutLiveAgent({ + probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), + isLocalExecutionHost: (worktreeId) => + isLocalExecutionHost( + resolveAgentWorkspaceExecutionHostId(worktreeId, { + getRepo: (repoId) => store.getRepo(repoId), + getWorktreeMeta: (resolvedWorktreeId) => store.getWorktreeMeta(resolvedWorktreeId), + getFolderWorkspace: (folderWorkspaceId) => store.getFolderWorkspace(folderWorkspaceId), + getProjectGroups: () => store.getProjectGroups() + }) + ), + getBoundPtyIdForPaneKey: getPtyIdForPaneKey, + getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), + reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => + agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalHost, + isLocalPaneAgentLive, + isLocalPaneLivenessEvidenceCurrent + ) + }) +} + +export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { + logStartupMilestone('first-window-startup-services-start') + const startupServices = startFirstWindowStartupServices({ + // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. + startDaemonPtyProvider: async (signal) => { + logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) + await initDaemonPtyProvider(signal, { + macosLoginSessionWatch: process.platform === 'darwin' && !state.isServeMode + }) + const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() + if ( + state.codexRuntimeHome && + (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane()) + ) { + const livePtyIds = await listLiveDaemonPtyIds() + if (livePtyIds) { + reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + const settings = state.store?.getSettings() + if (hasRetainedManagedHostPane) { + void reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: state.codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }).catch((error) => + console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) + ) + } + } + } + state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() + logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) + }, + startAgentHookServer: async () => { + const settings = state.store?.getSettings() + if (!isAgentStatusHooksEnabled(settings)) { + return + } + logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) + agentHookServer.setTransportInterferenceListener((report) => { + track('agent_hook_transport_blocked', { count: report.count }) + }) + await agentHookServer.start({ + env: app.isPackaged ? 'production' : 'development', + userDataPath: app.getPath('userData'), + endpointNamespace: state.devAgentHookEndpointNamespace + }) + logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) + }, + onDaemonError: (error) => { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` + ) + track('daemon_start_failed', classifyError(error)) + }, + onAgentHookServerError: (error) => { + console.error('[agent-hooks] Failed to start local hook server:', error) + } + }) + void startupServices.firstWindowReady.then(() => + logStartupMilestone('first-window-startup-services-ready') + ) + void startupServices.localPtyReady.then(() => { + logStartupMilestone('local-pty-startup-ready') + void reapRestoredSubagentsWithoutLiveAgent().catch((error) => + console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) + ) + }) + return startupServices +} + +export function bindTerminalRuntimeStartupServices( + services: Promise +): void { + state.firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) + state.localPtyStartupReady = services.then((value) => value.localPtyReady) + state.localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) +} diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts new file mode 100644 index 00000000000..4505e51ba93 --- /dev/null +++ b/src/main/startup/main-process-quit.ts @@ -0,0 +1,217 @@ +import { app, type Event } from 'electron' +import { closeAllWatchers } from '../ipc/filesystem-watcher' +import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher' +import { stopFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { killAllPty } from '../ipc/pty' +import { disconnectDaemon, shutdownDaemon } from '../daemon/daemon-init' +import { beginSshShutdown } from '../ipc/ssh-shutdown-drain' +import { agentHookServer } from '../agent-hooks/server' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { removeManagedAgentHooksAsync } from '../agent-hooks/managed-agent-hook-controls' +import { stopStructuredAgentSessionRuntime } from '../runtime/structured-agent-session-runtime' +import { awaitRuntimeFileWatcherUnsubscribes } from '../runtime/orca-runtime-files' +import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' +import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' +import { browserManager } from '../browser/browser-manager' +import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' +import { quitTeardownStartGate } from '../quit-teardown-start-gate' +import { setUnreadDockBadgeCount } from '../dock/unread-badge' +import { destroySystemTray } from '../tray/system-tray' +import { shutdownTelemetry } from '../telemetry/client' +import { shutdownObservability } from '../observability' +import { isQuittingForUpdate } from '../updater' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { stopTccPromptNotice } from '../macos-tcc-prompt-notice' +import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy' +import { mainProcessState as state } from './main-process-state' +import { isDevParentShutdownRequested } from './configure-process' +import { getCanonicalUserDataPath } from '../persistence' + +let daemonDisconnectDone = false +let watcherShutdownPromise: Promise | null = null +const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 + +function shutdownWatchersOnce(): Promise { + if (state.watcherShutdownDone) { + return Promise.resolve() + } + if (!watcherShutdownPromise) { + stopFolderRepoGitUpgradeWatch() + watcherShutdownPromise = Promise.allSettled([ + closeAllWatchers(), + disposeWorktreeBaseDirectoryWatchers() + ]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') { + console.error('[filesystem-watcher] shutdown failed:', result.reason) + } + } + }) + .then(() => { + state.watcherShutdownDone = true + }) + } + return watcherShutdownPromise +} + +function installBeforeQuitHandler(): void { + app.on('before-quit', () => { + if (isQuittingForUpdate()) { + recordUpdaterLifecycle('before_quit_allowed', undefined, { + message: 'before-quit allowed for update install' + }) + } + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + state.runtimeRpc?.setMobileRelayPairingProvider(null) + state.unsubscribeAgentAwakeStatusChanges?.() + state.unsubscribeAgentAwakeStatusChanges = null + state.agentAwakeService?.dispose() + state.agentAwakeService = null + state.rateLimits?.stop() + }) +} + +function installWillQuitHandler(): void { + app.on('will-quit', (event: Event) => { + if (daemonDisconnectDone) { + return + } + if (!quitTeardownStartGate.tryStart(event)) { + return + } + state.unsubscribeSystemResumeBroadcast?.() + state.unsubscribeSystemResumeBroadcast = null + stopTccPromptNotice() + const updateQuitInProgress = isQuittingForUpdate() + if (updateQuitInProgress) { + recordUpdaterLifecycle( + 'will_quit_cleanup_started', + { daemonTeardown: 'disconnect' }, + { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } + ) + } + destroySystemTray() + state.starNag?.stop() + state.automations?.stop() + state.pluginKillListService = null + state.pluginMarketplaceService = null + state.pluginMarketplaceInstaller = null + const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() + const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() + state.pluginService = null + setUnreadDockBadgeCount(0) + agentHookServer.stop() + const grokHookCleanup = + process.platform === 'win32' + ? settleWithinMs( + removeManagedAgentHooksAsync({ agents: ['grok'] }), + GROK_HOOK_CLEANUP_DEADLINE_MS + ).then((settled) => { + if (settled.outcome === 'timed-out') { + console.warn('[agent-hooks] Grok hook cleanup on quit timed out') + return + } + if (settled.outcome === 'failed') { + console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) + return + } + for (const status of settled.value.filter((entry) => entry.detail)) { + console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) + } + }) + : Promise.resolve() + wslHookRelayManager.disposeAll() + const statsFlush = state.stats?.flushAsync() ?? Promise.resolve() + const browserShutdown = (async (): Promise => { + await state.runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + await state.runtime?.getAgentBrowserBridge()?.destroyAllSessions() + })() + const localSshRouteShutdown = import('../browser/local-ssh-browser-route') + .then((routes) => routes.closeAllLocalSshBrowserRoutes()) + .catch(() => {}) + browserManager.setBrowserGuestStateChangedListener(null) + const emulatorShutdown = + state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() + const sshShutdown = beginSshShutdown() + killAllPty() + const watcherShutdown = shutdownWatchersOnce() + const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + const usageCacheFlush = Promise.all([ + state.claudeUsage?.flush(), + state.codexUsage?.flush(), + state.openCodeUsage?.flush() + ]).then(() => {}) + const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() + const skillUploadShutdown = state.runtime?.disposeSkillUploadSessions() ?? Promise.resolve() + const ownedPid = process.pid + const ownedRuntimeId = state.runtime?.getRuntimeId() + const rpcStopAndClear = state.runtimeRpc + ? state.runtimeRpc + .stop() + .then(() => awaitRuntimeFileWatcherUnsubscribes()) + .then(() => { + if (ownedRuntimeId) { + clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) + } + }) + .catch((error) => console.error('[runtime] Failed to stop local RPC transport:', error)) + : Promise.resolve() + const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + settleTeardownWithinDeadline([ + { name: 'daemon', promise: daemonTeardown }, + { name: 'browser', promise: browserShutdown }, + { name: 'runtime-rpc', promise: rpcStopAndClear }, + { name: 'watchers', promise: watcherShutdown }, + { name: 'emulator', promise: emulatorShutdown }, + { name: 'browser-client-hosts', promise: browserClientHostShutdown }, + { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, + { name: 'ssh', promise: sshShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown }, + { name: 'skill-uploads', promise: skillUploadShutdown }, + { name: 'grok-hooks', promise: grokHookCleanup }, + { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, + { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, + { name: 'usage-cache', promise: usageCacheFlush }, + { name: 'stats', promise: statsFlush }, + { name: 'state', promise: storeFlush } + ]) + .then((pendingTeardowns) => { + if (pendingTeardowns.length > 0) { + console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) + } + }) + .then(() => shutdownTelemetry()) + .then(() => shutdownObservability()) + .catch(() => {}) + .then(() => { + daemonDisconnectDone = true + app.quit() + }) + }) +} + +function installWindowAllClosedHandler(): void { + app.on('window-all-closed', () => { + if ( + shouldQuitWhenAllWindowsClosed({ + platform: process.platform, + isQuitting: state.isQuitting, + isServeMode: state.isServeMode + }) + ) { + app.quit() + } + }) +} + +/** Installs the process-level shutdown listeners once during bootstrap. */ +export function installMainProcessQuitHandlers(): void { + process.once('exit', stopTccPromptNotice) + installBeforeQuitHandler() + installWillQuitHandler() + installWindowAllClosedHandler() +} diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts new file mode 100644 index 00000000000..ec7a48753a5 --- /dev/null +++ b/src/main/startup/main-process-ready-foundation.ts @@ -0,0 +1,235 @@ +import { app, session } from 'electron' +import { electronApp, is } from '@electron-toolkit/utils' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' +import { applyElectronProxySettings } from '../network/proxy-settings' +import { installElectronProxyRequestGuard } from '../network/electron-proxy-request-guard' +import { handleElectronProxyLogin } from '../network/electron-proxy-credentials' +import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog' +import { + consumeHangDetectionMarker, + hangDetectionMarkerPath +} from '../hang-watchdog/hang-detection-marker' +import { browserCertificateTrustController } from '../browser/browser-manager' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { Store, getCanonicalUserDataPath } from '../persistence' +import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' +import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' +import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' +import { neutralizeLegacyTerminalShimDir } from '../pty/legacy-terminal-shim-dir' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import { + configureWindowsHostGitEnvironmentReadiness, + setDefaultWslDistroOverride +} from '../git/runner' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { + attachClaudeLivePtyPersistence, + onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence +} from '../claude-accounts/live-pty-gate' +import { applyAppIcon } from '../app-icon' +import { + shouldSuppressDevEducation, + suppressDevEducationForStore +} from './dev-education-suppression' +import { setBrowserNetworkProxySettingsResolver } from '../browser/browser-session-proxy' +import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol' +import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' +import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' +import { applyBrowserSessionProxies } from '../browser/browser-session-proxy' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { syncMacMenuBarIcon } from './main-window-actions' +import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' +import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' +import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' + +export async function initializeReadyFoundation(): Promise { + logStartupMilestone('app-ready') + applyBackgroundActivationPolicy({ warn: console.warn }) + installElectronProxyRequestGuard(session.defaultSession) + app.on('login', (event, webContents, details, authInfo, callback) => { + handleElectronProxyLogin( + event, + webContents, + details, + authInfo, + callback, + session.defaultSession + ) + }) + const canonicalUserDataPath = getCanonicalUserDataPath() + installMainThreadHangWatchdog({ userDataPath: canonicalUserDataPath }) + state.hangDetection = consumeHangDetectionMarker(hangDetectionMarkerPath(canonicalUserDataPath)) + if (state.hangDetection) { + recordDurableCrashBreadcrumb('main_thread_hang_detected', { + unresponsiveMs: state.hangDetection.unresponsiveMs, + previousPid: state.hangDetection.parentPid, + selfRecovered: state.hangDetection.selfRecovered + }) + } + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) + const identity = state.devInstanceIdentity + if (!identity) { + throw new Error('Development identity is unavailable') + } + electronApp.setAppUserModelId(identity.appUserModelId) + app.setName(identity.appName) + updateGpuAccelerationAboutPanel() + state.managedWslCliReconciliationStatus = 'pending' + state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ + isPackaged: app.isPackaged, + userDataPath: canonicalUserDataPath, + appVersion: app.getVersion() + }) + .then((results) => { + for (const result of results) { + if (result.outcome === 'failed') { + console.warn( + `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` + ) + } else if (result.outcome === 'repaired') { + console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) + } + } + state.managedWslCliReconciliationStatus = 'settled' + }) + .catch((error) => { + state.managedWslCliReconciliationStatus = 'failed' + console.warn( + '[wsl-cli] Managed registration reconciliation discovery failed:', + error instanceof Error ? error.message : String(error) + ) + }) + state.managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( + state.managedWslCliReconciliationReady + ) + const profile = ensureActiveOrcaProfile() + state.activeOrcaProfile = profile + initializeBrowserClientHostId(profile.profileDirectory) + const store = new Store({ + dataFile: profile.dataFile, + storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + }) + state.store = store + const initialProxyApplication = applyElectronProxySettings(store.getSettings()) + installElectronProxyRequestGuard(session.defaultSession) + scheduleSecretProtectionGapReport({ + dataFile: profile.dataFile, + force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', + deferUntilFirstWindow: !state.isServeMode + }) + initSshHostKeyStoreFile(profile.dataFile) + neutralizeLegacyTerminalShimDir(app.getPath('userData')) + const windowsShellPathHydration = createWindowsShellPathHydration() + state.windowsShellPathHydration = windowsShellPathHydration + configureWindowsHostGitEnvironmentReadiness( + process.platform === 'win32' ? windowsShellPathHydration.whenReady : null + ) + if (process.platform === 'win32') { + const settings = store.getSettings() + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + wslHookRelayManager.setManagedHookSettingsResolver(() => state.store?.getSettings() ?? null) + logStartupMilestone('store-loaded') + setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) + store.onSettingsChanged((updates, settings) => { + if ('terminalWindowsWslDistro' in updates) { + setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) + } + if ( + ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && + process.platform === 'win32' + ) { + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + if ('showMenuBarIcon' in updates) { + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + if ('agentStatusHooksEnabled' in updates) { + if (isAgentStatusHooksEnabled(settings)) { + wslHookRelayManager.resumeStoppedRelays() + } else { + wslHookRelayManager.disposeAll({ permanent: false }) + } + } + }) + attachClaudeLivePtyPersistence(store) + onLiveClaudePtysDrained(() => { + void state.rateLimits?.refreshAfterClaudeLivePtysDrained() + }) + const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() + seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + if (persistedClaudePtyIds.length > 0) { + console.log( + `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` + ) + } + applyAppIcon(store.getSettings().appIcon) + if (shouldSuppressDevEducation({ isDev: is.dev })) { + suppressDevEducationForStore(store) + } + try { + const proxyApplyResult = await initialProxyApplication + if (proxyApplyResult.source === 'invalid-settings') { + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } + } catch { + console.warn('[proxy] Failed to apply network proxy settings') + } + setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) + installDocPreviewProtocolHandler() + registerDocPreviewGrantHandlers() + initializeBrowserSessionsForApp({ + orcaProfileId: profile.profile.id, + profileDirectory: profile.profileDirectory, + listLocalSshTargetIds: () => { + const currentStore = state.store + if (!currentStore) { + throw new Error('ssh target store unavailable at partition sweep') + } + return currentStore.getSshTargets().map((target) => target.id) + } + }) + try { + await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) + } catch { + console.warn('[proxy] Failed to apply network proxy settings to browser sessions') + } +} diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts new file mode 100644 index 00000000000..c2e92607f2e --- /dev/null +++ b/src/main/startup/main-process-ready-runtime.ts @@ -0,0 +1,134 @@ +import { app, nativeTheme } from 'electron' +import { randomUUID } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { is } from '@electron-toolkit/utils' +import { StarNagService } from '../star-nag/service' +import { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { EmulatorBridge } from '../emulator/emulator-bridge' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { browserManager } from '../browser/browser-manager' +import { configureBrowserClientPageAutomationRuntime } from '../browser/browser-client-page-automation-runtime' +import { BrowserClientPageCommandError } from '../browser/browser-client-page-command-failure' +import { startPreGoneProcessMetricsSampling } from '../crash-reporting/process-gone-diagnostics' +import { recordProcessGoneCrash } from './main-window-lifecycle-flags' +import { handleGpuChildCrash } from './gpu-lifecycle' +import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { + installManagedAgentHooks, + resolveStartupManagedHookAction, + shouldContinueManagedHookStartup, + shouldInstallStartupManagedAgentHook +} from '../agent-hooks/managed-agent-hook-controls' +import { shouldInstallManagedHooks } from './configure-process' +import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' +import { mainProcessState as state } from './main-process-state' +import { initializeMainProcessObservers } from './main-process-observers' +import { initializeMainProcessAccountServices } from './main-process-account-services' +import { + initializeMainProcessRuntime, + configureRuntimeServices +} from './main-process-runtime-service' +import { initializeMainProcessAutomations } from './main-process-automations' +import { initializeMainProcessPlugins } from './main-process-plugins' +import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeReadyRuntimeServices(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before ready services') + } + initializeMainProcessObservers() + initializeMainProcessAccountServices() + const runtime = initializeMainProcessRuntime() + initializeMainProcessAutomations() + configureRuntimeServices(runtime) + await initializeMainProcessPlugins(runtime) + state.starNag = new StarNagService(store, state.stats!) + state.starNag.start() + state.starNag.registerIpcHandlers() + state.agentBrowserBridge = new AgentBrowserBridge(browserManager, { + onTabsChanged: (worktreeId) => runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + runtime.setAgentBrowserBridge(state.agentBrowserBridge) + void state.agentBrowserBridge.sweepOrphanedSessions() + const browserClientAutomationDispatcher = new RpcDispatcher({ runtime }) + configureBrowserClientPageAutomationRuntime({ + browserManager, + getAgentBrowserBridge: () => state.agentBrowserBridge, + executeRpc: async (method, params, signal) => { + const response = await browserClientAutomationDispatcher.dispatch( + { id: randomUUID(), authToken: 'local-browser-client-automation', method, params }, + { signal } + ) + if (!response.ok) { + throw new BrowserClientPageCommandError(response.error.code) + } + return response.result + } + }) + state.emulatorBridge = new EmulatorBridge() + runtime.setEmulatorBridge(state.emulatorBridge) + // Remove directories left behind by an interrupted worktree deletion. + void sweepStaleWorktreeTrash( + collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) + ).catch((error) => { + console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) + }) + nativeTheme.themeSource = store.getSettings().theme ?? 'system' + const startupManagedHookSettings = store.getSettings() + const shouldReconcileStartupManagedHooks = + shouldInstallManagedHooks(is.dev) && + resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' + const realHomeCodexHookState = + shouldReconcileStartupManagedHooks && + shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && + state.codexRuntimeHome?.isHostSystemDefaultRealHomeSelected() + ? ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: app.getPath('userData') + }).catch((error: unknown) => { + console.warn('[codex-real-home-hooks] startup ensure failed:', error) + }) + : Promise.resolve() + if (shouldReconcileStartupManagedHooks) { + const managedHookStore = store + void realHomeCodexHookState + .then(() => + installManagedAgentHooks(managedHookStore.getSettings(), { + shouldHydrateShellPath: app.isPackaged, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => + shouldContinueManagedHookStartup( + state.isQuitting, + managedHookStore.getSettings(), + agent + ) + }) + ) + .catch((error: unknown) => + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + ) + } + startPreGoneProcessMetricsSampling() + app.on('child-process-gone', (_event, details) => { + recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { + name: details.name, + serviceName: details.serviceName, + type: details.type + }) + if ( + isGpuFallbackCrashCandidate({ + platform: process.platform, + processType: details.type, + reason: details.reason + }) + ) { + const crashedAt = performance.now() + void state.gpuCrashDiagnostics?.record() + void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) + } + }) + logStartupMilestone('services-initialized') +} diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts new file mode 100644 index 00000000000..e6d8d782e6e --- /dev/null +++ b/src/main/startup/main-process-ready.ts @@ -0,0 +1,17 @@ +import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' +import { initializeReadyFoundation } from './main-process-ready-foundation' +import { initializeReadyRuntimeServices } from './main-process-ready-runtime' +import { + initializeMainProcessRuntimeLaunch, + type MainProcessRuntimeLaunchOptions +} from './main-process-runtime-launch' + +/** Runs the ready-phase composition in the same dependency order as the legacy entry point. */ +export async function initializeMainProcessReady( + options: MainProcessRuntimeLaunchOptions +): Promise { + await initializeReadyFoundation() + await initializeReadyRuntimeServices() + await initializeMainProcessI18nAndMenu() + await initializeMainProcessRuntimeLaunch(options) +} diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts new file mode 100644 index 00000000000..64160f3d65c --- /dev/null +++ b/src/main/startup/main-process-runtime-launch.ts @@ -0,0 +1,283 @@ +import { app, powerMonitor, type BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { + getCanonicalUserDataPath, + migrateMobilePairingDataToCanonicalUserDataPath +} from '../persistence' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { registerMobileHandlers } from '../ipc/mobile' +import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty' +import { LocalPtyProvider } from '../providers/local-pty-provider' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' +import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' +import { browserManager } from '../browser/browser-manager' +import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve' +import { + bindTerminalRuntimeStartupServices, + handleCodexHomePtySpawned, + handlePtyExit, + startTerminalRuntimeStartupServices +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' +import { registerServeSignalHandlers } from './serve-signal-handlers' +import { settleServeDesktopActivation } from './serve-desktop-activation' +import { + recordRuntimeRpcStartFailure, + showRuntimeRpcStartupFailureDialog +} from '../runtime/runtime-rpc-startup-failure' +import { CliInstaller } from '../cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' +import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion' +import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration' +import { mainProcessState as state } from './main-process-state' +import { logStartupMilestone } from './startup-diagnostics' + +type RuntimeService = NonNullable + +export type MainProcessRuntimeLaunchOptions = { + openMainWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow + handleMacAppActivation: () => void +} + +function settleDesktopActivation(): void { + const gate = state.desktopActivationGate + if (!gate) { + return + } + settleServeDesktopActivation(gate, { + hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) + }) +} + +function installRuntimeRpc( + runtime: RuntimeService, + serveOptions: ReturnType | null +): OrcaRuntimeRpcServer { + migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) + const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT + const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) + if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { + throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) + } + const devWsPort = is.dev && !isE2E ? 6769 : undefined + const runtimeRpc = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: getCanonicalUserDataPath(), + enableWebSocket: true, + exposeNetworkByDefault: Boolean(serveOptions) || isE2E, + ...(isE2E ? { wsPort: e2eWsPort } : {}), + ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), + ...(serveOptions?.wsPort !== undefined + ? { wsPort: serveOptions.wsPort, preferPinnedWsPort: true } + : {}), + webClientRoot: getBundledWebClientRoot() + }) + state.runtimeRpc = runtimeRpc + registerMobileHandlers(runtimeRpc, { + getRelayStatus: () => state.desktopRelayStatus, + consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { + if ( + !state.mainWindow || + state.mainWindow.isDestroyed() || + state.mainWindow.webContents.id !== webContentsId || + !state.pendingUnpairedDeviceAuthFailure + ) { + return false + } + state.pendingUnpairedDeviceAuthFailure = false + return true + } + }) + runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { + state.pendingUnpairedDeviceAuthFailure = true + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') + } + }) + return runtimeRpc +} + +async function launchServeMode( + runtime: RuntimeService, + runtimeRpc: OrcaRuntimeRpcServer, + serveOptions: NonNullable> +): Promise { + logStartupMilestone('wsl-cli-barrier-start') + await state.managedWslCliStartupBarrierReady + logStartupMilestone('wsl-cli-barrier-resolved', { + reconciliation: state.managedWslCliReconciliationStatus + }) + await state.localPtyStartupReady + await state.localPtyProviderStartupReady + await registerHeadlessPtyRuntime( + runtime, + prepareCodexRuntimeHomeForLaunch, + () => state.store!.getSettings(), + (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + state.store!, + prepareCodexSessionResumeForLaunch, + { onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit } + ) + await runtime.refreshRestoredOrchestrationAuthority() + await runtime.reconcileLegacyWorkerTerminals() + if (state.headlessBrowserDisplayAvailable) { + runtime.setOffscreenBrowserBackend( + new OffscreenBrowserBackend(browserManager, { + getAgentBrowserBridge: () => state.agentBrowserBridge + }) + ) + } + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + await runtimeRpc.start().catch((error) => { + console.error('[runtime] Failed to start headless RPC transport:', error) + throw error + }) + settleDesktopActivation() + registerServeSignalHandlers(process, () => app.quit()) + if (process.platform === 'darwin' || process.platform === 'linux') { + try { + const cliStatus = await new CliInstaller({ + privilegedRunner: async () => { + throw new Error('serve CLI auto-install must not request administrator privileges') + } + }).install() + console.log( + `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` + ) + } catch (error) { + console.warn( + '[serve] orca CLI install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { + try { + const dispatcher = await installLinuxBareOrcaDispatcher({ + resourcesPath: process.resourcesPath + }) + console.log( + `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + + `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` + ) + } catch (error) { + console.warn( + '[serve] bare orca dispatcher install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + state.automations?.start() + scheduleAllPendingHistoryTreeRemovals() + await printServeReady(serveOptions) +} + +async function launchDesktopMode( + runtimeRpc: OrcaRuntimeRpcServer, + shellPathReady: Promise, + desktopWindow: BrowserWindow | null, + openMainWindow: MainProcessRuntimeLaunchOptions['openMainWindow'] +): Promise { + // Preserve the pre-split startup failure contract if composition ever hands + // this phase an incomplete runtime graph. + if (!runtimeRpc) { + throw new Error('runtime_rpc_unavailable') + } + const [win, runtimeRpcStartResult] = await Promise.all([ + Promise.resolve(desktopWindow ?? openMainWindow()), + shellPathReady + .then(() => runtimeRpc.start()) + .then( + () => ({ ok: true as const }), + (error: unknown) => { + recordRuntimeRpcStartFailure(error) + return { ok: false as const, error } + } + ) + ]) + if (!runtimeRpcStartResult.ok) { + void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + } + const cloudAuth = getOrcaCloudAuthConfig() + if (cloudAuth.configured) { + try { + const relayService = new DesktopRelayService({ + authConfig: cloudAuth.config, + userDataPath: getProfileUserDataPath(), + appVersion: app.getVersion(), + runtimeRpc, + onStatus: (status) => { + state.desktopRelayStatus = status + state.mainWindow?.webContents.send('mobile:relayStatusChanged', status) + } + }) + state.desktopRelayService = relayService + runtimeRpc.setMobileRelayPairingProvider({ + createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), + onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + onDemandStateChanged: () => relayService.demandStateChanged(), + getEndpoints: (context, params) => relayService.getEndpoints(context, params), + provisionRelay: (context, params) => relayService.provisionRelay(context, params) + }) + relayService.start() + powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive()) + } catch (error) { + console.warn( + '[relay] Desktop relay startup unavailable:', + error instanceof Error ? error.message : String(error) + ) + } + } + win.once('show', () => { + const store = state.store + if (store && store.getOnboarding().closedAt !== null) { + triggerStartupNotificationRegistration(store) + } + }) +} + +export async function initializeMainProcessRuntimeLaunch( + options: MainProcessRuntimeLaunchOptions +): Promise { + const runtime = state.runtime + const shellPathHydration = state.windowsShellPathHydration + if (!runtime || !shellPathHydration) { + throw new Error('Runtime and shell-path services must be initialized before launch') + } + let serveOptions: ReturnType | null = null + try { + serveOptions = state.isServeMode ? getServeOptions() : null + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + app.exit(1) + return + } + state.serveOptions = serveOptions + const runtimeRpc = installRuntimeRpc(runtime, serveOptions) + const shellPathReady = shellPathHydration.whenReady() + let desktopWindow: BrowserWindow | null = null + if (process.platform === 'win32' && app.isPackaged && !serveOptions) { + const desktopStartup = startWindowsDesktopBeforeShellPathReady({ + bindServices: bindTerminalRuntimeStartupServices, + openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true }), + shellPathReady, + startServices: startTerminalRuntimeStartupServices + }) + desktopWindow = desktopStartup.window + } else { + await shellPathReady + bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) + } + app.on('activate', options.handleMacAppActivation) + if (serveOptions) { + await launchServeMode(runtime, runtimeRpc, serveOptions) + return + } + await launchDesktopMode(runtimeRpc, shellPathReady, desktopWindow, options.openMainWindow) +} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts new file mode 100644 index 00000000000..46e65c8c01f --- /dev/null +++ b/src/main/startup/main-process-runtime-service.ts @@ -0,0 +1,136 @@ +import { app } from 'electron' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import { browserManager } from '../browser/browser-manager' +import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { getDaemonProvider } from '../daemon/daemon-init' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { OrchestrationEnvironmentTransport } from '../runtime/orchestration/environment-transport' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' +import { ArtifactCloudService } from '../artifacts/artifact-cloud-service' +import { SkillCloudService } from '../skills/skill-cloud-service' +import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' + +export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const activation = state.desktopActivationGate + if (!activation) { + return 'available' + } + const value = activation.getState() + return value === 'ready' ? 'openable' : value +} + +export function initializeMainProcessRuntime(): OrcaRuntimeService { + const store = state.store + const stats = state.stats + if (!store || !stats) { + throw new Error('Store and stats must be initialized before runtime') + } + const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { + resolve: (selector) => { + const environment = resolveEnvironment(app.getPath('userData'), selector) + const pairing = getPreferredPairingOffer(environment) + return { + environmentId: environment.id, + name: environment.name, + peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) + } + }, + call: (selector, method, params, timeoutMs, envelope) => + callRuntimeEnvironment( + app.getPath('userData'), + selector, + method, + params, + timeoutMs, + undefined, + envelope + ) + } + const runtime = new OrcaRuntimeService(store, stats, { + agentSessionClaimSigner: loadAgentSessionClaimSigner( + getProfileUserDataPath(), + getProfileUserDataPath() + ), + getLocalProvider: () => getLocalPtyProvider(), + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), + onPtyStopped: clearProviderPtyState, + onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus, + getAgentStatusSnapshot: () => + agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + agentHookServer.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: (paneKey) => + agentHookServer.retirePaneAuthority(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, + getPairedDeviceName: (pairedDeviceId) => + state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, + getAdditionalAiVaultCodexHomePaths: () => + state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: state.codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { + launchAgent: 'codex', + workspacePath + }), + buildAgentHookPtyEnv: () => + isAgentStatusHooksEnabled(state.store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + orchestrationEnvironmentTransport, + skillTransactionRecovery: state.skillTransactionRecovery + }) + state.runtime = runtime + runtime.prepareLegacyWorkerTerminalRecovery() + runtime.rehydrateClientHostedBrowserPages() + state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + return runtime +} + +export function configureRuntimeServices(runtime: OrcaRuntimeService): void { + const store = state.store + const claudeAccounts = state.claudeAccounts + const codexAccounts = state.codexAccounts + const rateLimits = state.rateLimits + if (!store || !claudeAccounts || !codexAccounts || !rateLimits) { + throw new Error('Account services must be initialized before runtime wiring') + } + runtime.setArtifactService( + new ArtifactCloudService(app.getPath('userData'), () => + isArtifactSharingEnabled(state.store?.getSettings()) + ) + ) + runtime.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) + runtime.setCommitMessageAgentEnvironmentResolvers({ + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target) + }) +} diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts new file mode 100644 index 00000000000..b38d7b3ac62 --- /dev/null +++ b/src/main/startup/main-process-serve.ts @@ -0,0 +1,131 @@ +import { existsSync, statSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import { app } from 'electron' +import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' +import { notifyServeSupervisorReady } from '../serve-update-handoff' +import { mainProcessState as state } from './main-process-state' + +export type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null +} + +export function getServeOptions(argv = process.argv): ServeOptions { + const valueAfter = (flag: string): string | null => { + const index = argv.indexOf(flag) + if (index === -1) { + return null + } + const value = argv[index + 1] + return value && !value.startsWith('--') ? value : null + } + const rawPort = valueAfter('--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + return { + json: argv.includes('--serve-json'), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter('--serve-pairing-address'), + noPairing: argv.includes('--serve-no-pairing'), + mobilePairing: argv.includes('--serve-mobile-pairing'), + recipeJson: argv.includes('--serve-recipe-json'), + projectRoot: valueAfter('--serve-project-root') + } +} + +export function getBundledWebClientRoot(): string | undefined { + const appPath = app.getAppPath() + const roots = [ + join(appPath, 'out', 'web'), + // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. + join(appPath, '..', 'web') + ] + return roots.find((root) => existsSync(join(root, 'web-index.html'))) +} + +async function renderTerminalPairingQr(pairingUrl: string): Promise { + // Why dynamic: qrcode is only reachable from mobile pairing, so launch should + // not parse it for the majority who never pair a device. + const QRCode = await import('qrcode') + try { + return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) + } catch { + try { + return await QRCode.toString(pairingUrl, { type: 'utf8' }) + } catch { + return null + } + } +} + +export async function printServeReady(options: ServeOptions): Promise { + const runtime = state.runtime + const runtimeRpc = state.runtimeRpc + if (!runtime || !runtimeRpc) { + throw new Error('Runtime server must be initialized before printing serve readiness') + } + if (options.recipeJson) { + if (!options.projectRoot) { + throw new Error('--serve-recipe-json requires --serve-project-root') + } + if (!isAbsolute(options.projectRoot)) { + throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) + } + if (!statSync(options.projectRoot).isDirectory()) { + throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) + } + } + const boundEndpoint = runtimeRpc.getWebSocketEndpoint() + const advertised = boundEndpoint + ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + : null + const pairing = options.noPairing + ? ({ + available: false, + reason: 'disabled_by_operator', + guidance: 'Restart without --no-pairing to create a client pairing offer.' + } as const) + : runtimeRpc.createPairingOffer({ + address: options.pairingAddress, + name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, + scope: options.mobilePairing ? 'mobile' : 'runtime' + }) + const pairingQr = + pairing.available && options.mobilePairing + ? await renderTerminalPairingQr(pairing.pairingUrl) + : null + await state.serveReadinessPublisher.publish( + { + runtimeId: runtime.getRuntimeId(), + boundEndpoint, + advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + managedWslCliReconciliation: state.managedWslCliReconciliationStatus, + pairing: pairing.available + ? { + available: true, + url: pairing.pairingUrl, + endpoint: pairing.endpoint, + deviceId: pairing.deviceId, + webClientUrl: pairing.webClientUrl, + scope: options.mobilePairing ? 'mobile' : 'runtime', + qr: pairingQr + } + : pairing + }, + options.recipeJson + ? { mode: 'recipe-json', projectRoot: options.projectRoot! } + : { mode: options.json ? 'json' : 'human' } + ) + notifyServeSupervisorReady(runtime.getRuntimeId()) +} diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts new file mode 100644 index 00000000000..330f690d268 --- /dev/null +++ b/src/main/startup/main-process-state.ts @@ -0,0 +1,127 @@ +import type { BrowserWindow, Tray } from 'electron' +import { app } from 'electron' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { CodexAccountService } from '../codex-accounts/service' +import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RateLimitService } from '../rate-limits/service' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import type { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import type { StarNagService } from '../star-nag/service' +import type { AgentAwakeService } from '../agent-awake-service' +import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import type { AutomationService } from '../automations/service' +import type { PluginService } from '../plugins/plugin-service' +import type { PluginKillListService } from '../plugins/plugin-kill-list-service' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { KeybindingService } from '../keybindings/keybinding-service' +import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import type { EmulatorBridge } from '../emulator/emulator-bridge' +import type { GpuFallbackMarker, GpuFallbackEnvironment } from './gpu-fallback-marker' +import type { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import type { getDevInstanceIdentity } from './dev-instance-identity' +import type { createServeDesktopActivationGate } from './serve-desktop-activation' +import type { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import type { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import type { ServeOptions } from './main-process-serve' +import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' +import { ServeReadinessPublisher } from '../server/serve-readiness' +import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { + DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, + DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + GpuCrashFallbackTracker +} from '../crash-reporting/gpu-crash-fallback-decision' +import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { createWebContentsTimedFlag } from './web-contents-timed-flag' + +/** Mutable composition-root state shared by startup, window, serve, and quit phases. */ +export const mainProcessState = { + mainWindow: null as BrowserWindow | null, + isQuitting: false, + store: null as Store | null, + stats: null as StatsCollector | null, + claudeUsage: null as ClaudeUsageStore | null, + codexUsage: null as CodexUsageStore | null, + openCodeUsage: null as OpenCodeUsageStore | null, + codexAccounts: null as CodexAccountService | null, + codexRuntimeHome: null as CodexRuntimeHomeService | null, + codexSessionMigration: null as ReturnType | null, + claudeAccounts: null as ClaudeAccountService | null, + claudeRuntimeAuth: null as ClaudeRuntimeAuthService | null, + runtime: null as OrcaRuntimeService | null, + rateLimits: null as RateLimitService | null, + runtimeRpc: null as OrcaRuntimeRpcServer | null, + serveReadinessPublisher: new ServeReadinessPublisher(), + desktopRelayService: null as DesktopRelayService | null, + desktopRelayStatus: 'offline' as RelayBrokerStatus, + pendingUnpairedDeviceAuthFailure: false, + headlessBrowserDisplayAvailable: false, + starNag: null as StarNagService | null, + agentAwakeService: null as AgentAwakeService | null, + crashReports: null as CrashReportStore | null, + unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, + publishProviderSessionChanges: null as + | ((identities: AgentHookProviderSessionIdentity[]) => void) + | null, + unsubscribeSystemResumeBroadcast: null as (() => void) | null, + watcherShutdownPromise: null as Promise | null, + watcherShutdownDone: false, + automations: null as AutomationService | null, + pluginService: null as PluginService | null, + pluginKillListService: null as PluginKillListService | null, + pluginMarketplaceService: null as PluginMarketplaceService | null, + pluginMarketplaceInstaller: null as PluginMarketplaceInstaller | null, + keybindings: null as KeybindingService | null, + expectedRendererReload: createWebContentsTimedFlag(), + recoveryReloadInFlight: createWebContentsTimedFlag(), + pendingOpenSettings: createWebContentsTimedFlag(), + skillShareDeepLinks: new SkillShareDeepLinkState(), + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliReconciliationReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + managedWslCliReconciliationStatus: 'settled' as 'pending' | 'settled' | 'failed', + gpuCrashFallbackTracker: new GpuCrashFallbackTracker({ + windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD + }), + activeGpuFallbackMarker: null as GpuFallbackMarker | null, + gpuFallbackActiveThisLaunch: false, + gpuFeatureStatus: null as Electron.GPUFeatureStatus | null, + gpuCrashDiagnostics: null as GpuCrashDiagnosticsRecorder | null, + localPtyStartupReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve(), + isServeMode: false, + devInstanceIdentity: null as ReturnType | null, + devAgentHookEndpointNamespace: undefined as string | undefined, + startupDiagnosticsEnabled: false, + desktopActivationGate: null as ReturnType | null, + activeOrcaProfile: null as ReturnType | null, + windowsShellPathHydration: null as ReturnType | null, + shellPathReady: Promise.resolve(), + hangDetection: null as HangDetectionMarker | null, + skillTransactionRecovery: Promise.resolve() as Promise, + serveOptions: null as ServeOptions | null, + desktopWindow: null as BrowserWindow | null, + agentBrowserBridge: null as AgentBrowserBridge | null, + emulatorBridge: null as EmulatorBridge | null, + tray: null as Tray | null +} + +/** Environment passed to GPU fallback marker helpers. */ +export function gpuFallbackEnvironment(): GpuFallbackEnvironment { + return { + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? '', + platform: process.platform + } +} diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts new file mode 100644 index 00000000000..c1009d22baa --- /dev/null +++ b/src/main/startup/main-window-actions.ts @@ -0,0 +1,169 @@ +import { app, clipboard, dialog, type BrowserWindow, type Tray } from 'electron' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { isQuittingForUpdate } from '../updater' +import { + createSystemTray, + setMacMenuBarIconVisible, + type SystemTrayOptions +} from '../tray/system-tray' +import { checkForUpdatesFromMenu } from '../updater' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' +import { mainProcessState as state } from './main-process-state' +import { loadMainWindow } from '../window/createMainWindow' +import { describeInstallDirAclPoison } from './windows-install-dir-acl-recovery' +import { presentRendererRecoveryPrompt } from '../window/renderer-recovery-prompt' + +// The window module injects this callback to avoid a cycle between actions and lifecycle code. +let openWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +export function setMainWindowOpener( + opener: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +): void { + openWindow = opener +} + +export function focusExistingWindow(): void { + focusExistingMainWindow({ + app, + getWindow: () => state.mainWindow, + openWindow, + warn: console.warn + }) +} + +export function showMainWindowFromTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + safelyRevealWindow(state.mainWindow) + return + } + if (!isQuittingForUpdate()) { + openWindow() + } +} + +export function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + targetWindow.webContents.send('ui:openSettings') + state.pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +export function quitFromSystemTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + showMainWindowFromTray() + } + state.isQuitting = true + app.quit() +} + +export function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +export function getSystemTrayOptions(): SystemTrayOptions | null { + const store = state.store + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + isDevInstance: state.devInstanceIdentity?.isDev ?? false, + devInstanceLabel: state.devInstanceIdentity?.devLabel ?? null, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +export function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || state.isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + +export function createSystemTrayDeferred( + window: BrowserWindow, + onCreated?: () => void +): () => void { + let trayCreated = false + return () => { + if (trayCreated || window.isDestroyed() || state.isQuitting || !state.store) { + return + } + trayCreated = true + if (process.platform === 'darwin') { + if (syncMacMenuBarIcon(state.store.getSettings().showMenuBarIcon !== false)) { + onCreated?.() + } + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + onCreated?.() + } + } +} + +export function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openFeatureTour') +} + +export function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openSetupGuide') +} + +export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openCrashReport') +} + +// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. +export async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise { + await presentRendererRecoveryPrompt({ + recentRecoveryCount, + isQuitting: () => state.isQuitting, + diagnose: describeInstallDirAclPoison, + showMessageBox: (options) => { + const window = + state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options) + }, + copyToClipboard: (text) => clipboard.writeText(text), + reload: () => { + if (!state.mainWindow || state.mainWindow.isDestroyed()) { + return + } + recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') + // Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop. + loadMainWindow(state.mainWindow) + }, + quit: () => { + state.isQuitting = true + app.quit() + } + }) +} diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts new file mode 100644 index 00000000000..629c0550581 --- /dev/null +++ b/src/main/startup/main-window-agent-status.ts @@ -0,0 +1,138 @@ +import type { BrowserWindow } from 'electron' +import { agentHookServer } from '../agent-hooks/server' +import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-unsupported-pty-state' +import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' +import { isAskUserQuestionTool } from '../../shared/agent-question-answered-intent' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook +} from '../../shared/synthetic-agent-title' +import { + driveSyntheticTitleFromHook, + shouldSuppressCodexAutoApprovalSyntheticTitleFromHook, + stopAllSyntheticTitleSpinners +} from './synthetic-title-runtime' +import { mainProcessState as state } from './main-process-state' + +export type MainWindowAgentStatusOptions = { + window: BrowserWindow + maybeAutoRenameBranchOnFirstWork: (event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined + }) => void + onRecordAgentState: (agentType: string, status: string) => void +} + +export function installMainWindowAgentStatusListeners(options: MainWindowAgentStatusOptions): void { + agentHookServer.setListener( + ({ + paneKey, + tabId, + worktreeId, + connectionId, + payload, + receivedAt, + stateStartedAt, + launchToken, + providerSession, + providerSessionOnly, + promptInteractionKey, + restoredUnconfirmed, + observation, + isReplay + }) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (providerSessionOnly) { + state.mainWindow?.webContents.send('agentStatus:set', { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(observation ? { observation } : {}), + providerSessionOnly: true + }) + return + } + if (!restoredUnconfirmed) { + options.maybeAutoRenameBranchOnFirstWork({ paneKey, tabId, worktreeId, payload, isReplay }) + } + const runtime = state.runtime + const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) + const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + const statusEvent = { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + ...(terminalHandle ? { terminalHandle } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(observation ? { observation } : {}), + ...(orchestration ? { orchestration } : {}) + } + state.mainWindow?.webContents.send('agentStatus:set', statusEvent) + if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { + getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) + } + options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) + const profile = getSyntheticAgentTitleProfile(payload.agentType) + if ( + profile && + shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && + !suppressSyntheticCodexAutoApprovalTitle + ) { + driveSyntheticTitleFromHook(paneKey, payload.state, profile) + } + } + ) + agentHookServer.setPaneStatusClearListener((clear) => { + if (state.mainWindow?.isDestroyed()) { + return + } + state.mainWindow?.webContents.send('agentStatus:clear', clear) + getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) + }) + setMigrationUnsupportedPtyListener((event) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (event.type === 'set') { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) + } else { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { + ptyId: event.ptyId + }) + } + }) +} + +export function clearMainWindowAgentStatusListeners(): void { + agentHookServer.setListener(null) + agentHookServer.setPaneStatusClearListener(null) + setMigrationUnsupportedPtyListener(null) + stopAllSyntheticTitleSpinners() +} diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts new file mode 100644 index 00000000000..177f4231a65 --- /dev/null +++ b/src/main/startup/main-window-controller.ts @@ -0,0 +1,197 @@ +import { app, type BrowserWindow } from 'electron' +import { createMainWindow, loadMainWindow } from '../window/createMainWindow' +import { + recordCrashBreadcrumb, + recordCoalescedCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { resolveConsent } from '../telemetry/consent' +import { trackAppOpenedOnce } from '../telemetry/client' +import { ensureWindowsUserDataAclGrant } from './windows-user-data-acl' +import { probeWindowsInstallDirAcl } from './windows-install-dir-acl-probe' +import { startWindowsInstallDirAclRepairIfPoisoned } from './windows-install-dir-acl-recovery' +import { logStartupMilestone } from './startup-diagnostics' +import { notifyMainWindowBecameVisible } from '../window/main-window-visibility' +import { setTrayAttention } from '../tray/system-tray' +import { + createSystemTrayDeferred, + getSystemTrayOptions, + showMainWindowFromTray, + syncMacMenuBarIcon +} from './main-window-actions' +import { attachMainWindowCoreServices } from './main-window-core-services' +import { + clearMainWindowAgentStatusListeners, + installMainWindowAgentStatusListeners +} from './main-window-agent-status' +import { mainProcessState as state } from './main-process-state' +import { + clearExpectedRendererReload, + markExpectedRendererReload, + markRecoveryReloadInFlight, + getExpectedTeardownScope, + recordProcessGoneCrash +} from './main-window-lifecycle-flags' +import { showRendererRecoveryPrompt } from './main-window-actions' +import { presentGpuFallbackRecoveredLaunchPrompt } from './gpu-lifecycle' +import { maybeAutoRenameBranchOnFirstWorkFromHook } from './branch-rename-hook' +import { + resumeSyntheticTitleSpinnerTimer, + stopSyntheticTitleSpinnerTimer +} from './synthetic-title-runtime' +import { requireMainWindowServices } from './main-window-service-readiness' + +const TRAY_CREATE_FALLBACK_MS = 12_000 +const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + logStartupMilestone('open-main-window-start') + const { store, keybindings } = requireMainWindowServices({ + store: state.store, + runtime: state.runtime, + stats: state.stats, + claudeUsage: state.claudeUsage, + codexUsage: state.codexUsage, + openCodeUsage: state.openCodeUsage, + rateLimits: state.rateLimits, + automations: state.automations, + codexAccounts: state.codexAccounts, + codexRuntimeHome: state.codexRuntimeHome, + claudeAccounts: state.claudeAccounts, + claudeRuntimeAuth: state.claudeRuntimeAuth, + keybindings: state.keybindings + }) + if (process.platform === 'win32') { + logStartupMilestone('acl-grant-start') + ensureWindowsUserDataAclGrant(app.getPath('userData'), { + onDone: (result) => { + logStartupMilestone('acl-grant-done', { mode: result.mode }) + if (result.mode === 'failed') { + console.warn('[win32-acl] userData ACL grant failed:', result.reason) + } + } + }) + // Why here: read-only, and the install DACL is the one thing a 0x80000003 + // child death cannot tell us about itself. See electron/electron#51761. + probeWindowsInstallDirAcl({ + isServeMode: state.isServeMode, + onDone: (data) => + startWindowsInstallDirAclRepairIfPoisoned(data, { + isServeMode: state.isServeMode, + userDataPath: app.getPath('userData'), + appVersion: app.getVersion() + }) + }) + } + const window = createMainWindow(store, { + getIsQuitting: () => state.isQuitting, + onQuitAborted: () => { + state.isQuitting = false + clearExpectedRendererReload() + }, + onRendererProcessGone: (details, webContentsId) => + recordProcessGoneCrash( + 'renderer', + 'renderer', + details.reason, + details.exitCode ?? null, + { processType: 'renderer' }, + webContentsId + ), + shouldRecoverRenderer: (details, webContentsId) => + shouldRecoverRendererAfterProcessGone({ + reason: details.reason, + expectedTeardown: getExpectedTeardownScope(webContentsId, false) + }), + onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { + recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { + reason: details.reason, + exitCode: details.exitCode ?? null, + recentRecoveryCount + }) + void showRendererRecoveryPrompt(recentRecoveryCount) + }, + deferLoad: true, + ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), + title: state.devInstanceIdentity?.name ?? app.name, + getKeybindings: () => keybindings.getOverrides(), + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + markExpectedRendererReload(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onBeforeRecoveryReload: (webContentsId) => { + markRecoveryReloadInFlight(webContentsId) + recordDurableCrashBreadcrumb('renderer_recovery_reload') + } + }) + recordCrashBreadcrumb('main_window_created') + logStartupMilestone('window-created') + const createTray = createSystemTrayDeferred(window, () => logStartupMilestone('tray-created')) + window.once('ready-to-show', () => { + logStartupMilestone('ready-to-show') + setImmediate(createTray) + }) + window.once('show', () => { + logStartupMilestone('window-shown') + void presentGpuFallbackRecoveredLaunchPrompt(window) + }) + const trayCreateFallback = setTimeout(createTray, TRAY_CREATE_FALLBACK_MS) + trayCreateFallback.unref?.() + const rendererWebContentsId = window.webContents.id + const onFirstWindowLoad = (): void => { + clearExpectedRendererReload(rendererWebContentsId) + recordCrashBreadcrumb('main_window_loaded') + logStartupMilestone('did-finish-load') + const currentStore = state.store + if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') { + trackAppOpenedOnce() + } + } + window.webContents.on('did-finish-load', onFirstWindowLoad) + attachMainWindowCoreServices(window, { + markExpectedRendererReload, + recordRendererReload: (ignoreCache) => + recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) + }) + state.mainWindow = window + window.on('show', resumeSyntheticTitleSpinnerTimer) + window.on('restore', resumeSyntheticTitleSpinnerTimer) + window.on('hide', stopSyntheticTitleSpinnerTimer) + window.on('minimize', stopSyntheticTitleSpinnerTimer) + window.on('show', notifyMainWindowBecameVisible) + window.on('restore', notifyMainWindowBecameVisible) + window.on('show', () => setTrayAttention(false)) + window.on('restore', () => setTrayAttention(false)) + installMainWindowAgentStatusListeners({ + window, + maybeAutoRenameBranchOnFirstWork: maybeAutoRenameBranchOnFirstWorkFromHook, + onRecordAgentState: (agentType, status) => + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType, state: status }, + coalesceKey: `agent:${agentType}:${status}`, + minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS + }) + }) + window.on('closed', () => { + if (state.mainWindow === window) { + state.mainWindow = null + } + clearExpectedRendererReload(rendererWebContentsId) + state.automations?.setWebContents(null) + clearMainWindowAgentStatusListeners() + }) + logStartupMilestone('load-start') + loadMainWindow(window) + return window +} + +export function configureWindowActions(): void { + // Kept as a named seam for startup composition; action callbacks are state-backed. + void getSystemTrayOptions + void showMainWindowFromTray + void syncMacMenuBarIcon +} diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts new file mode 100644 index 00000000000..f6f51e221ec --- /dev/null +++ b/src/main/startup/main-window-core-services.ts @@ -0,0 +1,130 @@ +import type { BrowserWindow } from 'electron' +import { registerCoreHandlers } from '../ipc/register-core-handlers/register-core-handlers' +import { attachMainWindowServices } from '../window/attach-main-window-services' +import { initTccPromptNotice } from '../macos-tcc-prompt-notice' +import { resolveUpdateInstallMode } from '../updater' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { preserveAgentAuthBeforeRestart } from '../agent-auth-restart-preservation' +import { + emitPluginWorktreeLifecycle, + handleCodexHomePtySpawned, + handlePtyExit +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags' + +export function attachMainWindowCoreServices( + window: BrowserWindow, + deps: { + markExpectedRendererReload: (webContentsId: number) => void + recordRendererReload: (ignoreCache: boolean) => void + } +): void { + const store = state.store + const runtime = state.runtime + const stats = state.stats + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + const openCodeUsage = state.openCodeUsage + const codexAccounts = state.codexAccounts + const claudeAccounts = state.claudeAccounts + const rateLimits = state.rateLimits + const automations = state.automations + const keybindings = state.keybindings + const codexRuntimeHome = state.codexRuntimeHome + const claudeRuntimeAuth = state.claudeRuntimeAuth + if ( + !store || + !runtime || + !stats || + !claudeUsage || + !codexUsage || + !openCodeUsage || + !codexAccounts || + !claudeAccounts || + !rateLimits || + !automations || + !keybindings || + !codexRuntimeHome || + !claudeRuntimeAuth + ) { + throw new Error('Main window services must be initialized before attaching') + } + registerCoreHandlers( + store, + runtime, + stats, + claudeUsage, + codexUsage, + openCodeUsage, + codexAccounts, + claudeAccounts, + rateLimits, + window.webContents.id, + automations, + { + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target) + }, + state.agentAwakeService ?? undefined, + state.crashReports ?? undefined, + keybindings, + { + getAdditionalAiVaultCodexHomePaths: () => + codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery(), + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + onBeforeRelaunch: async () => { + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome, + claudeRuntimeAuth, + store + }) + }, + onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), + onBeforeOrcaProfileSignOut: () => state.desktopRelayService?.fenceAndCloseNow() + }, + state.pluginService ?? undefined, + state.pluginMarketplaceService && state.pluginMarketplaceInstaller + ? { marketplace: state.pluginMarketplaceService, installer: state.pluginMarketplaceInstaller } + : undefined + ) + automations.setWebContents(window.webContents) + automations.start() + attachMainWindowServices( + window, + store, + runtime, + prepareCodexRuntimeHomeForLaunch, + (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + { + prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, + awaitLocalPtyStartup: () => state.localPtyStartupReady, + awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady, + onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { + if (window.webContents.id === webContentsId) { + deps.markExpectedRendererReload(webContentsId) + } + deps.recordRendererReload(ignoreCache) + }, + isRecoveryReloadInFlight, + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, + onBeforeUpdateQuit: () => + preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + updateInstallMode: resolveUpdateInstallMode(state.isServeMode), + onWorktreeLifecycle: emitPluginWorktreeLifecycle + } + ) + initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) + rateLimits.attach(window) + rateLimits.start({ fetchImmediately: false }) +} diff --git a/src/main/startup/main-window-lifecycle-flags.ts b/src/main/startup/main-window-lifecycle-flags.ts new file mode 100644 index 00000000000..2a4964c8f4e --- /dev/null +++ b/src/main/startup/main-window-lifecycle-flags.ts @@ -0,0 +1,53 @@ +import { resolveExpectedTeardownScope } from '../crash-reporting/expected-teardown-state' +import type { ExpectedTeardownScope } from '../crash-reporting/process-gone-classification' +import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from '../crash-reporting/process-gone-recorder' +import { isQuittingForUpdate } from '../updater' +import { mainProcessState as state } from './main-process-state' + +export function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { + state.expectedRendererReload.mark(webContentsId, durationMs) +} + +export function clearExpectedRendererReload(webContentsId?: number): void { + state.expectedRendererReload.clear(webContentsId) +} + +export function getExpectedTeardownScope( + webContentsId?: number, + includeSystemSessionEnd = true +): ExpectedTeardownScope { + return resolveExpectedTeardownScope({ + isQuitting: state.isQuitting, + isQuittingForUpdate: isQuittingForUpdate(), + isExpectedRendererReload: + webContentsId !== undefined && state.expectedRendererReload.matches(webContentsId), + includeSystemSessionEnd + }) +} + +export function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { + state.recoveryReloadInFlight.mark(webContentsId, durationMs) +} + +export function isRecoveryReloadInFlight(webContentsId: number): boolean { + return state.recoveryReloadInFlight.matches(webContentsId, { consume: true }) +} + +export function recordProcessGoneCrash( + source: 'renderer' | 'child', + processType: string, + reason: string, + exitCode: number | null, + details: Record, + webContentsId?: number +): void { + recordProcessGoneCrashEvent(state.crashReports, { + source, + processType, + reason, + exitCode, + expectedTeardown: getExpectedTeardownScope(webContentsId), + details, + ...(webContentsId !== undefined ? { webContentsId } : {}) + }) +} diff --git a/src/main/startup/main-window-service-readiness.ts b/src/main/startup/main-window-service-readiness.ts new file mode 100644 index 00000000000..2adfd612ba7 --- /dev/null +++ b/src/main/startup/main-window-service-readiness.ts @@ -0,0 +1,42 @@ +/** + * Keep startup diagnostics specific and ordered. These messages are useful + * when a partial bootstrap opens a window and are part of the existing contract. + */ +const MAIN_WINDOW_SERVICE_REQUIREMENTS = [ + ['store', 'Store must be initialized before opening the main window'], + ['runtime', 'Runtime must be initialized before opening the main window'], + ['stats', 'Stats must be initialized before opening the main window'], + ['claudeUsage', 'Claude usage store must be initialized before opening the main window'], + ['codexUsage', 'Codex usage store must be initialized before opening the main window'], + ['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'], + ['rateLimits', 'Rate limit service must be initialized before opening the main window'], + ['automations', 'Automation service must be initialized before opening the main window'], + ['codexAccounts', 'Codex account service must be initialized before opening the main window'], + [ + 'codexRuntimeHome', + 'Codex runtime home service must be initialized before opening the main window' + ], + ['claudeAccounts', 'Claude account service must be initialized before opening the main window'], + [ + 'claudeRuntimeAuth', + 'Claude runtime auth service must be initialized before opening the main window' + ], + ['keybindings', 'Keybinding service must be initialized before opening the main window'] +] as const + +type MainWindowServiceKey = (typeof MAIN_WINDOW_SERVICE_REQUIREMENTS)[number][0] + +type RequiredServices> = { + [K in keyof T]: NonNullable +} + +export function requireMainWindowServices>( + services: T +): RequiredServices { + for (const [key, message] of MAIN_WINDOW_SERVICE_REQUIREMENTS) { + if (!services[key]) { + throw new Error(message) + } + } + return services as RequiredServices +} diff --git a/src/main/startup/secret-protection-report-deferral-wiring.test.ts b/src/main/startup/secret-protection-report-deferral-wiring.test.ts index eda9721667f..2d97c51d7b0 100644 --- a/src/main/startup/secret-protection-report-deferral-wiring.test.ts +++ b/src/main/startup/secret-protection-report-deferral-wiring.test.ts @@ -13,18 +13,22 @@ import { describe, expect, it } from 'vitest' * as a dead host); `false` puts the blocking probe back in front of the window. Deleting the * call entirely restores the original regression. * - * Source-level because that is the property: this runs once inside `app.whenReady()` during - * startup, so there is no seam to assert against at runtime. + * Source-level because that is the property: this runs once during the ready-phase foundation, + * so there is no seam to assert against at runtime. */ describe('secret protection report deferral wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const SCHEDULE = 'scheduleSecretProtectionGapReport({' it('arms the deferred report exactly once and never calls the blocking one directly', () => { expect(source.split(SCHEDULE).length - 1, `${SCHEDULE} should appear exactly once`).toBe(1) expect(source).toContain( - "import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report'" + "import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report'" ) // Why also assert the absence: re-importing the blocking entry point reinstates the // pre-window probe without touching the call site the next test pins. Note the scheduling @@ -48,26 +52,27 @@ describe('secret protection report deferral wiring', () => { // Why anchor the indent: `SCHEDULE` matches anywhere, including as the body of an added // `if (...) schedule(...)` guard, which leaves every assertion here true while the call // stops running unconditionally. Pinning it as a statement at whenReady's own indent is - // what makes "this runs on every desktop startup" the thing under test. + // what makes "this runs on every startup" the thing under test. expect(source).toContain(`\n ${SCHEDULE}`) - expect(call).toContain('deferUntilFirstWindow: !isServeMode') + expect(call).toContain('deferUntilFirstWindow: !state.isServeMode') // Why assert the constants are absent too: `!isServeMode` being present does not stop a // later property in the same literal from overriding it. expect(call).not.toContain('deferUntilFirstWindow: true') expect(call).not.toContain('deferUntilFirstWindow: false') }) - it('arms the report after the profile exists and inside app readiness', () => { + it('arms the report after the profile exists during app readiness', () => { // Why: the report remembers what it last said beside the profile data file, so arming it // before the profile is resolved would key the state off a path that does not exist yet. // Anchored on code, never a comment — a reworded comment silently becomes -1. - const ready = source.indexOf('app.whenReady().then(') - const profile = source.indexOf('const activeOrcaProfile = ensureActiveOrcaProfile()') + const ready = entrySource.indexOf('void app.whenReady().then(async () => {') + const profile = source.indexOf('const profile = ensureActiveOrcaProfile()') const schedule = source.indexOf(SCHEDULE) expect(ready).toBeGreaterThanOrEqual(0) - expect(profile).toBeGreaterThan(ready) + expect(profile).toBeGreaterThanOrEqual(0) expect(schedule).toBeGreaterThan(profile) + expect(entrySource.indexOf('initializeMainProcessReady({')).toBeGreaterThan(ready) }) }) diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index 0aea295c657..64d628c51ff 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -3,59 +3,84 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' describe('serve desktop activation wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const runtimeServiceSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-service.ts'), + 'utf8' + ) + const windowCoreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) it('routes second-instance and windowless app activation through one safety gate', () => { - expect(source).toContain('createServeDesktopActivationGate({') - expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') - expect(source).toContain('createMacAppActivationHandler({') - expect(source).toContain("app.on('activate', handleMacAppActivation)") - expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + expect(preflightSource).toContain('createServeDesktopActivationGate({') + expect(preflightSource).toContain( + 'acquireSingleInstanceLock(app, options.requestDesktopActivation)' + ) + expect(entrySource).toContain('createMacAppActivationHandler({') + expect(runtimeSource).toContain("app.on('activate', options.handleMacAppActivation)") + expect(runtimeServiceSource).toContain('getDesktopWindowStatus,') }) it('settles the persistent provider before headless PTY registration', () => { - const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') - const startupIndex = source.indexOf( - 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))', - appReadyIndex + const startupIndex = runtimeSource.indexOf( + 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))' ) - const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) - const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) - const providerReadyIndex = source.indexOf('await localPtyProviderStartupReady', serveIndex) - const headlessRegistrationIndex = source.indexOf( + const serveLaunchIndex = runtimeSource.indexOf('async function launchServeMode(') + const serveDispatchIndex = runtimeSource.indexOf(' if (serveOptions) {', startupIndex) + const ptyReadyIndex = runtimeSource.indexOf( + 'await state.localPtyStartupReady', + serveLaunchIndex + ) + const providerReadyIndex = runtimeSource.indexOf( + 'await state.localPtyProviderStartupReady', + serveLaunchIndex + ) + const headlessRegistrationIndex = runtimeSource.indexOf( 'await registerHeadlessPtyRuntime(', - serveIndex + serveLaunchIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveLaunchIndex) expect(startupIndex).toBeGreaterThanOrEqual(0) - expect(startupIndex).toBeLessThan(serveIndex) - expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(serveDispatchIndex).toBeGreaterThan(startupIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveLaunchIndex) expect(providerReadyIndex).toBeGreaterThan(ptyReadyIndex) expect(headlessRegistrationIndex).toBeGreaterThan(providerReadyIndex) expect(headlessRegistrationIndex).toBeLessThan(rpcIndex) - expect(source).not.toContain( + expect(runtimeSource).not.toContain( 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' ) }) it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { - const serveIndex = source.indexOf('if (serveOptions) {') - const sentinelIndex = source.indexOf( + const serveIndex = runtimeSource.indexOf('async function launchServeMode(') + const sentinelIndex = runtimeSource.indexOf( 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', serveIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) - const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = runtimeSource.indexOf('settleDesktopActivation()', rpcIndex) expect(serveIndex).toBeGreaterThanOrEqual(0) expect(sentinelIndex).toBeGreaterThan(serveIndex) expect(rpcIndex).toBeGreaterThan(sentinelIndex) expect(settleIndex).toBeGreaterThan(rpcIndex) - expect(source).not.toContain('runtime.syncWindowGraph(0,') + expect(runtimeSource).not.toContain('runtime.syncWindowGraph(0,') }) it('keeps the headless install policy after desktop promotion', () => { - expect(source).toContain('updateInstallMode: resolveUpdateInstallMode(isServeMode)') + expect(windowCoreSource).toContain( + 'updateInstallMode: resolveUpdateInstallMode(state.isServeMode)' + ) }) }) diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts index be6bf76e625..3455c8c59ab 100644 --- a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -49,14 +49,17 @@ describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) }) - // Why source text: the ordering only exists as statement order at index.ts module scope, and the + // Why source text: the ordering is the preflight phase's executable statement order, and the // cases above stay green if it is reversed — nothing else would catch the regression. - it('keeps index.ts running both CLI redirects before the argv rewrite', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + it('keeps the preflight running both CLI redirects before the argv rewrite', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') - const serveModeCheck = source.indexOf("const isServeMode = process.argv.includes('--serve')") + const serveModeCheck = source.indexOf("state.isServeMode = process.argv.includes('--serve')") expect(packagedRedirect).toBeGreaterThanOrEqual(0) expect(appImageRedirect).toBeGreaterThanOrEqual(0) diff --git a/src/main/startup/single-instance-lock-exit.electron.test.ts b/src/main/startup/single-instance-lock-exit.electron.test.ts index 7cf61a4af0f..15623c2459f 100644 --- a/src/main/startup/single-instance-lock-exit.electron.test.ts +++ b/src/main/startup/single-instance-lock-exit.electron.test.ts @@ -34,10 +34,13 @@ afterAll(() => { /** The `app.*` call the shipped lock-loss gate executes, so a revert to `app.quit()` fails here. */ function readLockLossTermination(): string { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = source.indexOf('if (!hasSingleInstanceLock) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const start = source.indexOf('if (!hasLock) {') expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('\n}', start) + const end = source.indexOf('\n }', start) expect(end).toBeGreaterThan(start) return source diff --git a/src/main/startup/single-instance-lock-headless-exit.test.ts b/src/main/startup/single-instance-lock-headless-exit.test.ts index 3c9468f595e..f79afa3f5ba 100644 --- a/src/main/startup/single-instance-lock-headless-exit.test.ts +++ b/src/main/startup/single-instance-lock-headless-exit.test.ts @@ -19,28 +19,32 @@ function readSystemdUnitBlocks(doc: string): Map { } describe('headless lock-loss exit contract', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const doc = readFileSync(join(process.cwd(), 'docs/reference/headless-linux-server.md'), 'utf8') it('exits the lock-losing launch immediately instead of scheduling a graceful quit', () => { - const gateStart = source.indexOf('if (!hasSingleInstanceLock) {') + const gateStart = preflightSource.indexOf('if (!hasLock) {') // Why: bound the anchor — an unresolved indexOf slices to EOF and passes vacuously. expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = source.indexOf('\n}', gateStart) + const gateEnd = preflightSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) - const gate = source.slice(gateStart, gateEnd) + const gate = preflightSource.slice(gateStart, gateEnd) expect(gate).toContain('app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)') expect(gate).not.toContain('app.quit()') }) it('keeps a duplicate serve launch from promoting the live server to a desktop window', () => { - const activationStart = source.indexOf('function requestDesktopActivation(') + const activationStart = entrySource.indexOf('function requestDesktopActivation(') expect(activationStart).toBeGreaterThanOrEqual(0) - const activationEnd = source.indexOf('\n}', activationStart) + const activationEnd = entrySource.indexOf('\n}', activationStart) expect(activationEnd).toBeGreaterThan(activationStart) - expect(source.slice(activationStart, activationEnd)).toContain( + expect(entrySource.slice(activationStart, activationEnd)).toContain( 'shouldActivateDesktopForSecondInstance(argv)' ) }) diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts new file mode 100644 index 00000000000..dde50f4d2c8 --- /dev/null +++ b/src/main/startup/synthetic-title-runtime.ts @@ -0,0 +1,187 @@ +import { registerPaneKeyTeardownListener, getPtyIdForPaneKey } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import type { AgentStatusState } from '../../shared/agent-status-types' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook, + type SyntheticAgentTitleProfile +} from '../../shared/synthetic-agent-title' +import { + advanceSyntheticTitleSpinnerEntries, + getSyntheticTitleSpinnerPaneKeyToStop, + type SyntheticTitleSpinnerEntry +} from '../synthetic-title-spinner' +import { shouldSendSyntheticTitleFrame } from '../synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from '../synthetic-title-frame-routing' +import { resolveTuiAgentPermissionMode } from '../../shared/tui-agent-permissions' +import { mainProcessState as state } from './main-process-state' + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 +const syntheticTitleSpinnerByPaneKey = new Map< + string, + SyntheticTitleSpinnerEntry +>() +let syntheticTitleSpinnerTimer: ReturnType | null = null + +function isSyntheticTitleWindowVisible(): boolean { + const window = state.mainWindow + return window !== null && !window.isDestroyed() && window.isVisible() && !window.isMinimized() +} + +function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { + const window = state.mainWindow + if (!window || window.isDestroyed()) { + return + } + if ( + !shouldSendSyntheticTitleFrame({ + force: options.force === true, + windowVisible: isSyntheticTitleWindowVisible() + }) + ) { + return + } + state.runtime?.ingestSyntheticTitleFrame(ptyId, data) + if (shouldCopySyntheticTitleFrameToPtyData(state.store?.getSettings())) { + window.webContents.send('pty:data', { id: ptyId, data }) + } +} + +function canSendDecorativeSyntheticTitle(): boolean { + return shouldSendSyntheticTitleFrame({ + force: false, + windowVisible: isSyntheticTitleWindowVisible() + }) +} + +export function stopSyntheticTitleSpinner(paneKey: string): void { + if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { + stopSyntheticTitleSpinnerTimerIfIdle() + } +} + +export function stopAllSyntheticTitleSpinners(): void { + syntheticTitleSpinnerByPaneKey.clear() + stopSyntheticTitleSpinnerTimer() +} + +export function stopSyntheticTitleSpinnerTimer(): void { + if (syntheticTitleSpinnerTimer) { + clearInterval(syntheticTitleSpinnerTimer) + syntheticTitleSpinnerTimer = null + } +} + +function stopSyntheticTitleSpinnerTimerIfIdle(): void { + if (syntheticTitleSpinnerByPaneKey.size === 0) { + stopSyntheticTitleSpinnerTimer() + } +} + +function tickSyntheticTitleSpinners(): void { + if (!canSendDecorativeSyntheticTitle()) { + stopSyntheticTitleSpinnerTimer() + return + } + const ticks = advanceSyntheticTitleSpinnerEntries({ + entries: syntheticTitleSpinnerByPaneKey, + frameCount: SPINNER_FRAMES.length, + getPtyIdForPaneKey + }) + for (const tick of ticks) { + sendSyntheticTitle( + tick.ptyId, + `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` + ) + } + stopSyntheticTitleSpinnerTimerIfIdle() +} + +function ensureSyntheticTitleSpinnerTimer(): void { + if ( + syntheticTitleSpinnerTimer || + syntheticTitleSpinnerByPaneKey.size === 0 || + !canSendDecorativeSyntheticTitle() + ) { + return + } + syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) +} + +export function resumeSyntheticTitleSpinnerTimer(): void { + ensureSyntheticTitleSpinnerTimer() +} + +export function driveSyntheticTitleFromHook( + paneKey: string, + agentState: AgentStatusState, + profile: SyntheticAgentTitleProfile +): void { + const ptyId = getPtyIdForPaneKey(paneKey) + if (!ptyId) { + return + } + if (agentState === 'working') { + const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) + const frame = existing ? existing.frame : 0 + sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) + if (existing) { + existing.profile = profile + return + } + syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) + ensureSyntheticTitleSpinnerTimer() + return + } + stopSyntheticTitleSpinner(paneKey) + const needsUserInput = agentState === 'blocked' || agentState === 'waiting' + const label = needsUserInput ? profile.permissionLabel : profile.idleLabel + sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { force: true }) +} + +export function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { + agentType: string | null | undefined + state: AgentStatusState + launchConfig: + | { agentArgs?: string | null; agentEnv?: Record | null } + | null + | undefined +}): boolean { + if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { + return false + } + if (!args.launchConfig) { + return false + } + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: args.launchConfig.agentArgs, + agentEnv: args.launchConfig.agentEnv + }) === 'yolo' + ) +} + +export function initializeSyntheticTitleRuntime(): void { + registerPaneKeyTeardownListener((paneKey) => stopSyntheticTitleSpinner(paneKey)) + // Retire synthetic titles with either pane-scoped clears or explicit status drops. + agentHookServer.subscribePaneStatusClear((clear) => { + const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) + if (paneKey) { + stopSyntheticTitleSpinner(paneKey) + } + }) + agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) +} + +export function driveSyntheticTitleForAgentStatus( + paneKey: string, + agentType: string | null | undefined, + agentState: AgentStatusState +): void { + const profile = getSyntheticAgentTitleProfile(agentType) + if (profile && shouldDriveSyntheticAgentTitleFromHook(agentType, agentState)) { + driveSyntheticTitleFromHook(paneKey, agentState, profile) + } +} diff --git a/src/main/startup/web-contents-timed-flag.ts b/src/main/startup/web-contents-timed-flag.ts new file mode 100644 index 00000000000..4e281c29a02 --- /dev/null +++ b/src/main/startup/web-contents-timed-flag.ts @@ -0,0 +1,31 @@ +/** A short-lived renderer intent scoped to one WebContents instance. */ +export function createWebContentsTimedFlag(defaultDurationMs = 10_000): { + mark: (webContentsId: number, durationMs?: number) => void + clear: (webContentsId?: number) => void + matches: (webContentsId: number, options?: { consume?: boolean }) => boolean +} { + let state: { webContentsId: number; until: number } | null = null + return { + mark(webContentsId, durationMs = defaultDurationMs) { + state = { webContentsId, until: Date.now() + durationMs } + }, + clear(webContentsId) { + if (webContentsId === undefined || state?.webContentsId === webContentsId) { + state = null + } + }, + matches(webContentsId, options) { + if (!state || Date.now() > state.until) { + state = null + return false + } + if (state.webContentsId !== webContentsId) { + return false + } + if (options?.consume) { + state = null + } + return true + } + } +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 912c681cfad..e22e18e84ba 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,11 +1,7 @@ -/* eslint-disable max-lines */ -import { app, BrowserWindow, powerMonitor } from 'electron' -import { is } from '@electron-toolkit/utils' +import type { BrowserWindow } from 'electron' import type { LinuxPackageInstallInstructions, - LinuxPackageInstallRecovery, UpdateCheckOptions, - UpdateSource, UpdateStatus } from '../shared/update-status-types' import type { @@ -13,2341 +9,86 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../shared/remote-server-update' -import { - isWindowsSignatureCheckUnavailableFailure, - isWindowsSignatureMismatchFailure -} from '../shared/updater-windows-signature-check' -import { killAllPty } from './ipc/pty' -import { withUpdaterSpan } from './observability/instrumentation' -import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' -import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' -import { runWithLaunchPath } from './startup/hydrate-shell-path' -import { - beginMacUpdateDownload, - deferMacQuitUntilInstallerReady, - isMacInstallerReady, - markMacQuitAndInstallInFlight, - resetMacInstallState -} from './updater-mac-install' -import { - armUpdateInstallExitWatchdog, - disarmUpdateInstallExitWatchdog -} from './update-install-exit-watchdog' -import { registerAutoUpdaterHandlers } from './updater-events' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { getLinuxRootPackageType } from './linux-update-package-type' -import { - beginLinuxPackageInstallDiagnosticCapture, - createUpdaterDiagnosticLogger, - endLinuxPackageInstallDiagnosticCapture, - getLinuxPackageInstallDiagnostic, - parseLinuxPackageInstallExitCode, - redactLinuxPackageInstallText, - type LinuxPackageInstallDiagnostic -} from './linux-package-install-diagnostic' -import { - clearTrackedLinuxPackageArtifact, - getTrackedLinuxPackageArtifact, - resolveLinuxPackageInstallInstructions, - revalidateLinuxPackageForInstall, - revealLinuxPackage, - type LinuxPackageArtifact, - type LinuxPackageRecoveryUnavailableReason -} from './linux-package-update-recovery' -import { - compareVersions, - isBenignCheckFailure, - isMissingUpdateManifestFailure, - isPrereleaseVersion, - statusesEqual -} from './updater-fallback' -import { - fetchNewerReleaseTagsWithReadiness, - getReleaseDownloadUrl -} from './updater-prerelease-feed' -import { fetchNudge, shouldApplyNudge } from './updater-nudge' -import { - failServeUpdateHandoff, - getServeUpdateHandoffFailure, - hasServeUpdateSupervisor, - requestServeUpdateHandoff -} from './serve-update-handoff' -import type { LocalBuildFeed } from './local-builds/local-build-feed-server' -import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' -import { - DEV_CHANNEL_PLATFORM_LABEL, - getVersionChannel, - hasDedicatedReleaseRepo, - isChannelSupportedOnPlatform, - RELEASE_CHANNEL_LABELS, - requiresManualDevChannelInstall, - type ReleaseBuild, - type ReleaseChannel -} from '../shared/release-channel' +import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' +import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' +import type { UpdateInstallMode } from './updater/updater-state' -type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' -type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } -type PrimaryEventSuppression = { failureKey: string; error: unknown } -type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' -type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' -// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. -class ReleaseFeedPreflightError extends Error { - constructor( - readonly reason: ReleaseFeedPreflightFailure, - readonly releaseChannel: UpdateCheckVariant, - message: string - ) { - super(message) - this.name = 'ReleaseFeedPreflightError' - } -} -type ReleaseFeedPreflightResult = 'ready' | 'not-available' -export type UpdateInstallMode = - | 'interactive' - | 'supervised-headless-serve' - | 'unsupported-headless-serve' +// Keep one service instance so all public API calls share updater state and event listeners. +const updater = new UpdaterSetup() -const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 -const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 -// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. -const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 -const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 -const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 -const QUIT_AND_INSTALL_DELAY_MS = 100 -const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 -const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 -const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 - -let mainWindowRef: BrowserWindow | null = null -let currentStatus: UpdateStatus = { state: 'idle' } -let userInitiatedCheck = false -let onBeforeQuitCleanup: (() => void | Promise) | null = null -let autoUpdaterInitialized = false -// Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. -let includePrereleaseActive = false -let availableVersion: string | null = null -let availableReleaseUrl: string | null = null -let pendingCheckFailureKey: string | null = null -let pendingCheckFailurePromise: Promise | null = null -let autoUpdateCheckTimer: ReturnType | null = null -let nudgeCheckTimer: ReturnType | null = null -let pendingQuitAndInstallTimer: ReturnType | null = null -let quitAndInstallInProgress = false -// Why: the pre-install digest re-proof streams the whole package, so a second install request can -// arrive while it runs — after the quit timer was cleared but before the handoff owns the process. -let linuxPackageRevalidationInFlight = false -let updateInstallMode: UpdateInstallMode = 'interactive' -let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null } -// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. -let updateInstallCommitted = false -// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. -let quitAndInstallNativeInvoked = false -// Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. -let lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null -let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null -let _getLastUpdateCheckAt: (() => number | null) | null = null -let backgroundCheckLaunchPending = false -// Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. -let backgroundCheckPromotedToUserInitiated = false -let updateCheckStallTimer: ReturnType | null = null -let updateCheckSilentSettleTimer: ReturnType | null = null -let updateCheckAttemptSequence = 0 -let activeUpdateCheckAttemptId: number | null = null -let activeUpdateCheckLaunchAttemptId: number | null = null -let activeUpdateCheckEventAttemptId: number | null = null -let updateAvailableEventPendingAttemptId: number | null = null -let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null -let activeUpdateNudgeId: string | null = null -let awaitingNudgeCheckOutcome = false -let nudgeCheckInFlight = false -let lastNudgeCheckAt = 0 -let publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null -let pendingPrereleaseFallback: { - primaryTag: string - fallbackTag: string - // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. - userInitiated: boolean - suppressedPrimaryPromiseFailureKey: string | null - suppressedPrimaryEventFailure: PrimaryEventSuppression | null - suppressedFallbackPromiseFailureKey: string | null - suppressedFallbackEventFailureKey: string | null - fallbackResultHandled: boolean - fallbackCheckingForUpdateSeen: boolean - retryLaunched: boolean -} | null = null - -let _getPendingUpdateNudgeId: (() => string | null) | null = null -let _getDismissedUpdateNudgeId: (() => string | null) | null = null -let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null -let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null -// Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. -let downloadInFlight = false -/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ -let quittingForUpdate = false -let autoUpdater: ElectronAutoUpdater | null = null -let activeUpdateSource: 'release' | UpdateSource = 'release' -let activeLocalBuildFeed: LocalBuildFeed | null = null -let localBuildSelectionInProgress = false -// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade -// like local builds — but off a real release feed, not a loopback server. -let pinnedBuildSelectionInProgress = false -// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a -// deliberate downgrade, so newer-only gates must yield to it too. -let isPinnedBuildActive = false -let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null - -function getAutoUpdater(): ElectronAutoUpdater { - if (!autoUpdater) { - autoUpdater = loadElectronAutoUpdater() - } - return autoUpdater -} - -function clearAvailableUpdateContext(): void { - availableVersion = null - availableReleaseUrl = null -} - -function closeLocalBuildFeed(): void { - const feed = activeLocalBuildFeed - activeLocalBuildFeed = null - if (feed) { - void feed.close() - } -} - -function restoreReleaseUpdateSource(): void { - closeLocalBuildFeed() - activeUpdateSource = 'release' - isPinnedBuildActive = false - if (autoUpdater) { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - // Why: a pinned jump forces allowPrerelease on; leaving it set would opt - // every later background check into the RC channel behind the user's back. - autoUpdater.allowPrerelease = includePrereleaseActive - } -} - -function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { - clearAvailableUpdateContext() - if ( - currentStatus.state !== 'error' || - currentStatus.message !== message || - currentStatus.userInitiated !== userInitiated || - currentStatus.source !== 'local' - ) { - sendStatus({ state: 'error', message, userInitiated, source: 'local' }) - } - restoreReleaseUpdateSource() -} - -function clearPrereleaseFallbackContext(): void { - pendingPrereleaseFallback = null -} - -function clearPendingUpdateNudge(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false - _setPendingUpdateNudgeId?.(null) -} - -function deferPendingUpdateNudgeUntilRetry(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false -} - -function clearPublishingWindowLastGoodCheck(): void { - publishingWindowLastGoodCheck = null -} - -function getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { - return publishingWindowLastGoodCheck -} - -function getPersistedPendingUpdateNudgeId(): string | null { - return _getPendingUpdateNudgeId?.() ?? null -} - -function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { - // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. - if (!activeUpdateNudgeId) { - return status - } - if (status.state === 'idle' || status.state === 'checking' || status.state === 'not-available') { - return status - } - return { ...status, activeNudgeId: activeUpdateNudgeId } -} - -/** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ -function sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { - const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight - const shouldLaunchPendingUserInitiatedCheck = - pendingUserInitiatedCheckVariant !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - const shouldPreserveNudgeForPublishingWindow = - publishingWindowLastGoodCheck !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - if (awaitingNudgeCheckOutcome) { - if (status.state === 'available') { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. - deferPendingUpdateNudgeUntilRetry() - } else { - awaitingNudgeCheckOutcome = false - } - } else if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'error' - ) { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. - deferPendingUpdateNudgeUntilRetry() - } else { - // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. - if (activeUpdateNudgeId) { - _setDismissedUpdateNudgeId?.(activeUpdateNudgeId) - } - clearPendingUpdateNudge() - } - } - } - - const sourcedStatus: UpdateStatus = - activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource } - const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) - - if (isUpdateCheckResultState(status.state)) { - finishActiveUpdateCheckAttempt() - } - - if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error' - ) { - clearPublishingWindowLastGoodCheck() - } - - // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. - if ( - decoratedStatus.state === 'downloading' || - decoratedStatus.state === 'error' || - decoratedStatus.state === 'idle' - ) { - downloadInFlight = false - } - if (shouldLaunchPendingUserInitiatedCheck) { - // Why: a forced status must still land before the queued check restarts the cycle. - if (options?.force) { - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) - } - launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) - return - } - if (!options?.force && statusesEqual(currentStatus, decoratedStatus)) { - return - } - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) -} - -function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { - switch (variant) { - case 'perf': - return { includePrerelease: true, includePerfPrerelease: true } - case 'prerelease': - return { includePrerelease: true } - case 'default': - return { includePrerelease: false } - } -} - -function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { - if (options?.includePerfPrerelease) { - return 'perf' - } - if (options?.includePrerelease) { - return 'prerelease' - } - // Why: a persisted 'rc' override makes every routine check follow the RC series - // without the user re-holding shift; the dev channels need an explicit tag, so - // neither is a routine-check variant. - if (getReleaseChannelOverride?.() === 'rc') { - return 'prerelease' - } - return 'default' -} - -function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { - pendingUserInitiatedCheckAfterInFlight = null - setTimeout(() => { - // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. - if (currentStatus.state === 'checking') { - currentStatus = { state: 'idle' } - } - checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) - }, 0) -} - -function clearBackgroundCheckLaunchPending(): void { - backgroundCheckLaunchPending = false -} - -function clearUpdateCheckStallTimer(): void { - if (!updateCheckStallTimer) { - return - } - clearTimeout(updateCheckStallTimer) - updateCheckStallTimer = null -} - -function clearUpdateCheckSilentSettleTimer(): void { - if (!updateCheckSilentSettleTimer) { - return - } - clearTimeout(updateCheckSilentSettleTimer) - updateCheckSilentSettleTimer = null -} - -function clearUpdateCheckTimers(): void { - clearUpdateCheckStallTimer() - clearUpdateCheckSilentSettleTimer() -} - -function finishActiveUpdateCheckAttempt(): void { - activeUpdateCheckAttemptId = null - activeUpdateCheckLaunchAttemptId = null - activeUpdateCheckEventAttemptId = null - clearUpdateCheckTimers() -} - -function getActiveUpdateCheckEventAttemptId(): number | null { - if (activeUpdateCheckAttemptId === null) { - return null - } - if (activeUpdateCheckEventAttemptId !== activeUpdateCheckAttemptId) { - return null - } - return activeUpdateCheckAttemptId -} - -function isActiveUpdateCheckAttempt(attemptId: number): boolean { - return activeUpdateCheckAttemptId === attemptId -} - -function markUpdateCheckEventAttempt(): boolean { - if (activeUpdateCheckAttemptId === null) { - return false - } - if (activeUpdateCheckLaunchAttemptId !== activeUpdateCheckAttemptId) { - return false - } - activeUpdateCheckEventAttemptId = activeUpdateCheckAttemptId - return true -} - -function markUpdateCheckLaunched(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - activeUpdateCheckLaunchAttemptId = attemptId -} - -function markUpdateAvailableEventPending(attemptId: number | null): void { - updateAvailableEventPendingAttemptId = attemptId -} - -function clearUpdateAvailableEventPending(attemptId: number | null): void { - if (updateAvailableEventPendingAttemptId !== attemptId) { - return - } - updateAvailableEventPendingAttemptId = null -} - -function armUpdateCheckStallTimer(attemptId: number): void { - clearUpdateCheckStallTimer() - updateCheckStallTimer = setTimeout(() => { - updateCheckStallTimer = null - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - if (currentStatus.state === 'checking') { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - void sendCheckFailureStatus( - 'Update check timed out. Try again in a few minutes.', - wasUserInitiated, - 'promise' - ) - return - } - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - }, UPDATE_CHECK_STALL_TIMEOUT_MS) -} - -function beginUpdateCheckAttempt(): number { - finishActiveUpdateCheckAttempt() - updateAvailableEventPendingAttemptId = null - updateCheckAttemptSequence += 1 - activeUpdateCheckAttemptId = updateCheckAttemptSequence - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) - // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. - writeMainThreadDiagnosticMarker('updater-check-attempt') - return activeUpdateCheckAttemptId -} - -function rearmActiveUpdateCheckStallTimer(): void { - if (activeUpdateCheckAttemptId === null) { - return - } - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) -} - -function getSettledCheckUserInitiated(): boolean | undefined { - return userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined -} - -function isUpdateCheckResultState(state: UpdateStatus['state']): boolean { - return ( - state === 'idle' || - state === 'not-available' || - state === 'available' || - state === 'error' || - state === 'downloading' || - state === 'downloaded' - ) -} - -function consumeSilentCheckShortRetryReason(): boolean { - if (publishingWindowLastGoodCheck !== null) { - return true - } - return consumeMissingManifestPrereleaseFallbackResult() !== null -} - -function completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { - const shouldRetrySoon = consumeSilentCheckShortRetryReason() - clearAvailableUpdateContext() - if (shouldRetrySoon) { - // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - return true - } - recordCompletedUpdateCheck() - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - return false -} - -function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - if (updateAvailableEventPendingAttemptId === attemptId) { - return - } - if (currentStatus.state !== 'checking') { - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - const shouldRetrySoon = completeSilentUpdateCheck(userInitiated) - if (awaitingNudgeCheckOutcome) { - if (shouldRetrySoon) { - deferPendingUpdateNudgeUntilRetry() - return - } - sendStatus({ state: 'not-available', userInitiated }) - } - } - return - } - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - completeSilentUpdateCheck(userInitiated) - sendStatus({ state: 'not-available', userInitiated }) -} - -function handleSettledUpdateCheckPromise(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - clearUpdateCheckSilentSettleTimer() - // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. - updateCheckSilentSettleTimer = setTimeout(() => { - updateCheckSilentSettleTimer = null - settleSilentUpdateCheck(attemptId, getSettledCheckUserInitiated()) - }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) -} - -function shouldHandleUpdaterErrorEvent(): boolean { - if (getActiveUpdateCheckEventAttemptId() !== null) { - return true - } - // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. - return ( - downloadInFlight || - currentStatus.state === 'downloading' || - currentStatus.state === 'downloaded' - ) -} - -function sendErrorStatus(message: string, userInitiated?: boolean): void { - if ( - currentStatus.state === 'error' && - currentStatus.message === message && - currentStatus.userInitiated === userInitiated - ) { - return - } - // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. - if (isWindowsSignatureCheckUnavailableFailure(message)) { - recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { - level: 'warn', - message: 'Windows update signature check could not run' - }) - } - sendStatus({ state: 'error', message, userInitiated }) -} - -function getKnownReleaseUrl(): string | undefined { - return availableReleaseUrl ?? undefined -} - -function hasInstallableDownloadedVersion(): boolean { - return ( - availableVersion !== null && - // Why: local builds and pinned dev jumps may intentionally move backwards. - (activeUpdateSource !== 'release' || - isPinnedBuildActive || - compareVersions(availableVersion, app.getVersion()) > 0) - ) -} - -function getPendingInstallVersion(): string { - if (availableVersion) { - return availableVersion - } - if (currentStatus.state === 'downloading' || currentStatus.state === 'downloaded') { - return currentStatus.version - } - return '' -} - -function deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { - if (updateInstallMode !== 'unsupported-headless-serve') { - return false - } - const diagnosticVersion = version || 'unknown' - if (lastInstallDeferralVersion[phase] !== diagnosticVersion) { - lastInstallDeferralVersion[phase] = diagnosticVersion - recordUpdaterLifecycle( - 'headless_serve_install_deferred', - { phase, version: version || null }, - { - level: 'warn', - message: 'Update install deferred while hosting orca serve' - } - ) - } - sendErrorStatus( - 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', - true - ) - return true -} +export type { UpdateInstallMode, UpdaterSetupOptions } export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { - if (!isServeMode) { - return 'interactive' - } - return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' -} - -function getCheckFailureKey(message: string, userInitiated?: boolean): string { - return `${userInitiated ? 'user' : 'auto'}:${message}` -} - -function clearPrereleaseFallbackContextIfSettled(): void { - if ( - pendingPrereleaseFallback?.fallbackResultHandled && - !pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && - !pendingPrereleaseFallback.suppressedPrimaryEventFailure && - !pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && - !pendingPrereleaseFallback.suppressedFallbackEventFailureKey - ) { - clearPrereleaseFallbackContext() - } -} - -async function performQuitAndInstall(): Promise { - if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) { - recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) - return - } - - if (pendingQuitAndInstallTimer) { - clearTimeout(pendingQuitAndInstallTimer) - pendingQuitAndInstallTimer = null - } - - const pendingVersion = getPendingInstallVersion() - if (deferHeadlessServeInstall('install', pendingVersion)) { - return - } - // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about - // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a - // swapped or vanished package aborts instead of being installed as root. The synchronous guard - // keeps every non-Linux install on its existing timing. - if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) { - // Why: the renderer armed its restart before invoking, and it infers the abort from the error - // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where - // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. - mainWindowRef?.webContents.send('updater:quitAndInstallAborted') - return - } - quitAndInstallInProgress = true - - markMacQuitAndInstallInFlight() - - // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. - quittingForUpdate = true - - try { - await withUpdaterSpan({ stage: 'install' }, async (span) => { - span.setAttribute('updater.version', pendingVersion || 'unknown') - span.setAttribute('updater.platform', process.platform) - span.setAttribute( - 'updater.macosInstallerReady', - process.platform === 'darwin' ? isMacInstallerReady() : true - ) - recordUpdaterLifecycle('quit_and_install_started', { - version: pendingVersion || null, - macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true - }) - span.addEvent('pre_quit_cleanup_start') - await runBeforeUpdateQuitCleanup() - span.addEvent('pre_quit_cleanup_done') - - if ( - updateInstallMode === 'supervised-headless-serve' && - !requestServeUpdateHandoff(pendingVersion) - ) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { version: pendingVersion || null }, - { - level: 'warn', - message: 'Could not persist supervised serve update handoff' - } - ) - sendErrorStatus( - 'Could not prepare the supervised server restart. Orca remains running.', - true - ) - resetQuitForUpdateState() - // Why: a bare return would exit this span Success and hide the aborted install from tracing. - span.fail('Could not persist the supervised serve update handoff') - return - } - - recordUpdaterLifecycle('quit_and_install_invoking_native', { - version: pendingVersion || null - }) - // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. - if (!quitAndInstallInProgress) { - return - } - // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. - quitAndInstallNativeInvoked = true - // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. - const supervisorOwnsRelaunch = updateInstallMode === 'supervised-headless-serve' - // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. - beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) - try { - runWithLaunchPath(() => - getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) - ) - } finally { - const diagnostic = endLinuxPackageInstallDiagnosticCapture() - // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. - lastInstallAttemptDiagnostic = quitAndInstallInProgress ? diagnostic : null - } - span.addEvent('native_quit_and_install_invoked') - - // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. - if (!quitAndInstallInProgress) { - // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. - span.fail( - currentStatus.state === 'error' - ? currentStatus.message - : 'quitAndInstall returned without invoking the installer' - ) - return - } - - // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the - // package is installed. Commit here or a throw in the cleanup below is reported as an install - // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. - if (getLinuxRootPackageType() !== null) { - updateInstallCommitted = true - armUpdateInstallExitWatchdog() - } - - killAllPty() - span.addEvent('local_pty_kill_all') - - for (const win of BrowserWindow.getAllWindows()) { - win.removeAllListeners('close') - } - span.addEvent('window_close_listeners_removed', { - windowCount: BrowserWindow.getAllWindows().length - }) - - // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. - if (!updateInstallCommitted && (process.platform !== 'darwin' || isMacInstallerReady())) { - updateInstallCommitted = true - // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). - armUpdateInstallExitWatchdog() - } - }) - } catch (error) { - // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is - // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear - // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. - if (updateInstallCommitted) { - recordUpdaterLifecycle( - 'post_commit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install cleanup failed after commit; install already applied' - } - ) - return - } - // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. - const quitAndInstallNativeInvokedBeforeReset = quitAndInstallNativeInvoked - const recoveryStatus = - quitAndInstallNativeInvokedBeforeReset && !updateInstallCommitted - ? buildLinuxPackageInstallFailureStatus(error) - : null - failServeUpdateHandoff('Could not invoke the native updater.') - resetQuitForUpdateState() - recordUpdaterLifecycle( - 'quit_and_install_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Could not start update install' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. - // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. - message: quitAndInstallNativeInvokedBeforeReset - ? withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - : 'Could not restart to install the update. Quit and reopen Orca, then try again.' - } - ) - } -} - -function resetQuitForUpdateState(): void { - quitAndInstallInProgress = false - quittingForUpdate = false - updateInstallCommitted = false - quitAndInstallNativeInvoked = false - lastInstallAttemptDiagnostic = null - disarmUpdateInstallExitWatchdog() - resetMacInstallState() -} - -/** - * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage - * it — so keep that advice there. Everywhere else a restart is not known to help. - */ -function getPreCommitInstallFailureMessage(): string { - return process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' -} - -/** - * Sends an install-failure status even when it repeats the current one. "Try Automatic Install - * Again" usually fails identically, and a deduped status would never reach the preload abort relay, - * leaving the renderer stuck in its restart checkpoint. - */ -function sendInstallFailureStatus(status: UpdateStatus): void { - sendStatus(status, { force: true }) -} - -const INSTALL_FAILURE_CAUSE_MAX_LENGTH = 200 - -/** - * Appends the updater's own text to the generic install-failure copy. Without it the only record of - * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` - * command the user has to run by hand, and remote clients get nothing but "it didn't come back". - */ -function withInstallFailureCause(baseMessage: string, error: unknown): string { - const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' - // Why: the retained-package card runs its text through this same sanitizer, so a home directory, - // user name, or terminal escape must not reach the card merely because no artifact was tracked. - const redacted = - redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' - const cause = redacted.slice(0, INSTALL_FAILURE_CAUSE_MAX_LENGTH) - if (!cause || cause === 'Unknown error') { - return baseMessage - } - // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. - if ( - isWindowsSignatureCheckUnavailableFailure(cause) || - isWindowsSignatureMismatchFailure(cause) - ) { - return cause - } - return `${baseMessage} (${cause})` -} - -/** - * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can - * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. - */ -function buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return null - } - const pendingVersion = getPendingInstallVersion() - if (pendingVersion && pendingVersion !== artifact.version) { - return null - } - const diagnostic = getLinuxPackageInstallDiagnostic() ?? lastInstallAttemptDiagnostic - // Why: the reason was classified from the original output, before redaction could rewrite a match. - const reason = diagnostic?.reason ?? 'package-install-failed' - // Durable data carries classification only — never the package path, home path, command, or stderr. - const exitCode = parseLinuxPackageInstallExitCode(error) - recordUpdaterLifecycle( - 'linux_package_install_failed', - { - packageType: artifact.packageType, - reason, - // Omitted rather than null when the child status could not be parsed. - ...(exitCode === null ? {} : { exitCode }), - version: artifact.version, - errorType: error instanceof Error ? error.name : typeof error - }, - { level: 'warn', message: 'Linux package install failed; cached package retained' } - ) - // Why: this text is shown in the card, so it gets the same redaction as retained stderr. - const message = - diagnostic?.message ?? - (error instanceof Error ? redactLinuxPackageInstallText(error.message, artifact.path) : null) ?? - 'The system package installer did not start.' - return { - state: 'error', - message, - recovery: { - kind: 'linux-package-install', - packageType: artifact.packageType, - reason, - version: artifact.version - } - } -} - -// Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. -function handleQuitAndInstallFailure(error?: unknown): boolean { - if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) { - return false - } - const recoveryStatus = buildLinuxPackageInstallFailureStatus(error) - failServeUpdateHandoff('The native updater rejected the install request.') - resetQuitForUpdateState() - // Durable data carries classification only — the cause text stays on the status the user can read. - recordUpdaterLifecycle( - 'quit_and_install_failed_via_event', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install could not start; recovered app state' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - message: withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - } - ) - return true -} - -// Why: while quit-and-install owns the process, general check/download error UI must not run. -function isQuitAndInstallHandoffActive(): boolean { - return quitAndInstallInProgress -} - -async function runBeforeUpdateQuitCleanup(): Promise { - if (!onBeforeQuitCleanup) { - return - } - - let timeout: ReturnType | null = null - const cleanup = Promise.resolve() - .then(() => onBeforeQuitCleanup?.()) - .catch((error) => { - recordUpdaterLifecycle( - 'pre_quit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Pre-quit cleanup failed; continuing update install' - } - ) - }) - const timeoutResult = new Promise<'timeout'>((resolve) => { - timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) - }) - - const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) - if (result === 'timeout') { - recordUpdaterLifecycle( - 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, - { - level: 'warn', - message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` - } - ) - return - } - - if (timeout) { - clearTimeout(timeout) - } -} - -async function sendCheckFailureStatus( - message: string, - userInitiated?: boolean, - source: CheckFailureSource = 'promise', - sourceError?: unknown -): Promise { - if (activeUpdateSource === 'local') { - sendLocalBuildErrorAndRestore(message, userInitiated) - return - } - if (isPinnedBuildActive) { - // Why: a failed pinned jump must hand the feed back before surfacing the - // error, or the pin blocks background checks for the process lifetime. - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ state: 'error', message, userInitiated }) - return - } - const failureKey = getCheckFailureKey(message, userInitiated) - if ( - source === 'promise' && - pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - if ( - source === 'fallback-promise' && - pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - - if ( - retryPrereleaseFallbackAfterMissingManifest( - message, - userInitiated, - source, - failureKey, - sourceError - ) - ) { - return - } - - if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) { - return pendingCheckFailurePromise - } - - const handleFailure = async (): Promise => { - if (isBenignCheckFailure(message) || isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). - console.warn('[updater] benign check failure:', message) - clearAvailableUpdateContext() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - if (userInitiated) { - // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. - sendErrorStatus( - isStableReleaseNotReadyFailure(sourceError) - ? "A newer release isn't available for this device yet. Check again later." - : "Couldn't reach the update server. Try again in a few minutes.", - true - ) - } else { - if (isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. - deferPendingUpdateNudgeUntilRetry() - } - sendStatus({ state: 'idle' }) - } - return - } - - clearAvailableUpdateContext() - persistLastUpdateCheckAt?.(Date.now()) - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - sendErrorStatus(message, userInitiated) - } - - pendingCheckFailureKey = failureKey - pendingCheckFailurePromise = handleFailure().finally(() => { - if (pendingCheckFailureKey === failureKey) { - pendingCheckFailureKey = null - pendingCheckFailurePromise = null - } - }) - return pendingCheckFailurePromise -} - -function isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') - ) -} - -function isStableReleaseNotReadyFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - sourceError.reason === 'release-not-ready' && - sourceError.releaseChannel === 'default' - ) + return updater.resolveUpdateInstallMode(isServeMode) } export function getUpdateStatus(): UpdateStatus { - return currentStatus + return updater.getUpdateStatus() } export function getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { - if (!app.isPackaged || is.dev) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'unpackaged-build' - } - } - if (!autoUpdaterInitialized) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'updater-unavailable' - } - } - if (updateInstallMode === 'unsupported-headless-serve') { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'manual-service-update-required' - } - } - return { installMode: updateInstallMode, automatic: true, reason: 'available' } + return updater.getRemoteServerUpdateSupport() } export function getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { - return { - appVersion: app.getVersion(), - runtimeId, - support: getRemoteServerUpdateSupport(), - status: getUpdateStatus() - } -} - -function assertRemoteServerUpdateAvailable(): void { - if (!getRemoteServerUpdateSupport().automatic) { - throw new Error('remote_update_manual_required') - } + return updater.getRemoteServerUpdaterSnapshot(runtimeId) } export function checkForRemoteServerUpdate( runtimeId: string, options?: UpdateCheckOptions ): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - checkForUpdatesFromMenu(options) - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.checkForRemoteServerUpdate(runtimeId, options) } export function downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'available') { - throw new Error('remote_update_not_available') - } - downloadUpdate() - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.downloadRemoteServerUpdate(runtimeId) } export function installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'downloaded') { - throw new Error('remote_update_not_downloaded') - } - const targetVersion = currentStatus.version - const result: RemoteServerUpdateInstallResult = { - accepted: true, - fromVersion: app.getVersion(), - targetVersion, - runtimeId - } - quitAndInstall() - return result -} - -let consecutiveAutomaticRetrySchedules = 0 - -function scheduleAutomaticUpdateCheck(delayMs: number): void { - let effectiveDelayMs = delayMs - // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. - if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { - effectiveDelayMs = Math.min( - AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules, - MAX_AUTO_UPDATE_RETRY_INTERVAL_MS - ) - consecutiveAutomaticRetrySchedules += 1 - } - if (autoUpdateCheckTimer) { - clearTimeout(autoUpdateCheckTimer) - } - autoUpdateCheckTimer = setTimeout(() => { - // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. - if (!runBackgroundUpdateCheck()) { - // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - }, effectiveDelayMs) -} - -function recordCompletedUpdateCheck(): void { - consecutiveAutomaticRetrySchedules = 0 - persistLastUpdateCheckAt?.(Date.now()) -} - -function getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - return pendingPrereleaseFallback.userInitiated -} - -function markMissingManifestPrereleaseFallbackChecking(): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true -} - -function consumeMissingManifestPrereleaseFallbackResult(): MissingManifestPrereleaseFallbackResult | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - const result = { userInitiated: pendingPrereleaseFallback.userInitiated } - pendingPrereleaseFallback.fallbackResultHandled = true - clearPrereleaseFallbackContextIfSettled() - return result -} - -function suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -function shouldSuppressMissingManifestPrereleaseFallbackEvent( - message: string, - error: unknown -): boolean { - if (!pendingPrereleaseFallback?.retryLaunched) { - return false - } - const failureKey = getCheckFailureKey(message, pendingPrereleaseFallback.userInitiated) - const primaryEventSuppression = pendingPrereleaseFallback.suppressedPrimaryEventFailure - if (primaryEventSuppression?.failureKey === failureKey) { - const isPrimaryPromisePair = primaryEventSuppression.error === error - // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. - if (isPrimaryPromisePair || !pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { - pendingPrereleaseFallback.suppressedPrimaryEventFailure = null - clearPrereleaseFallbackContextIfSettled() - return true - } - } - if (pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return true - } - return false -} - -function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -async function pinDefaultReleaseFeed( - variant: UpdateCheckVariant = 'default' -): Promise { - const autoUpdater = getAutoUpdater() - // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). - const currentVersion = app.getVersion() - const isPerfCheck = variant === 'perf' - const includePrerelease = - isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) - const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( - currentVersion, - includePrerelease ? 2 : 1, - { - includePrerelease, - ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) - } - ) - const newerTag = releaseTagsResult.tags[0] ?? null - const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null - pendingPrereleaseFallback = - includePrerelease && newerTag && fallbackTag - ? { - primaryTag: newerTag, - fallbackTag, - userInitiated: false, - suppressedPrimaryPromiseFailureKey: null, - suppressedPrimaryEventFailure: null, - suppressedFallbackPromiseFailureKey: null, - suppressedFallbackEventFailureKey: null, - fallbackResultHandled: false, - fallbackCheckingForUpdateSeen: false, - retryLaunched: false - } - : null - // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. - if (newerTag) { - clearPublishingWindowLastGoodCheck() - const url = getReleaseDownloadUrl(newerTag) - console.info( - `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } else if (releaseTagsResult.state === 'not-ready') { - clearPrereleaseFallbackContext() - if (releaseTagsResult.lastGoodTag) { - // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. - const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) - console.info( - `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } - clearPublishingWindowLastGoodCheck() - console.info( - `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` - ) - throw new ReleaseFeedPreflightError( - 'release-not-ready', - isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', - 'Latest release artifacts are not ready' - ) - } else if ( - releaseTagsResult.state === 'unavailable' && - releaseTagsResult.unavailableReason === 'manifest' && - !includePrerelease - ) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - throw new ReleaseFeedPreflightError( - 'manifest-unavailable', - 'default', - 'Unable to find latest version on GitHub' - ) - } else if (isPerfCheck) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - if (releaseTagsResult.state === 'no-newer') { - console.info( - `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` - ) - return 'not-available' - } - throw new Error('Could not resolve perf update feed') - } else { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - const url = 'https://github.com/stablyai/orca/releases/latest/download' - console.info( - `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } -} - -function retryPrereleaseFallbackAfterMissingManifest( - message: string, - userInitiated: boolean | undefined, - source: CheckFailureSource, - failureKey: string, - sourceError?: unknown -): boolean { - if ( - !pendingPrereleaseFallback || - pendingPrereleaseFallback.retryLaunched || - !isMissingUpdateManifestFailure(message) - ) { - return false - } - const attemptId = activeUpdateCheckAttemptId - if (attemptId === null) { - return false - } - - // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. - pendingPrereleaseFallback.retryLaunched = true - pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = - source === 'event' ? failureKey : null - pendingPrereleaseFallback.suppressedPrimaryEventFailure = - source === 'promise' ? { failureKey, error: sourceError } : null - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false - const { primaryTag, fallbackTag } = pendingPrereleaseFallback - const url = getReleaseDownloadUrl(fallbackTag) - console.info( - `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` - ) - const autoUpdater = getAutoUpdater() - autoUpdater.setFeedURL({ provider: 'generic', url }) - userInitiatedCheck = Boolean(userInitiated) - backgroundCheckLaunchPending = !userInitiated - armUpdateCheckStallTimer(attemptId) - markUpdateCheckLaunched(attemptId) - void autoUpdater - .checkForUpdates() - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const message = String(err?.message ?? err) - if (userInitiated) { - userInitiatedCheck = false - } else { - backgroundCheckLaunchPending = false - } - markMissingManifestPrereleaseFallbackPromiseHandled(message) - consumeMissingManifestPrereleaseFallbackResult() - void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err) - }) - return true -} - -/** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ -function runBackgroundUpdateCheck( - nudgeId: string | null = getPersistedPendingUpdateNudgeId() -): boolean { - // Why: a pinned dev jump owns the feed until it settles; a background check - // would repoint it mid-flight and download the wrong build. - if ( - activeUpdateSource !== 'release' || - isPinnedBuildActive || - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress - ) { - return false - } - if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { - return false - } - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available' }) - return false - } - // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. - activeUpdateNudgeId = nudgeId - // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. - backgroundCheckLaunchPending = true - backgroundCheckPromotedToUserInitiated = false - const attemptId = beginUpdateCheckAttempt() - // Don't send 'checking' here — the 'checking-for-update' handler does; sending from both dupes notifications (issue #35). - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed().then(launch) - void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - if (wasUserInitiated) { - userInitiatedCheck = false - } - void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err) - }) - return true + return updater.installRemoteServerUpdate(runtimeId) } export function checkForUpdates(): void { - // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. - void withUpdaterSpan({ stage: 'check' }, async (span) => { - span.setAttribute('updater.outcome', 'launched') - runBackgroundUpdateCheck() - }) + updater.checkForUpdates() } -function enablePrereleaseManifestChecks(): void { - getAutoUpdater().allowPrerelease = true -} - -function enableIncludePrerelease(): void { - if (includePrereleaseActive) { - return - } - // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. - enablePrereleaseManifestChecks() - includePrereleaseActive = true -} - -/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - if (options?.localBuild) { - void checkForLocalBuildFromMenu() - return - } - if (options?.targetTag && options.channel) { - void checkForPinnedBuild(options.channel, options.targetTag) - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - if ( - activeUpdateSource !== 'release' && - (currentStatus.state === 'checking' || currentStatus.state === 'downloading') - ) { - return - } - restoreReleaseUpdateSource() - - const checkVariant = getUpdateCheckVariant(options) - if (checkVariant === 'prerelease') { - clearPrereleaseFallbackContext() - enableIncludePrerelease() - } else if (checkVariant === 'perf') { - clearPrereleaseFallbackContext() - // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. - enablePrereleaseManifestChecks() - } - - const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' - userInitiatedCheck = true - // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. - activeUpdateNudgeId = null - // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. - sendStatus({ state: 'checking', userInitiated: true }) - if (checkAlreadyInFlight) { - backgroundCheckPromotedToUserInitiated = true - rearmActiveUpdateCheckStallTimer() - if (checkVariant !== 'default') { - // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. - pendingUserInitiatedCheckAfterInFlight = checkVariant - } - return - } - - const attemptId = beginUpdateCheckAttempt() - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { - if (preflightResult === 'not-available') { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return false - } - userInitiatedCheck = false - finishActiveUpdateCheckAttempt() - recordCompletedUpdateCheck() - sendStatus({ state: 'not-available', userInitiated: true }) - return false - } - return launch() - }) - void Promise.resolve(run) - .then((launchResult) => { - if (launchResult === false) { - return - } - handleSettledUpdateCheckPromise(attemptId) - }) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - userInitiatedCheck = false - void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) - }) -} - -async function checkForLocalBuildFromMenu(): Promise { - if (process.platform !== 'darwin') { - sendLocalBuildErrorAndRestore( - 'Local build switching is currently available only on macOS.', - true - ) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress) { - return - } - localBuildSelectionInProgress = true - try { - const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ - import('./local-builds/local-build-switch'), - import('./local-builds/local-build-feed-server') - ]) - const candidate = await chooseLocalBuild(mainWindowRef) - if (!candidate) { - return - } - closeLocalBuildFeed() - const feed = await startLocalBuildFeed(candidate) - activeLocalBuildFeed = feed - activeUpdateSource = 'local' - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.setFeedURL({ provider: 'generic', url: feed.url }) - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) - } finally { - localBuildSelectionInProgress = false - } -} - -export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { - return listReleaseBuilds(channel) -} - -/** - * Pins the updater at one exact release tag and checks it, so a dev can move to - * any published build on any channel — including an older one. - * - * Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's - * hourly" is a downgrade by semver. The pin is torn down as soon as the attempt - * settles so ordinary background checks never inherit it. - */ -async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - // Why here as well as in the picker: the renderer disables the option, but IPC - // is reachable regardless, and there is no artifact to install on a platform - // the dev workflows do not build for. - if (!isChannelSupportedOnPlatform(channel, process.platform)) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, - userInitiated: true - }) - return - } - // Why: electron-updater would otherwise take this all the way to a download - // and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead - // — the installer is run by hand once, and in-app updates work from there on. - if ( - requiresManualDevChannelInstall({ - platform: process.platform, - runningChannel: getVersionChannel(app.getVersion()), - targetChannel: channel - }) - ) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, - userInitiated: true - }) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - pinnedBuildSelectionInProgress = true - try { - const target = resolveTargetBuild(channel, tag) - if (compareVersions(target.version, app.getVersion()) === 0) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - closeLocalBuildFeed() - activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' - isPinnedBuildActive = true - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - // Why: an intentional jump to an older tag must not be filtered out as "not newer". - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.allowPrerelease = true - console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) - updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) - availableReleaseUrl = target.feedUrl - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ - state: 'error', - message: String((error as Error)?.message ?? error), - userInitiated: true - }) - } finally { - pinnedBuildSelectionInProgress = false - } -} - -export function isQuittingForUpdate(): boolean { - return quittingForUpdate -} - -function getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { - if (currentStatus.state !== 'error') { - return null - } - return currentStatus.recovery?.kind === 'linux-package-install' ? currentStatus.recovery : null -} - -const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { - missing: - 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', - // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. - 'not-regular': - 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', - 'hash-mismatch': - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', - 'read-failed': - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', - 'no-sudo': - 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - 'no-package-manager': - 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. - 'invalid-package-path': - 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' -} - -// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. -const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ - 'missing', - 'not-regular', - 'hash-mismatch' -] - -function recordLinuxPackageRecoveryUnavailable( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): void { - recordUpdaterLifecycle( - 'linux_package_recovery_unavailable', - { reason, packageType: recovery.packageType, version: recovery.version }, - { level: 'warn', message: 'Linux package recovery action unavailable' } - ) -} - -function failLinuxPackageRecovery( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): never { - recordLinuxPackageRecoveryUnavailable(recovery, reason) - const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] - // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would - // destroy the newer artifact and clobber whatever card replaced this one. - const active = getActiveLinuxPackageRecovery() - const stillCurrent = - active?.version === recovery.version && active?.packageType === recovery.packageType - if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { - clearTrackedLinuxPackageArtifact() - sendStatus({ state: 'error', message }) - } - throw new Error(message) -} - -/** - * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash - * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. - */ -function getInstallCycleSignature(): string { - const recovery = getActiveLinuxPackageRecovery() - if (recovery) { - return `recovery:${recovery.packageType}:${recovery.version}` - } - return currentStatus.state === 'downloaded' - ? `downloaded:${currentStatus.version}` - : `state:${currentStatus.state}` -} - -/** - * Re-proves the retained package before the install starts. Returns false when the install must be - * abandoned; the artifact is only re-read here, so callers still own every teardown decision. - */ -async function proveRetainedLinuxPackage(pendingVersion: string): Promise { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return true - } - // Why: an artifact retained from another cycle says nothing about the file electron-updater is - // about to install, so proving it would block a legitimate install on an unrelated digest. - if (pendingVersion && pendingVersion !== artifact.version) { - return true - } - const recovery = getActiveLinuxPackageRecovery() - const cycle = getInstallCycleSignature() - const reason = await revalidateRetainedLinuxPackage(artifact) - if (!reason) { - return true - } - reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) - return false -} - -/** The failing reason, or null when the retained package still matches its release digest. */ -async function revalidateRetainedLinuxPackage( - artifact: LinuxPackageArtifact -): Promise { - linuxPackageRevalidationInFlight = true - try { - const verdict = await revalidateLinuxPackageForInstall(artifact) - return verdict.ok ? null : verdict.reason - } catch (error) { - recordUpdaterLifecycle( - 'linux_package_revalidation_errored', - { errorType: error instanceof Error ? error.name : typeof error }, - { level: 'warn', message: 'Could not re-verify the retained update package' } - ) - // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. - return 'read-failed' - } finally { - // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall - // early-return for the rest of the session. - linuxPackageRevalidationInFlight = false - } -} - -function reportLinuxPackageRevalidationFailure({ - artifact, - recovery, - reason, - cycle -}: { - artifact: LinuxPackageArtifact - recovery: LinuxPackageInstallRecovery | null - reason: LinuxPackageRecoveryUnavailableReason - cycle: string -}): void { - recordUpdaterLifecycle( - 'linux_package_revalidation_failed', - { - action: recovery ? 'retry-automatic' : 'restart-to-install', - packageType: artifact.packageType, - version: artifact.version, - reason - }, - { level: 'warn', message: 'Retained update package failed its pre-install digest check' } - ) - // Why: a package proven bad must not stay tracked, but a download that landed during the hash - // owns the slot now and destroying it would force a needless 160 MB redownload. - const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) - if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { - clearTrackedLinuxPackageArtifact() - } - // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been - // replaced must not clobber whatever card the user is looking at now. - if (getInstallCycleSignature() !== cycle) { - return - } - sendInstallFailureStatus({ - state: 'error', - message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], - // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its - // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. - ...(recovery && !clearsArtifact ? { recovery } : {}) - }) -} - -export async function getLinuxPackageInstallInstructions(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'copy-command', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await resolveLinuxPackageInstallInstructions(recovery) - if (!result.ok) { - // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote - // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). - if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { - recordLinuxPackageRecoveryUnavailable(recovery, result.reason) - return { - ok: false, - reason: result.reason, - message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] - } - } - failLinuxPackageRecovery(recovery, result.reason) - } - return { ok: true, command: result.command, packageFileName: result.packageFileName } -} - -export async function showLinuxPackage(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'show-package', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await revealLinuxPackage(recovery) - if (!result.ok) { - failLinuxPackageRecovery(recovery, result.reason) - } -} - -export function quitAndInstall(): void { - if ( - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress || - pendingQuitAndInstallTimer || - quitAndInstallInProgress || - // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so - // without this a second click would schedule a parallel install of the same package. - linuxPackageRevalidationInFlight - ) { - return - } - - const retriedRecovery = getActiveLinuxPackageRecovery() - if (retriedRecovery) { - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: retriedRecovery.packageType, - version: retriedRecovery.version - }) - } - - if (deferHeadlessServeInstall('install', getPendingInstallVersion())) { - return - } - - if ( - deferMacQuitUntilInstallerReady( - currentStatus, - hasInstallableDownloadedVersion(), - getPendingInstallVersion, - sendStatus - ) - ) { - return - } - - // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. - pendingQuitAndInstallTimer = setTimeout(() => { - void performQuitAndInstall() - }, QUIT_AND_INSTALL_DELAY_MS) -} - -async function checkForUpdateNudge(): Promise { - if (!app.isPackaged || is.dev) { - return - } - if (nudgeCheckInFlight) { - return - } - - const now = Date.now() - if (now - lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { - return - } - lastNudgeCheckAt = now - - nudgeCheckInFlight = true - try { - const nudge = await fetchNudge() - if (!nudge) { - return - } - - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - - const appVersion = app.getVersion() - const pendingUpdateNudgeId = _getPendingUpdateNudgeId?.() ?? null - const dismissedUpdateNudgeId = _getDismissedUpdateNudgeId?.() ?? null - - if ( - shouldApplyNudge({ - nudge, - appVersion, - pendingUpdateNudgeId, - dismissedUpdateNudgeId - }) - ) { - awaitingNudgeCheckOutcome = true - _setPendingUpdateNudgeId?.(nudge.id) - mainWindowRef?.webContents.send('updater:clearDismissal') - runBackgroundUpdateCheck(nudge.id) - } - } finally { - nudgeCheckInFlight = false - } -} - -function scheduleUpdateNudgeCheck(): void { - if (nudgeCheckTimer) { - clearTimeout(nudgeCheckTimer) - } - nudgeCheckTimer = setTimeout(() => { - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - }, NUDGE_POLL_INTERVAL_MS) -} - -export function dismissNudge(): void { - const pendingId = activeUpdateNudgeId ?? _getPendingUpdateNudgeId?.() ?? null - if (pendingId) { - _setDismissedUpdateNudgeId?.(pendingId) - clearPendingUpdateNudge() - } -} - -/** - * The user closed an offered update without taking it. For a local build or a - * pinned dev jump that ends the session: nothing will consume that feed now, so - * release checks must stop being deferred. - */ -export function dismissAvailableUpdate(): void { - if (activeUpdateSource === 'release' && !isPinnedBuildActive) { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. - if (currentStatus.state !== 'available') { - return - } - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. - sendStatus({ state: 'idle' }) -} - -export function setupAutoUpdater( - mainWindow: BrowserWindow, - opts?: { - getLastUpdateCheckAt?: () => number | null - onBeforeQuit?: () => void | Promise - setLastUpdateCheckAt?: (timestamp: number) => void - getPendingUpdateNudgeId?: () => string | null - getDismissedUpdateNudgeId?: () => string | null - setPendingUpdateNudgeId?: (id: string | null) => void - setDismissedUpdateNudgeId?: (id: string | null) => void - getReleaseChannelOverride?: () => ReleaseChannel | null - installMode?: UpdateInstallMode - } -): void { - mainWindowRef = mainWindow - onBeforeQuitCleanup = opts?.onBeforeQuit ?? null - persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null - _getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null - _getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null - _getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null - _setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null - _setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null - getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null - updateInstallMode = opts?.installMode ?? 'interactive' - lastInstallDeferralVersion = { download: null, install: null } - - const serveHandoffFailure = getServeUpdateHandoffFailure() - if (serveHandoffFailure) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { reason: serveHandoffFailure }, - { level: 'warn', message: 'Supervised serve update did not complete' } - ) - sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) - } - - if (!app.isPackaged && !is.dev) { - return - } - if (is.dev) { - return - } - - const autoUpdater = getAutoUpdater() - autoUpdater.autoDownload = false - if (activeUpdateSource === 'release') { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - } - // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. - // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. - autoUpdater.autoInstallOnAppQuit = - updateInstallMode === 'interactive' && getLinuxRootPackageType() === null - // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. - autoUpdater.autoRunAppAfterInstall = updateInstallMode === 'interactive' - - // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. - // The adapter also retains the redacted child stderr that BaseUpdater logs but drops from the 'error' event. - autoUpdater.logger = createUpdaterDiagnosticLogger() as never - - // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. - - // Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download// URL avoids /latest redirect drift between check and download. - if (activeUpdateSource === 'release') { - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) - } - - if (autoUpdaterInitialized) { - return - } - autoUpdaterInitialized = true - - registerAutoUpdaterHandlers({ - autoUpdater, - clearAvailableUpdateContext, - consumeMissingManifestPrereleaseFallbackResult, - getMissingManifestPrereleaseFallbackUserInitiated, - getPublishingWindowLastGoodCheck, - getActiveUpdateCheckEventAttemptId, - getCurrentStatus: () => currentStatus, - getKnownReleaseUrl, - getPendingInstallVersion, - getUserInitiatedCheck: () => userInitiatedCheck, - handleQuitAndInstallFailure, - isQuitAndInstallHandoffActive, - hasInstallableDownloadedVersion, - isLocalBuildCheck: () => activeUpdateSource === 'local', - // Why: pinned jumps are deliberate, so update-available/-downloaded must not - // reject them for being older than the running version. - isPinnedBuildCheck: () => isPinnedBuildActive, - shouldHandleUpdaterErrorEvent, - performQuitAndInstall, - clearUpdateAvailableEventPending, - isActiveUpdateCheckAttempt, - markUpdateCheckEventAttempt, - markUpdateAvailableEventPending, - sendCheckFailureStatus, - sendErrorStatus, - markMissingManifestPrereleaseFallbackChecking, - shouldDeferMacQuitForInstall: () => updateInstallMode === 'interactive', - shouldSuppressMissingManifestPrereleaseFallbackEvent, - suppressMissingManifestPrereleaseFallbackPromiseFailure, - recordCompletedUpdateCheck, - restoreReleaseUpdateSource, - sendStatus, - scheduleAutomaticUpdateCheck, - clearBackgroundCheckLaunchPending, - setAvailableReleaseUrl: (releaseUrl) => { - availableReleaseUrl = releaseUrl - }, - setAvailableVersion: (version) => { - availableVersion = version - }, - setUserInitiatedCheck: (value) => { - userInitiatedCheck = value - } - }) - - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - - const checkDailyOnWake = () => { - void checkForUpdateNudge() - if ( - backgroundCheckLaunchPending || - currentStatus.state === 'checking' || - currentStatus.state === 'downloading' - ) { - return - } - const lastCheck = _getLastUpdateCheckAt?.() ?? null - const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck - if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - } - - powerMonitor.on('resume', checkDailyOnWake) - app.on('browser-window-focus', checkDailyOnWake) - - const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null - const msSinceLastCheck = - lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt - - if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } else { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) - } + updater.checkForUpdatesFromMenu(options) } export function downloadUpdate(): void { - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) { - return - } - // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. - const canStart = - currentStatus.state === 'available' || - (currentStatus.state === 'error' && hasInstallableDownloadedVersion()) - if (!canStart) { - return - } - const version = currentStatus.state === 'available' ? currentStatus.version : availableVersion - if (!version) { - return - } - if (deferHeadlessServeInstall('download', version)) { - return - } - downloadInFlight = true - const localBuildDownload = activeUpdateSource === 'local' - beginMacUpdateDownload() - // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. - sendStatus({ state: 'downloading', percent: 0, version }) - getAutoUpdater() - .downloadUpdate() - .catch((err) => { - downloadInFlight = false - const message = String(err?.message ?? err) - if (localBuildDownload) { - sendLocalBuildErrorAndRestore(message) - } else { - sendErrorStatus(message) - } - }) + updater.downloadUpdate() +} + +export function quitAndInstall(): void { + updater.quitAndInstall() +} + +export function isQuittingForUpdate(): boolean { + return updater.isQuittingForUpdate() +} + +export async function getLinuxPackageInstallInstructions(): Promise { + return updater.getLinuxPackageInstallInstructions() +} + +export async function showLinuxPackage(): Promise { + return updater.showLinuxPackage() +} + +export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return updater.listAvailableReleaseBuilds(channel) +} + +export function dismissNudge(): void { + updater.dismissNudge() +} + +export function dismissAvailableUpdate(): void { + updater.dismissAvailableUpdate() +} + +export function setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + updater.setupAutoUpdater(mainWindow, opts) } diff --git a/src/main/updater/updater-build-selection.ts b/src/main/updater/updater-build-selection.ts new file mode 100644 index 00000000000..63222fb3101 --- /dev/null +++ b/src/main/updater/updater-build-selection.ts @@ -0,0 +1,152 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { + DEV_CHANNEL_PLATFORM_LABEL, + getVersionChannel, + hasDedicatedReleaseRepo, + isChannelSupportedOnPlatform, + RELEASE_CHANNEL_LABELS, + requiresManualDevChannelInstall, + type ReleaseBuild, + type ReleaseChannel +} from '../../shared/release-channel' +import { compareVersions } from '../updater-fallback' +import { listReleaseBuilds, resolveTargetBuild } from '../updater-release-builds' +import { UpdaterMenuChecks } from './updater-menu-checks' + +/** Handles local-build selection and exact release-channel/tag jumps. */ +export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { + protected async checkForLocalBuildFromMenu(): Promise { + if (process.platform !== 'darwin') { + this.sendLocalBuildErrorAndRestore( + 'Local build switching is currently available only on macOS.', + true + ) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress) { + return + } + this.localBuildSelectionInProgress = true + try { + const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ + import('../local-builds/local-build-switch'), + import('../local-builds/local-build-feed-server') + ]) + const candidate = await chooseLocalBuild(this.mainWindowRef) + if (!candidate) { + return + } + this.closeLocalBuildFeed() + const feed = await startLocalBuildFeed(candidate) + this.activeLocalBuildFeed = feed + this.activeUpdateSource = 'local' + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.setFeedURL({ provider: 'generic', url: feed.url }) + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) + } finally { + this.localBuildSelectionInProgress = false + } + } + + protected async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return listReleaseBuilds(channel) + } + + /** Pins the updater at one exact release tag and checks it, so a dev can move to any published build on any channel — including an older one. */ + protected async checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + // Why here as well as in the picker: the renderer disables the option, but IPC is reachable regardless, and there is no artifact to install on a platform the dev workflows do not build for. + if (!isChannelSupportedOnPlatform(channel, process.platform)) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, + userInitiated: true + }) + return + } + // Why: electron-updater would otherwise take this all the way to a download and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead — the installer is run by hand once, and in-app updates work from there on. + if ( + requiresManualDevChannelInstall({ + platform: process.platform, + runningChannel: getVersionChannel(app.getVersion()), + targetChannel: channel + }) + ) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, + userInitiated: true + }) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + this.pinnedBuildSelectionInProgress = true + try { + const target = resolveTargetBuild(channel, tag) + if (compareVersions(target.version, app.getVersion()) === 0) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + this.closeLocalBuildFeed() + this.activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' + this.isPinnedBuildActive = true + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + // Why: an intentional jump to an older tag must not be filtered out as "not newer". + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.allowPrerelease = true + console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) + updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) + this.availableReleaseUrl = target.feedUrl + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ + state: 'error', + message: String((error as Error)?.message ?? error), + userInitiated: true + }) + } finally { + this.pinnedBuildSelectionInProgress = false + } + } +} diff --git a/src/main/updater/updater-check-failure.ts b/src/main/updater/updater-check-failure.ts new file mode 100644 index 00000000000..8ee2500c6a9 --- /dev/null +++ b/src/main/updater/updater-check-failure.ts @@ -0,0 +1,118 @@ +import { isBenignCheckFailure } from '../updater-fallback' +import { ReleaseFeedPreflightError } from './updater-state' +import type { CheckFailureSource } from './updater-state' +import { UpdaterReleaseFeed } from './updater-release-feed' + +/** Normalizes check failures, retry policy, and release-feed preflight diagnostics. */ +export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { + protected isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') + ) + } + + protected isStableReleaseNotReadyFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + sourceError.reason === 'release-not-ready' && + sourceError.releaseChannel === 'default' + ) + } + + protected async sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source: CheckFailureSource = 'promise', + sourceError?: unknown + ): Promise { + if (this.activeUpdateSource === 'local') { + this.sendLocalBuildErrorAndRestore(message, userInitiated) + return + } + if (this.isPinnedBuildActive) { + // Why: a failed pinned jump must hand the feed back before surfacing the error, or the pin blocks background checks for the process lifetime. + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ state: 'error', message, userInitiated }) + return + } + const failureKey = this.getCheckFailureKey(message, userInitiated) + if ( + source === 'promise' && + this.pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + source === 'fallback-promise' && + this.pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + this.retryPrereleaseFallbackAfterMissingManifest( + message, + userInitiated, + source, + failureKey, + sourceError + ) + ) { + return + } + if (this.pendingCheckFailureKey === failureKey && this.pendingCheckFailurePromise) { + return this.pendingCheckFailurePromise + } + + const handleFailure = async (): Promise => { + if ( + isBenignCheckFailure(message) || + this.isRetryableReleaseFeedPreflightFailure(sourceError) + ) { + // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). + console.warn('[updater] benign check failure:', message) + this.clearAvailableUpdateContext() + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + if (userInitiated) { + // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. + this.sendErrorStatus( + this.isStableReleaseNotReadyFailure(sourceError) + ? "A newer release isn't available for this device yet. Check again later." + : "Couldn't reach the update server. Try again in a few minutes.", + true + ) + } else { + if (this.isRetryableReleaseFeedPreflightFailure(sourceError)) { + // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. + this.deferPendingUpdateNudgeUntilRetry() + } + this.sendStatus({ state: 'idle' }) + } + return + } + this.clearAvailableUpdateContext() + this.persistLastUpdateCheckAt?.(Date.now()) + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + } + this.sendErrorStatus(message, userInitiated) + } + + this.pendingCheckFailureKey = failureKey + this.pendingCheckFailurePromise = handleFailure().finally(() => { + if (this.pendingCheckFailureKey === failureKey) { + this.pendingCheckFailureKey = null + this.pendingCheckFailurePromise = null + } + }) + return this.pendingCheckFailurePromise + } + + /** Keeps retry interval access in one place for the scheduling layer. */ + protected abstract getAutomaticRetryInterval(): number +} diff --git a/src/main/updater/updater-check-state.ts b/src/main/updater/updater-check-state.ts new file mode 100644 index 00000000000..8905029c75d --- /dev/null +++ b/src/main/updater/updater-check-state.ts @@ -0,0 +1,298 @@ +import { writeMainThreadDiagnosticMarker } from '../diagnostics/main-thread-churn-probe' +import { isWindowsSignatureCheckUnavailableFailure } from '../../shared/updater-windows-signature-check' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterStatus } from './updater-status' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + UPDATE_CHECK_SILENT_SETTLE_DELAY_MS, + UPDATE_CHECK_STALL_TIMEOUT_MS +} from './updater-state' + +export abstract class UpdaterCheckState extends UpdaterStatus { + protected getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } + } + + protected getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + // Why: a persisted 'rc' override makes every routine check follow the RC series + // without the user re-holding shift; the dev channels need an explicit tag, so + // neither is a routine-check variant. + if (this.getReleaseChannelOverride?.() === 'rc') { + return 'prerelease' + } + return 'default' + } + + protected launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + this.pendingUserInitiatedCheckAfterInFlight = null + setTimeout(() => { + // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. + if (this.currentStatus.state === 'checking') { + this.currentStatus = { state: 'idle' } + } + this.checkForUpdatesFromMenu(this.getOptionsForUpdateCheckVariant(variant)) + }, 0) + } + + protected clearBackgroundCheckLaunchPending(): void { + this.backgroundCheckLaunchPending = false + } + + protected clearUpdateCheckStallTimer(): void { + if (!this.updateCheckStallTimer) { + return + } + clearTimeout(this.updateCheckStallTimer) + this.updateCheckStallTimer = null + } + + protected clearUpdateCheckSilentSettleTimer(): void { + if (!this.updateCheckSilentSettleTimer) { + return + } + clearTimeout(this.updateCheckSilentSettleTimer) + this.updateCheckSilentSettleTimer = null + } + + protected clearUpdateCheckTimers(): void { + this.clearUpdateCheckStallTimer() + this.clearUpdateCheckSilentSettleTimer() + } + + protected finishActiveUpdateCheckAttempt(): void { + this.activeUpdateCheckAttemptId = null + this.activeUpdateCheckLaunchAttemptId = null + this.activeUpdateCheckEventAttemptId = null + this.clearUpdateCheckTimers() + } + + protected getActiveUpdateCheckEventAttemptId(): number | null { + if (this.activeUpdateCheckAttemptId === null) { + return null + } + if (this.activeUpdateCheckEventAttemptId !== this.activeUpdateCheckAttemptId) { + return null + } + return this.activeUpdateCheckAttemptId + } + + protected isActiveUpdateCheckAttempt(attemptId: number): boolean { + return this.activeUpdateCheckAttemptId === attemptId + } + + protected markUpdateCheckEventAttempt(): boolean { + if (this.activeUpdateCheckAttemptId === null) { + return false + } + if (this.activeUpdateCheckLaunchAttemptId !== this.activeUpdateCheckAttemptId) { + return false + } + this.activeUpdateCheckEventAttemptId = this.activeUpdateCheckAttemptId + return true + } + + protected markUpdateCheckLaunched(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.activeUpdateCheckLaunchAttemptId = attemptId + } + + protected markUpdateAvailableEventPending(attemptId: number | null): void { + this.updateAvailableEventPendingAttemptId = attemptId + } + + protected clearUpdateAvailableEventPending(attemptId: number | null): void { + if (this.updateAvailableEventPendingAttemptId !== attemptId) { + return + } + this.updateAvailableEventPendingAttemptId = null + } + + protected armUpdateCheckStallTimer(attemptId: number): void { + this.clearUpdateCheckStallTimer() + this.updateCheckStallTimer = setTimeout(() => { + this.updateCheckStallTimer = null + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + if (this.currentStatus.state === 'checking') { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + void this.sendCheckFailureStatus( + 'Update check timed out. Try again in a few minutes.', + wasUserInitiated, + 'promise' + ) + return + } + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } + }, UPDATE_CHECK_STALL_TIMEOUT_MS) + } + + protected beginUpdateCheckAttempt(): number { + this.finishActiveUpdateCheckAttempt() + this.updateAvailableEventPendingAttemptId = null + this.updateCheckAttemptSequence += 1 + this.activeUpdateCheckAttemptId = this.updateCheckAttemptSequence + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. + writeMainThreadDiagnosticMarker('updater-check-attempt') + return this.activeUpdateCheckAttemptId + } + + protected rearmActiveUpdateCheckStallTimer(): void { + if (this.activeUpdateCheckAttemptId === null) { + return + } + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + } + + protected getSettledCheckUserInitiated(): boolean | undefined { + return this.userInitiatedCheck || this.backgroundCheckPromotedToUserInitiated || undefined + } + + protected isUpdateCheckResultState(state: UpdateStatus['state']): boolean { + return ( + state === 'idle' || + state === 'not-available' || + state === 'available' || + state === 'error' || + state === 'downloading' || + state === 'downloaded' + ) + } + + protected consumeSilentCheckShortRetryReason(): boolean { + if (this.publishingWindowLastGoodCheck !== null) { + return true + } + return this.consumeMissingManifestPrereleaseFallbackResult() !== null + } + + protected completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { + const shouldRetrySoon = this.consumeSilentCheckShortRetryReason() + this.clearAvailableUpdateContext() + if (shouldRetrySoon) { + // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + return true + } + this.recordCompletedUpdateCheck() + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + return false + } + + protected settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + if (this.updateAvailableEventPendingAttemptId === attemptId) { + return + } + if (this.currentStatus.state !== 'checking') { + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + const shouldRetrySoon = this.completeSilentUpdateCheck(userInitiated) + if (this.awaitingNudgeCheckOutcome) { + if (shouldRetrySoon) { + this.deferPendingUpdateNudgeUntilRetry() + return + } + this.sendStatus({ state: 'not-available', userInitiated }) + } + } + return + } + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.completeSilentUpdateCheck(userInitiated) + this.sendStatus({ state: 'not-available', userInitiated }) + } + + protected handleSettledUpdateCheckPromise(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.clearUpdateCheckSilentSettleTimer() + // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. + this.updateCheckSilentSettleTimer = setTimeout(() => { + this.updateCheckSilentSettleTimer = null + this.settleSilentUpdateCheck(attemptId, this.getSettledCheckUserInitiated()) + }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) + } + + protected shouldHandleUpdaterErrorEvent(): boolean { + if (this.getActiveUpdateCheckEventAttemptId() !== null) { + return true + } + // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. + return ( + this.downloadInFlight || + this.currentStatus.state === 'downloading' || + this.currentStatus.state === 'downloaded' + ) + } + + protected sendErrorStatus(message: string, userInitiated?: boolean): void { + if ( + this.currentStatus.state === 'error' && + this.currentStatus.message === message && + this.currentStatus.userInitiated === userInitiated + ) { + return + } + // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. + if (isWindowsSignatureCheckUnavailableFailure(message)) { + recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { + level: 'warn', + message: 'Windows update signature check could not run' + }) + } + this.sendStatus({ state: 'error', message, userInitiated }) + } + + protected abstract consumeMissingManifestPrereleaseFallbackResult(): { + userInitiated: boolean + } | null + protected abstract recordCompletedUpdateCheck(): void + protected abstract sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source?: 'event' | 'promise' | 'fallback-promise', + sourceError?: unknown + ): Promise + protected abstract scheduleAutomaticUpdateCheck(delayMs: number): void +} diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts new file mode 100644 index 00000000000..a1627d3895c --- /dev/null +++ b/src/main/updater/updater-download-install.ts @@ -0,0 +1,93 @@ +import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { QUIT_AND_INSTALL_DELAY_MS } from './updater-state' +import { UpdaterRemoteStatus } from './updater-remote-status' + +/** Coordinates renderer-facing download/install actions and their duplicate guards. */ +export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { + protected quitAndInstall(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so without this a second click would schedule a parallel install of the same package. + this.linuxPackageRevalidationInFlight + ) { + return + } + + const retriedRecovery = this.getActiveLinuxPackageRecovery() + if (retriedRecovery) { + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'retry-automatic', + packageType: retriedRecovery.packageType, + version: retriedRecovery.version + }) + } + + if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { + return + } + if ( + deferMacQuitUntilInstallerReady( + this.currentStatus, + this.hasInstallableDownloadedVersion(), + () => this.getPendingInstallVersion(), + (status) => this.sendStatus(status) + ) + ) { + return + } + + // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. + this.pendingQuitAndInstallTimer = setTimeout(() => { + void this.performQuitAndInstall() + }, QUIT_AND_INSTALL_DELAY_MS) + } + + protected downloadUpdate(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.downloadInFlight + ) { + return + } + // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. + const canStart = + this.currentStatus.state === 'available' || + (this.currentStatus.state === 'error' && this.hasInstallableDownloadedVersion()) + if (!canStart) { + return + } + const version = + this.currentStatus.state === 'available' ? this.currentStatus.version : this.availableVersion + if (!version) { + return + } + if (this.deferHeadlessServeInstall('download', version)) { + return + } + this.downloadInFlight = true + const localBuildDownload = this.activeUpdateSource === 'local' + beginMacUpdateDownload() + // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. + this.sendStatus({ state: 'downloading', percent: 0, version }) + this.getAutoUpdater() + .downloadUpdate() + .catch((err) => { + this.downloadInFlight = false + const message = String(err?.message ?? err) + if (localBuildDownload) { + this.sendLocalBuildErrorAndRestore(message) + } else { + this.sendErrorStatus(message) + } + }) + } + + protected isQuittingForUpdate(): boolean { + return this.quittingForUpdate + } +} diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts new file mode 100644 index 00000000000..4522e155865 --- /dev/null +++ b/src/main/updater/updater-install-execution.ts @@ -0,0 +1,228 @@ +import { BrowserWindow } from 'electron' +import { killAllPty } from '../ipc/pty' +import { withUpdaterSpan } from '../observability/instrumentation' +import { runWithLaunchPath } from '../startup/hydrate-shell-path' +import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' +import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { + beginLinuxPackageInstallDiagnosticCapture, + endLinuxPackageInstallDiagnosticCapture +} from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { requestServeUpdateHandoff, failServeUpdateHandoff } from '../serve-update-handoff' +import { UpdaterPackageRecovery } from './updater-package-recovery' + +export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { + protected async performQuitAndInstall(): Promise { + if (this.quitAndInstallInProgress || this.linuxPackageRevalidationInFlight) { + recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) + return + } + + if (this.pendingQuitAndInstallTimer) { + clearTimeout(this.pendingQuitAndInstallTimer) + this.pendingQuitAndInstallTimer = null + } + + const pendingVersion = this.getPendingInstallVersion() + if (this.deferHeadlessServeInstall('install', pendingVersion)) { + return + } + // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about + // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a + // swapped or vanished package aborts instead of being installed as root. The synchronous guard + // keeps every non-Linux install on its existing timing. + if ( + getTrackedLinuxPackageArtifact() && + !(await this.proveRetainedLinuxPackage(pendingVersion)) + ) { + // Why: the renderer armed its restart before invoking, and it infers the abort from the error + // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where + // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + return + } + this.quitAndInstallInProgress = true + + markMacQuitAndInstallInFlight() + + // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. + this.quittingForUpdate = true + + try { + await withUpdaterSpan({ stage: 'install' }, async (span) => { + span.setAttribute('updater.version', pendingVersion || 'unknown') + span.setAttribute('updater.platform', process.platform) + span.setAttribute( + 'updater.macosInstallerReady', + process.platform === 'darwin' ? isMacInstallerReady() : true + ) + recordUpdaterLifecycle('quit_and_install_started', { + version: pendingVersion || null, + macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true + }) + span.addEvent('pre_quit_cleanup_start') + await this.runBeforeUpdateQuitCleanup() + span.addEvent('pre_quit_cleanup_done') + + if ( + this.updateInstallMode === 'supervised-headless-serve' && + !requestServeUpdateHandoff(pendingVersion) + ) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { version: pendingVersion || null }, + { + level: 'warn', + message: 'Could not persist supervised serve update handoff' + } + ) + this.sendErrorStatus( + 'Could not prepare the supervised server restart. Orca remains running.', + true + ) + this.resetQuitForUpdateState() + // Why: a bare return would exit this span Success and hide the aborted install from tracing. + span.fail('Could not persist the supervised serve update handoff') + return + } + + recordUpdaterLifecycle('quit_and_install_invoking_native', { + version: pendingVersion || null + }) + // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. + if (!this.quitAndInstallInProgress) { + return + } + // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. + this.quitAndInstallNativeInvoked = true + // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. + const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' + // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. + beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) + try { + runWithLaunchPath(() => + this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) + } finally { + const diagnostic = endLinuxPackageInstallDiagnosticCapture() + // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. + this.lastInstallAttemptDiagnostic = this.quitAndInstallInProgress ? diagnostic : null + } + span.addEvent('native_quit_and_install_invoked') + + // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. + if (!this.quitAndInstallInProgress) { + // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. + span.fail( + this.currentStatus.state === 'error' + ? this.currentStatus.message + : 'quitAndInstall returned without invoking the installer' + ) + return + } + + // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the + // package is installed. Commit here or a throw in the cleanup below is reported as an install + // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. + if (getLinuxRootPackageType() !== null) { + this.updateInstallCommitted = true + armUpdateInstallExitWatchdog() + } + + killAllPty() + span.addEvent('local_pty_kill_all') + + for (const win of BrowserWindow.getAllWindows()) { + win.removeAllListeners('close') + } + span.addEvent('window_close_listeners_removed', { + windowCount: BrowserWindow.getAllWindows().length + }) + + // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. + if ( + !this.updateInstallCommitted && + (process.platform !== 'darwin' || isMacInstallerReady()) + ) { + this.updateInstallCommitted = true + // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). + armUpdateInstallExitWatchdog() + } + }) + } catch (error) { + // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is + // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear + // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. + if (this.updateInstallCommitted) { + recordUpdaterLifecycle( + 'post_commit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install cleanup failed after commit; install already applied' + } + ) + return + } + // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. + const quitAndInstallNativeInvokedBeforeReset = this.quitAndInstallNativeInvoked + const recoveryStatus = + quitAndInstallNativeInvokedBeforeReset && !this.updateInstallCommitted + ? this.buildLinuxPackageInstallFailureStatus(error) + : null + failServeUpdateHandoff('Could not invoke the native updater.') + this.resetQuitForUpdateState() + recordUpdaterLifecycle( + 'quit_and_install_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Could not start update install' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. + // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. + message: quitAndInstallNativeInvokedBeforeReset + ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + : 'Could not restart to install the update. Quit and reopen Orca, then try again.' + } + ) + } + } + + // Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. + protected handleQuitAndInstallFailure(error?: unknown): boolean { + if ( + !this.quitAndInstallInProgress || + !this.quitAndInstallNativeInvoked || + this.updateInstallCommitted + ) { + return false + } + const recoveryStatus = this.buildLinuxPackageInstallFailureStatus(error) + failServeUpdateHandoff('The native updater rejected the install request.') + this.resetQuitForUpdateState() + // Durable data carries classification only — the cause text stays on the status the user can read. + recordUpdaterLifecycle( + 'quit_and_install_failed_via_event', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install could not start; recovered app state' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + } + ) + return true + } +} diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts new file mode 100644 index 00000000000..ce6b4d3f398 --- /dev/null +++ b/src/main/updater/updater-install-support.ts @@ -0,0 +1,174 @@ +import { app } from 'electron' +import { + isWindowsSignatureCheckUnavailableFailure, + isWindowsSignatureMismatchFailure +} from '../../shared/updater-windows-signature-check' +import { redactLinuxPackageInstallText } from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { resetMacInstallState } from '../updater-mac-install' +import type { + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../shared/update-status-types' +import { compareVersions } from '../updater-fallback' +import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' +import { UpdaterCheckState } from './updater-check-state' + +export abstract class UpdaterInstallSupport extends UpdaterCheckState { + protected getKnownReleaseUrl(): string | undefined { + return this.availableReleaseUrl ?? undefined + } + + protected hasInstallableDownloadedVersion(): boolean { + return ( + this.availableVersion !== null && + // Why: local builds and pinned dev jumps may intentionally move backwards. + (this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + compareVersions(this.availableVersion, app.getVersion()) > 0) + ) + } + + protected getPendingInstallVersion(): string { + if (this.availableVersion) { + return this.availableVersion + } + if (this.currentStatus.state === 'downloading' || this.currentStatus.state === 'downloaded') { + return this.currentStatus.version + } + return '' + } + + protected deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { + if (this.updateInstallMode !== 'unsupported-headless-serve') { + return false + } + const diagnosticVersion = version || 'unknown' + if (this.lastInstallDeferralVersion[phase] !== diagnosticVersion) { + this.lastInstallDeferralVersion[phase] = diagnosticVersion + recordUpdaterLifecycle( + 'headless_serve_install_deferred', + { phase, version: version || null }, + { + level: 'warn', + message: 'Update install deferred while hosting orca serve' + } + ) + } + this.sendErrorStatus( + 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', + true + ) + return true + } + + protected getCheckFailureKey(message: string, userInitiated?: boolean): string { + return `${userInitiated ? 'user' : 'auto'}:${message}` + } + + protected resetQuitForUpdateState(): void { + this.quitAndInstallInProgress = false + this.quittingForUpdate = false + this.updateInstallCommitted = false + this.quitAndInstallNativeInvoked = false + this.lastInstallAttemptDiagnostic = null + disarmUpdateInstallExitWatchdog() + resetMacInstallState() + } + + /** + * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage + * it — so keep that advice there. Everywhere else a restart is not known to help. + */ + protected getPreCommitInstallFailureMessage(): string { + return process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' + } + + /** + * Sends an install-failure status even when it repeats the current one. "Try Automatic Install + * Again" usually fails identically, and a deduped status would never reach the preload abort relay, + * leaving the renderer stuck in its restart checkpoint. + */ + protected sendInstallFailureStatus(status: UpdateStatus): void { + this.sendStatus(status, { force: true }) + } + + /** + * Appends the updater's own text to the generic install-failure copy. Without it the only record of + * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` + * command the user has to run by hand, and remote clients get nothing but "it didn't come back". + */ + protected withInstallFailureCause(baseMessage: string, error: unknown): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + // Why: the retained-package card runs its text through this same sanitizer, so a home directory, + // user name, or terminal escape must not reach the card merely because no artifact was tracked. + const redacted = + redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' + const cause = redacted.slice(0, this.installFailureCauseMaxLength) + if (!cause || cause === 'Unknown error') { + return baseMessage + } + // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. + if ( + isWindowsSignatureCheckUnavailableFailure(cause) || + isWindowsSignatureMismatchFailure(cause) + ) { + return cause + } + return `${baseMessage} (${cause})` + } + + /** + * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can + * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. + */ + protected isQuitAndInstallHandoffActive(): boolean { + return this.quitAndInstallInProgress + } + + protected async runBeforeUpdateQuitCleanup(): Promise { + if (!this.onBeforeQuitCleanup) { + return + } + + let timeout: ReturnType | null = null + const cleanup = Promise.resolve() + .then(() => this.onBeforeQuitCleanup?.()) + .catch((error) => { + recordUpdaterLifecycle( + 'pre_quit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Pre-quit cleanup failed; continuing update install' + } + ) + }) + const timeoutResult = new Promise<'timeout'>((resolve) => { + timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) + }) + + const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) + if (result === 'timeout') { + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, + { + level: 'warn', + message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + } + ) + return + } + + if (timeout) { + clearTimeout(timeout) + } + } + + protected abstract getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null +} diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts new file mode 100644 index 00000000000..b76d5c19710 --- /dev/null +++ b/src/main/updater/updater-menu-checks.ts @@ -0,0 +1,100 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import { UpdaterScheduling } from './updater-scheduling' + +/** Handles checks initiated from the desktop menu and modifier-key variants. */ +export abstract class UpdaterMenuChecks extends UpdaterScheduling { + protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + if (options?.localBuild) { + void this.checkForLocalBuildFromMenu() + return + } + if (options?.targetTag && options.channel) { + void this.checkForPinnedBuild(options.channel, options.targetTag) + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + if ( + this.activeUpdateSource !== 'release' && + (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') + ) { + return + } + this.restoreReleaseUpdateSource() + + const checkVariant = this.getUpdateCheckVariant(options) + if (checkVariant === 'prerelease') { + this.clearPrereleaseFallbackContext() + this.enableIncludePrerelease() + } else if (checkVariant === 'perf') { + this.clearPrereleaseFallbackContext() + // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. + this.enablePrereleaseManifestChecks() + } + + const checkAlreadyInFlight = + this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking' + this.userInitiatedCheck = true + // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. + this.activeUpdateNudgeId = null + // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. + this.sendStatus({ state: 'checking', userInitiated: true }) + if (checkAlreadyInFlight) { + this.backgroundCheckPromotedToUserInitiated = true + this.rearmActiveUpdateCheckStallTimer() + if (checkVariant !== 'default') { + // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. + this.pendingUserInitiatedCheckAfterInFlight = checkVariant + } + return + } + + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return false + } + this.userInitiatedCheck = false + this.finishActiveUpdateCheckAttempt() + this.recordCompletedUpdateCheck() + this.sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) + void Promise.resolve(run) + .then((launchResult) => { + if (launchResult === false) { + return + } + this.handleSettledUpdateCheckPromise(attemptId) + }) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.userInitiatedCheck = false + void this.sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) + }) + } + + protected abstract checkForLocalBuildFromMenu(): Promise + protected abstract checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise +} diff --git a/src/main/updater/updater-nudge.ts b/src/main/updater/updater-nudge.ts new file mode 100644 index 00000000000..765af67cd53 --- /dev/null +++ b/src/main/updater/updater-nudge.ts @@ -0,0 +1,86 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { fetchNudge, shouldApplyNudge } from '../updater-nudge' +import { NUDGE_ACTIVATION_COOLDOWN_MS, NUDGE_POLL_INTERVAL_MS } from './updater-state' +import { UpdaterBuildSelection } from './updater-build-selection' + +/** Polls update campaigns and exposes their dismissal actions. */ +export abstract class UpdaterNudge extends UpdaterBuildSelection { + protected async checkForUpdateNudge(): Promise { + if (!app.isPackaged || is.dev) { + return + } + if (this.nudgeCheckInFlight) { + return + } + const now = Date.now() + if (now - this.lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { + return + } + this.lastNudgeCheckAt = now + this.nudgeCheckInFlight = true + try { + const nudge = await fetchNudge() + if (!nudge) { + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + const appVersion = app.getVersion() + const pendingUpdateNudgeId = this._getPendingUpdateNudgeId?.() ?? null + const dismissedUpdateNudgeId = this._getDismissedUpdateNudgeId?.() ?? null + if ( + shouldApplyNudge({ + nudge, + appVersion, + pendingUpdateNudgeId, + dismissedUpdateNudgeId + }) + ) { + this.awaitingNudgeCheckOutcome = true + this._setPendingUpdateNudgeId?.(nudge.id) + this.mainWindowRef?.webContents.send('updater:clearDismissal') + this.runBackgroundUpdateCheck(nudge.id) + } + } finally { + this.nudgeCheckInFlight = false + } + } + + protected scheduleUpdateNudgeCheck(): void { + if (this.nudgeCheckTimer) { + clearTimeout(this.nudgeCheckTimer) + } + this.nudgeCheckTimer = setTimeout(() => { + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + }, NUDGE_POLL_INTERVAL_MS) + } + + protected dismissNudge(): void { + const pendingId = this.activeUpdateNudgeId ?? this._getPendingUpdateNudgeId?.() ?? null + if (pendingId) { + this._setDismissedUpdateNudgeId?.(pendingId) + this.clearPendingUpdateNudge() + } + } + + /** Abandons an un-acted local or pinned update and restores the release feed. */ + protected dismissAvailableUpdate(): void { + if (this.activeUpdateSource === 'release' && !this.isPinnedBuildActive) { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. + if (this.currentStatus.state !== 'available') { + return + } + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. + this.sendStatus({ state: 'idle' }) + } +} diff --git a/src/main/updater/updater-package-recovery.ts b/src/main/updater/updater-package-recovery.ts new file mode 100644 index 00000000000..870d601a5f3 --- /dev/null +++ b/src/main/updater/updater-package-recovery.ts @@ -0,0 +1,274 @@ +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { + getTrackedLinuxPackageArtifact, + clearTrackedLinuxPackageArtifact, + revalidateLinuxPackageForInstall, + resolveLinuxPackageInstallInstructions, + revealLinuxPackage, + type LinuxPackageArtifact, + type LinuxPackageRecoveryUnavailableReason +} from '../linux-package-update-recovery' +import { + getLinuxPackageInstallDiagnostic, + parseLinuxPackageInstallExitCode, + redactLinuxPackageInstallText +} from '../linux-package-install-diagnostic' +import type { + LinuxPackageInstallInstructions, + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../shared/update-status-types' +import { UpdaterInstallSupport } from './updater-install-support' + +const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { + missing: + 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', + // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. + 'not-regular': + 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', + 'hash-mismatch': + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', + 'read-failed': + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', + 'no-sudo': + 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + 'no-package-manager': + 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. + 'invalid-package-path': + 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' +} + +// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. +const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ + 'missing', + 'not-regular', + 'hash-mismatch' +] + +export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { + protected getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { + if (this.currentStatus.state !== 'error') { + return null + } + return this.currentStatus.recovery?.kind === 'linux-package-install' + ? this.currentStatus.recovery + : null + } + + protected recordLinuxPackageRecoveryUnavailable( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): void { + recordUpdaterLifecycle( + 'linux_package_recovery_unavailable', + { reason, packageType: recovery.packageType, version: recovery.version }, + { level: 'warn', message: 'Linux package recovery action unavailable' } + ) + } + + protected failLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): never { + this.recordLinuxPackageRecoveryUnavailable(recovery, reason) + const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] + // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would + // destroy the newer artifact and clobber whatever card replaced this one. + const active = this.getActiveLinuxPackageRecovery() + const stillCurrent = + active?.version === recovery.version && active?.packageType === recovery.packageType + if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { + clearTrackedLinuxPackageArtifact() + this.sendStatus({ state: 'error', message }) + } + throw new Error(message) + } + + /** + * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash + * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. + */ + protected getInstallCycleSignature(): string { + const recovery = this.getActiveLinuxPackageRecovery() + if (recovery) { + return `recovery:${recovery.packageType}:${recovery.version}` + } + return this.currentStatus.state === 'downloaded' + ? `downloaded:${this.currentStatus.version}` + : `state:${this.currentStatus.state}` + } + + /** + * Re-proves the retained package before the install starts. Returns false when the install must be + * abandoned; the artifact is only re-read here, so callers still own every teardown decision. + */ + protected async proveRetainedLinuxPackage(pendingVersion: string): Promise { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return true + } + // Why: an artifact retained from another cycle says nothing about the file electron-updater is + // about to install, so proving it would block a legitimate install on an unrelated digest. + if (pendingVersion && pendingVersion !== artifact.version) { + return true + } + const recovery = this.getActiveLinuxPackageRecovery() + const cycle = this.getInstallCycleSignature() + const reason = await this.revalidateRetainedLinuxPackage(artifact) + if (!reason) { + return true + } + this.reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) + return false + } + + /** The failing reason, or null when the retained package still matches its release digest. */ + protected async revalidateRetainedLinuxPackage( + artifact: LinuxPackageArtifact + ): Promise { + this.linuxPackageRevalidationInFlight = true + try { + const verdict = await revalidateLinuxPackageForInstall(artifact) + return verdict.ok ? null : verdict.reason + } catch (error) { + recordUpdaterLifecycle( + 'linux_package_revalidation_errored', + { errorType: error instanceof Error ? error.name : typeof error }, + { level: 'warn', message: 'Could not re-verify the retained update package' } + ) + // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. + return 'read-failed' + } finally { + // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall + // early-return for the rest of the session. + this.linuxPackageRevalidationInFlight = false + } + } + + protected reportLinuxPackageRevalidationFailure({ + artifact, + recovery, + reason, + cycle + }: { + artifact: LinuxPackageArtifact + recovery: LinuxPackageInstallRecovery | null + reason: LinuxPackageRecoveryUnavailableReason + cycle: string + }): void { + recordUpdaterLifecycle( + 'linux_package_revalidation_failed', + { + action: recovery ? 'retry-automatic' : 'restart-to-install', + packageType: artifact.packageType, + version: artifact.version, + reason + }, + { level: 'warn', message: 'Retained update package failed its pre-install digest check' } + ) + // Why: a package proven bad must not stay tracked, but a download that landed during the hash + // owns the slot now and destroying it would force a needless 160 MB redownload. + const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) + if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { + clearTrackedLinuxPackageArtifact() + } + // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been + // replaced must not clobber whatever card the user is looking at now. + if (this.getInstallCycleSignature() !== cycle) { + return + } + this.sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], + // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its + // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. + ...(recovery && !clearsArtifact ? { recovery } : {}) + }) + } + + protected async getLinuxPackageInstallInstructions(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'copy-command', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await resolveLinuxPackageInstallInstructions(recovery) + if (!result.ok) { + // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote + // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). + if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { + this.recordLinuxPackageRecoveryUnavailable(recovery, result.reason) + return { + ok: false, + reason: result.reason, + message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] + } + } + this.failLinuxPackageRecovery(recovery, result.reason) + } + return { ok: true, command: result.command, packageFileName: result.packageFileName } + } + + protected async showLinuxPackage(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'show-package', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await revealLinuxPackage(recovery) + if (!result.ok) { + this.failLinuxPackageRecovery(recovery, result.reason) + } + } + + /** Builds a recoverable status when the native Linux package installer rejects a retained artifact. */ + protected buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return null + } + const pendingVersion = this.getPendingInstallVersion() + if (pendingVersion && pendingVersion !== artifact.version) { + return null + } + const diagnostic = getLinuxPackageInstallDiagnostic() ?? this.lastInstallAttemptDiagnostic + const reason = diagnostic?.reason ?? 'package-install-failed' + const exitCode = parseLinuxPackageInstallExitCode(error) + recordUpdaterLifecycle( + 'linux_package_install_failed', + { + packageType: artifact.packageType, + reason, + ...(exitCode === null ? {} : { exitCode }), + version: artifact.version, + errorType: error instanceof Error ? error.name : typeof error + }, + { level: 'warn', message: 'Linux package install failed; cached package retained' } + ) + const message = + diagnostic?.message ?? + (error instanceof Error + ? redactLinuxPackageInstallText(error.message, artifact.path) + : null) ?? + 'The system package installer did not start.' + return { + state: 'error', + message, + recovery: { + kind: 'linux-package-install', + packageType: artifact.packageType, + reason, + version: artifact.version + } + } + } +} diff --git a/src/main/updater/updater-release-feed.ts b/src/main/updater/updater-release-feed.ts new file mode 100644 index 00000000000..46a34eb49ed --- /dev/null +++ b/src/main/updater/updater-release-feed.ts @@ -0,0 +1,270 @@ +import { app } from 'electron' +import { + fetchNewerReleaseTagsWithReadiness, + getReleaseDownloadUrl +} from '../updater-prerelease-feed' +import { isMissingUpdateManifestFailure, isPrereleaseVersion } from '../updater-fallback' +import type { CheckFailureSource } from './updater-state' +import type { UpdateCheckVariant } from './updater-types' +import { ReleaseFeedPreflightError } from './updater-state' +import { UpdaterInstallExecution } from './updater-install-execution' + +/** Owns concrete release-feed pinning and the one-shot prerelease fallback. */ +export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution { + protected clearPrereleaseFallbackContextIfSettled(): void { + if ( + this.pendingPrereleaseFallback?.fallbackResultHandled && + !this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedPrimaryEventFailure && + !this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey + ) { + this.clearPrereleaseFallbackContext() + } + } + + protected getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + return this.pendingPrereleaseFallback.userInitiated + } + + protected markMissingManifestPrereleaseFallbackChecking(): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true + } + + protected consumeMissingManifestPrereleaseFallbackResult(): { userInitiated: boolean } | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + const result = { userInitiated: this.pendingPrereleaseFallback.userInitiated } + this.pendingPrereleaseFallback.fallbackResultHandled = true + this.clearPrereleaseFallbackContextIfSettled() + return result + } + + protected suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected shouldSuppressMissingManifestPrereleaseFallbackEvent( + message: string, + error: unknown + ): boolean { + if (!this.pendingPrereleaseFallback?.retryLaunched) { + return false + } + const failureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + const primaryEventSuppression = this.pendingPrereleaseFallback.suppressedPrimaryEventFailure + if (primaryEventSuppression?.failureKey === failureKey) { + const isPrimaryPromisePair = primaryEventSuppression.error === error + // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. + if (isPrimaryPromisePair || !this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + } + if (this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + return false + } + + protected markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected async pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' + ): Promise<'ready' | 'not-available'> { + const autoUpdater = this.getAutoUpdater() + // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). + const currentVersion = app.getVersion() + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || this.includePrereleaseActive || isPrereleaseVersion(currentVersion) + const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( + currentVersion, + includePrerelease ? 2 : 1, + { + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) + } + ) + const newerTag = releaseTagsResult.tags[0] ?? null + const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null + this.pendingPrereleaseFallback = + includePrerelease && newerTag && fallbackTag + ? { + primaryTag: newerTag, + fallbackTag, + userInitiated: false, + suppressedPrimaryPromiseFailureKey: null, + suppressedPrimaryEventFailure: null, + suppressedFallbackPromiseFailureKey: null, + suppressedFallbackEventFailureKey: null, + fallbackResultHandled: false, + fallbackCheckingForUpdateSeen: false, + retryLaunched: false + } + : null + // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. + if (newerTag) { + this.clearPublishingWindowLastGoodCheck() + const url = getReleaseDownloadUrl(newerTag) + console.info( + `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + if (releaseTagsResult.state === 'not-ready') { + this.clearPrereleaseFallbackContext() + if (releaseTagsResult.lastGoodTag) { + // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. + const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) + console.info( + `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + this.publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + this.clearPublishingWindowLastGoodCheck() + console.info( + `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` + ) + throw new ReleaseFeedPreflightError( + 'release-not-ready', + isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', + 'Latest release artifacts are not ready' + ) + } + if ( + releaseTagsResult.state === 'unavailable' && + releaseTagsResult.unavailableReason === 'manifest' && + !includePrerelease + ) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + throw new ReleaseFeedPreflightError( + 'manifest-unavailable', + 'default', + 'Unable to find latest version on GitHub' + ) + } + if (isPerfCheck) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') + } + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + const url = 'https://github.com/stablyai/orca/releases/latest/download' + console.info( + `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + + protected retryPrereleaseFallbackAfterMissingManifest( + message: string, + userInitiated: boolean | undefined, + source: CheckFailureSource, + failureKey: string, + sourceError?: unknown + ): boolean { + if ( + !this.pendingPrereleaseFallback || + this.pendingPrereleaseFallback.retryLaunched || + !isMissingUpdateManifestFailure(message) + ) { + return false + } + const attemptId = this.activeUpdateCheckAttemptId + if (attemptId === null) { + return false + } + // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. + this.pendingPrereleaseFallback.retryLaunched = true + this.pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = + source === 'event' ? failureKey : null + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = + source === 'promise' ? { failureKey, error: sourceError } : null + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false + const { primaryTag, fallbackTag } = this.pendingPrereleaseFallback + const url = getReleaseDownloadUrl(fallbackTag) + console.info( + `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` + ) + const autoUpdater = this.getAutoUpdater() + autoUpdater.setFeedURL({ provider: 'generic', url }) + this.userInitiatedCheck = Boolean(userInitiated) + this.backgroundCheckLaunchPending = !userInitiated + this.armUpdateCheckStallTimer(attemptId) + this.markUpdateCheckLaunched(attemptId) + void autoUpdater + .checkForUpdates() + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const fallbackMessage = String(err?.message ?? err) + if (userInitiated) { + this.userInitiatedCheck = false + } else { + this.backgroundCheckLaunchPending = false + } + this.markMissingManifestPrereleaseFallbackPromiseHandled(fallbackMessage) + this.consumeMissingManifestPrereleaseFallbackResult() + void this.sendCheckFailureStatus(fallbackMessage, userInitiated, 'fallback-promise', err) + }) + return true + } +} diff --git a/src/main/updater/updater-remote-status.ts b/src/main/updater/updater-remote-status.ts new file mode 100644 index 00000000000..6e3db76b0c8 --- /dev/null +++ b/src/main/updater/updater-remote-status.ts @@ -0,0 +1,102 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { hasServeUpdateSupervisor } from '../serve-update-handoff' +import { UpdaterNudge } from './updater-nudge' +import type { UpdateInstallMode } from './updater-state' + +/** Exposes updater state to runtime RPC callers without leaking internal mutators. */ +export abstract class UpdaterRemoteStatus extends UpdaterNudge { + protected getUpdateStatus(): UpdateStatus { + return this.currentStatus + } + + protected getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + if (!app.isPackaged || is.dev) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'unpackaged-build' + } + } + if (!this.autoUpdaterInitialized) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'updater-unavailable' + } + } + if (this.updateInstallMode === 'unsupported-headless-serve') { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'manual-service-update-required' + } + } + return { installMode: this.updateInstallMode, automatic: true, reason: 'available' } + } + + protected getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return { + appVersion: app.getVersion(), + runtimeId, + support: this.getRemoteServerUpdateSupport(), + status: this.getUpdateStatus() + } + } + + protected assertRemoteServerUpdateAvailable(): void { + if (!this.getRemoteServerUpdateSupport().automatic) { + throw new Error('remote_update_manual_required') + } + } + + protected checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + this.checkForUpdatesFromMenu(options) + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'available') { + throw new Error('remote_update_not_available') + } + this.downloadUpdate() + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'downloaded') { + throw new Error('remote_update_not_downloaded') + } + const targetVersion = this.currentStatus.version + const result: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: app.getVersion(), + targetVersion, + runtimeId + } + this.quitAndInstall() + return result + } + + protected resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + if (!isServeMode) { + return 'interactive' + } + return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' + } + + protected abstract downloadUpdate(): void + protected abstract quitAndInstall(): void +} diff --git a/src/main/updater/updater-scheduling.ts b/src/main/updater/updater-scheduling.ts new file mode 100644 index 00000000000..954431385d0 --- /dev/null +++ b/src/main/updater/updater-scheduling.ts @@ -0,0 +1,121 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { withUpdaterSpan } from '../observability/instrumentation' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS +} from './updater-state' +import { UpdaterCheckFailure } from './updater-check-failure' + +/** Owns timer-driven checks and the shared check-launch bookkeeping. */ +export abstract class UpdaterScheduling extends UpdaterCheckFailure { + protected getAutomaticRetryInterval(): number { + return AUTO_UPDATE_RETRY_INTERVAL_MS + } + + protected scheduleAutomaticUpdateCheck(delayMs: number): void { + let effectiveDelayMs = delayMs + // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. + if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { + effectiveDelayMs = Math.min( + AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** this.consecutiveAutomaticRetrySchedules, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS + ) + this.consecutiveAutomaticRetrySchedules += 1 + } + if (this.autoUpdateCheckTimer) { + clearTimeout(this.autoUpdateCheckTimer) + } + this.autoUpdateCheckTimer = setTimeout(() => { + // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. + if (!this.runBackgroundUpdateCheck()) { + // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + }, effectiveDelayMs) + } + + protected recordCompletedUpdateCheck(): void { + this.consecutiveAutomaticRetrySchedules = 0 + this.persistLastUpdateCheckAt?.(Date.now()) + } + + /** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ + protected runBackgroundUpdateCheck( + nudgeId: string | null = this.getPersistedPendingUpdateNudgeId() + ): boolean { + // Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build. + if ( + this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress + ) { + return false + } + if (this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking') { + return false + } + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available' }) + return false + } + // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. + this.activeUpdateNudgeId = nudgeId + // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. + this.backgroundCheckLaunchPending = true + this.backgroundCheckPromotedToUserInitiated = false + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed().then(launch) + void Promise.resolve(run) + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + if (wasUserInitiated) { + this.userInitiatedCheck = false + } + void this.sendCheckFailureStatus( + String(err?.message ?? err), + wasUserInitiated, + 'promise', + err + ) + }) + return true + } + + protected checkForUpdatesInBackground(): void { + // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. + void withUpdaterSpan({ stage: 'check' }, async (span) => { + span.setAttribute('updater.outcome', 'launched') + this.runBackgroundUpdateCheck() + }) + } + + protected enablePrereleaseManifestChecks(): void { + this.getAutoUpdater().allowPrerelease = true + } + + protected enableIncludePrerelease(): void { + if (this.includePrereleaseActive) { + return + } + // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. + this.enablePrereleaseManifestChecks() + this.includePrereleaseActive = true + } +} diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts new file mode 100644 index 00000000000..21354f97af3 --- /dev/null +++ b/src/main/updater/updater-setup.ts @@ -0,0 +1,251 @@ +import { app, powerMonitor } from 'electron' +import type { BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { ReleaseBuild, ReleaseChannel } from '../../shared/release-channel' +import type { + LinuxPackageInstallInstructions, + UpdateCheckOptions, + UpdateStatus +} from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { createUpdaterDiagnosticLogger } from '../linux-package-install-diagnostic' +import { registerAutoUpdaterHandlers } from '../updater-events' +import { getServeUpdateHandoffFailure } from '../serve-update-handoff' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { AUTO_UPDATE_CHECK_INTERVAL_MS } from './updater-state' +import { UpdaterDownloadInstall } from './updater-download-install' +import type { UpdateInstallMode } from './updater-state' + +export type UpdaterSetupOptions = { + getLastUpdateCheckAt?: () => number | null + onBeforeQuit?: () => void | Promise + setLastUpdateCheckAt?: (timestamp: number) => void + getPendingUpdateNudgeId?: () => string | null + getDismissedUpdateNudgeId?: () => string | null + setPendingUpdateNudgeId?: (id: string | null) => void + setDismissedUpdateNudgeId?: (id: string | null) => void + getReleaseChannelOverride?: () => ReleaseChannel | null + installMode?: UpdateInstallMode +} + +/** Initializes electron-updater and attaches lifecycle/event bridges. */ +export class UpdaterSetup extends UpdaterDownloadInstall { + checkForUpdates(): void { + this.checkForUpdatesInBackground() + } + + checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + super.checkForUpdatesFromMenu(options) + } + + downloadUpdate(): void { + super.downloadUpdate() + } + + quitAndInstall(): void { + super.quitAndInstall() + } + + isQuittingForUpdate(): boolean { + return super.isQuittingForUpdate() + } + + getUpdateStatus(): UpdateStatus { + return super.getUpdateStatus() + } + + getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + return super.getRemoteServerUpdateSupport() + } + + getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.getRemoteServerUpdaterSnapshot(runtimeId) + } + + checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + return super.checkForRemoteServerUpdate(runtimeId, options) + } + + downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.downloadRemoteServerUpdate(runtimeId) + } + + installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + return super.installRemoteServerUpdate(runtimeId) + } + + resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + return super.resolveUpdateInstallMode(isServeMode) + } + + async getLinuxPackageInstallInstructions(): Promise { + return super.getLinuxPackageInstallInstructions() + } + + async showLinuxPackage(): Promise { + return super.showLinuxPackage() + } + + async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return super.listAvailableReleaseBuilds(channel) + } + + dismissNudge(): void { + super.dismissNudge() + } + + dismissAvailableUpdate(): void { + super.dismissAvailableUpdate() + } + + setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + this.mainWindowRef = mainWindow + this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null + this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null + this._getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null + this._getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null + this._getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null + this._setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null + this._setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null + this.getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null + this.updateInstallMode = opts?.installMode ?? 'interactive' + this.lastInstallDeferralVersion = { download: null, install: null } + + const serveHandoffFailure = getServeUpdateHandoffFailure() + if (serveHandoffFailure) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { reason: serveHandoffFailure }, + { level: 'warn', message: 'Supervised serve update did not complete' } + ) + this.sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) + } + + if (!app.isPackaged && !is.dev) { + return + } + if (is.dev) { + return + } + + const autoUpdater = this.getAutoUpdater() + autoUpdater.autoDownload = false + if (this.activeUpdateSource === 'release') { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } + // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. + // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. + autoUpdater.autoInstallOnAppQuit = + this.updateInstallMode === 'interactive' && getLinuxRootPackageType() === null + // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. + autoUpdater.autoRunAppAfterInstall = this.updateInstallMode === 'interactive' + // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. + autoUpdater.logger = createUpdaterDiagnosticLogger() as never + + // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. + if (this.activeUpdateSource === 'release') { + autoUpdater.setFeedURL({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + if (this.autoUpdaterInitialized) { + return + } + this.autoUpdaterInitialized = true + + registerAutoUpdaterHandlers({ + autoUpdater, + clearBackgroundCheckLaunchPending: () => this.clearBackgroundCheckLaunchPending(), + clearAvailableUpdateContext: () => this.clearAvailableUpdateContext(), + consumeMissingManifestPrereleaseFallbackResult: () => + this.consumeMissingManifestPrereleaseFallbackResult(), + getPublishingWindowLastGoodCheck: () => this.getPublishingWindowLastGoodCheck(), + getMissingManifestPrereleaseFallbackUserInitiated: () => + this.getMissingManifestPrereleaseFallbackUserInitiated(), + getCurrentStatus: () => this.currentStatus, + getActiveUpdateCheckEventAttemptId: () => this.getActiveUpdateCheckEventAttemptId(), + getKnownReleaseUrl: () => this.getKnownReleaseUrl(), + getPendingInstallVersion: () => this.getPendingInstallVersion(), + getUserInitiatedCheck: () => this.userInitiatedCheck, + handleQuitAndInstallFailure: (error) => this.handleQuitAndInstallFailure(error), + isQuitAndInstallHandoffActive: () => this.isQuitAndInstallHandoffActive(), + hasInstallableDownloadedVersion: () => this.hasInstallableDownloadedVersion(), + isLocalBuildCheck: () => this.activeUpdateSource === 'local', + // Why: pinned jumps are deliberate, so update-available/-downloaded must not reject them for being older than the running version. + isPinnedBuildCheck: () => this.isPinnedBuildActive, + shouldHandleUpdaterErrorEvent: () => this.shouldHandleUpdaterErrorEvent(), + clearUpdateAvailableEventPending: (attemptId) => + this.clearUpdateAvailableEventPending(attemptId), + isActiveUpdateCheckAttempt: (attemptId) => this.isActiveUpdateCheckAttempt(attemptId), + markUpdateCheckEventAttempt: () => this.markUpdateCheckEventAttempt(), + markUpdateAvailableEventPending: (attemptId) => + this.markUpdateAvailableEventPending(attemptId), + markMissingManifestPrereleaseFallbackChecking: () => + this.markMissingManifestPrereleaseFallbackChecking(), + performQuitAndInstall: () => this.performQuitAndInstall(), + shouldDeferMacQuitForInstall: () => this.updateInstallMode === 'interactive', + recordCompletedUpdateCheck: () => this.recordCompletedUpdateCheck(), + restoreReleaseUpdateSource: () => this.restoreReleaseUpdateSource(), + sendCheckFailureStatus: (message, userInitiated, source, sourceError) => + this.sendCheckFailureStatus(message, userInitiated, source, sourceError), + sendErrorStatus: (message, userInitiated) => this.sendErrorStatus(message, userInitiated), + sendStatus: (status) => this.sendStatus(status), + scheduleAutomaticUpdateCheck: (delayMs) => this.scheduleAutomaticUpdateCheck(delayMs), + shouldSuppressMissingManifestPrereleaseFallbackEvent: (message, error) => + this.shouldSuppressMissingManifestPrereleaseFallbackEvent(message, error), + suppressMissingManifestPrereleaseFallbackPromiseFailure: (message) => + this.suppressMissingManifestPrereleaseFallbackPromiseFailure(message), + setAvailableReleaseUrl: (releaseUrl) => { + this.availableReleaseUrl = releaseUrl + }, + setAvailableVersion: (version) => { + this.availableVersion = version + }, + setUserInitiatedCheck: (value) => { + this.userInitiatedCheck = value + } + }) + + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + + const checkDailyOnWake = () => { + void this.checkForUpdateNudge() + if ( + this.backgroundCheckLaunchPending || + this.currentStatus.state === 'checking' || + this.currentStatus.state === 'downloading' + ) { + return + } + const lastCheck = this._getLastUpdateCheckAt?.() ?? null + const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck + if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + } + powerMonitor.on('resume', checkDailyOnWake) + app.on('browser-window-focus', checkDailyOnWake) + + const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null + const msSinceLastCheck = + lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt + if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } else { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) + } + } +} diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts new file mode 100644 index 00000000000..a410c5e10b8 --- /dev/null +++ b/src/main/updater/updater-state.ts @@ -0,0 +1,127 @@ +import type { BrowserWindow } from 'electron' +import type { ElectronAutoUpdater } from '../electron-updater-loader' +import type { LinuxPackageInstallDiagnostic } from '../linux-package-install-diagnostic' +import type { LocalBuildFeed } from '../local-builds/local-build-feed-server' +import type { UpdateSource, UpdateStatus } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import type { PrimaryEventSuppression, UpdateCheckVariant } from './updater-types' + +export const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +export const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 +// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. +export const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 +export const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 +export const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 +export const QUIT_AND_INSTALL_DELAY_MS = 100 +export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 +export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 +export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 + +export type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' +export type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } +export type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' +export type ReleaseFeedPreflightResult = 'ready' | 'not-available' +export type UpdateInstallMode = + | 'interactive' + | 'supervised-headless-serve' + | 'unsupported-headless-serve' + +// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. +export class ReleaseFeedPreflightError extends Error { + constructor( + readonly reason: ReleaseFeedPreflightFailure, + readonly releaseChannel: UpdateCheckVariant, + message: string + ) { + super(message) + this.name = 'ReleaseFeedPreflightError' + } +} + +export abstract class UpdaterState { + protected mainWindowRef: BrowserWindow | null = null + protected currentStatus: UpdateStatus = { state: 'idle' } + protected userInitiatedCheck = false + protected onBeforeQuitCleanup: (() => void | Promise) | null = null + protected autoUpdaterInitialized = false + // Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. + protected includePrereleaseActive = false + protected availableVersion: string | null = null + protected availableReleaseUrl: string | null = null + protected pendingCheckFailureKey: string | null = null + protected pendingCheckFailurePromise: Promise | null = null + protected autoUpdateCheckTimer: ReturnType | null = null + protected nudgeCheckTimer: ReturnType | null = null + protected pendingQuitAndInstallTimer: ReturnType | null = null + protected quitAndInstallInProgress = false + // Why: the pre-install digest re-proof streams the whole package, so a second install request can + // arrive while it runs — after the quit timer was cleared but before the handoff owns the process. + protected linuxPackageRevalidationInFlight = false + protected updateInstallMode: UpdateInstallMode = 'interactive' + protected lastInstallDeferralVersion = { + download: null as string | null, + install: null as string | null + } + // Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. + protected updateInstallCommitted = false + // Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. + protected quitAndInstallNativeInvoked = false + // Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. + protected lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null + protected persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null + protected _getLastUpdateCheckAt: (() => number | null) | null = null + protected backgroundCheckLaunchPending = false + // Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. + protected backgroundCheckPromotedToUserInitiated = false + protected updateCheckStallTimer: ReturnType | null = null + protected updateCheckSilentSettleTimer: ReturnType | null = null + protected updateCheckAttemptSequence = 0 + protected activeUpdateCheckAttemptId: number | null = null + protected activeUpdateCheckLaunchAttemptId: number | null = null + protected activeUpdateCheckEventAttemptId: number | null = null + protected updateAvailableEventPendingAttemptId: number | null = null + protected pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null + protected activeUpdateNudgeId: string | null = null + protected awaitingNudgeCheckOutcome = false + protected nudgeCheckInFlight = false + protected lastNudgeCheckAt = 0 + protected publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null + protected pendingPrereleaseFallback: { + primaryTag: string + fallbackTag: string + // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. + userInitiated: boolean + suppressedPrimaryPromiseFailureKey: string | null + suppressedPrimaryEventFailure: PrimaryEventSuppression | null + suppressedFallbackPromiseFailureKey: string | null + suppressedFallbackEventFailureKey: string | null + fallbackResultHandled: boolean + fallbackCheckingForUpdateSeen: boolean + retryLaunched: boolean + } | null = null + + protected _getPendingUpdateNudgeId: (() => string | null) | null = null + protected _getDismissedUpdateNudgeId: (() => string | null) | null = null + protected _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null + protected _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null + // Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. + protected downloadInFlight = false + /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ + protected quittingForUpdate = false + protected autoUpdater: ElectronAutoUpdater | null = null + protected activeUpdateSource: 'release' | UpdateSource = 'release' + protected activeLocalBuildFeed: LocalBuildFeed | null = null + protected localBuildSelectionInProgress = false + // Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade + // like local builds — but off a real release feed, not a loopback server. + protected pinnedBuildSelectionInProgress = false + // Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a + // deliberate downgrade, so newer-only gates must yield to it too. + protected isPinnedBuildActive = false + protected getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null + + protected consecutiveAutomaticRetrySchedules = 0 + protected readonly installFailureCauseMaxLength = 200 + + constructor() {} +} diff --git a/src/main/updater/updater-status.ts b/src/main/updater/updater-status.ts new file mode 100644 index 00000000000..26b8f605084 --- /dev/null +++ b/src/main/updater/updater-status.ts @@ -0,0 +1,184 @@ +import { loadElectronAutoUpdater, type ElectronAutoUpdater } from '../electron-updater-loader' +import { statusesEqual } from '../updater-fallback' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterState as BaseUpdaterState } from './updater-state' + +export abstract class UpdaterStatus extends BaseUpdaterState { + protected getAutoUpdater(): ElectronAutoUpdater { + if (!this.autoUpdater) { + this.autoUpdater = loadElectronAutoUpdater() + } + return this.autoUpdater + } + + protected clearAvailableUpdateContext(): void { + this.availableVersion = null + this.availableReleaseUrl = null + } + + protected closeLocalBuildFeed(): void { + const feed = this.activeLocalBuildFeed + this.activeLocalBuildFeed = null + if (feed) { + void feed.close() + } + } + + protected restoreReleaseUpdateSource(): void { + this.closeLocalBuildFeed() + this.activeUpdateSource = 'release' + this.isPinnedBuildActive = false + if (this.autoUpdater) { + this.autoUpdater.allowDowngrade = false + this.autoUpdater.disableDifferentialDownload = false + // Why: a pinned jump forces allowPrerelease on; leaving it set would opt + // every later background check into the RC channel behind the user's back. + this.autoUpdater.allowPrerelease = this.includePrereleaseActive + } + } + + protected sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { + this.clearAvailableUpdateContext() + if ( + this.currentStatus.state !== 'error' || + this.currentStatus.message !== message || + this.currentStatus.userInitiated !== userInitiated || + this.currentStatus.source !== 'local' + ) { + this.sendStatus({ state: 'error', message, userInitiated, source: 'local' }) + } + this.restoreReleaseUpdateSource() + } + + protected clearPrereleaseFallbackContext(): void { + this.pendingPrereleaseFallback = null + } + + protected clearPendingUpdateNudge(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + this._setPendingUpdateNudgeId?.(null) + } + + protected deferPendingUpdateNudgeUntilRetry(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + } + + protected clearPublishingWindowLastGoodCheck(): void { + this.publishingWindowLastGoodCheck = null + } + + protected getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { + return this.publishingWindowLastGoodCheck + } + + protected getPersistedPendingUpdateNudgeId(): string | null { + return this._getPendingUpdateNudgeId?.() ?? null + } + + protected decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { + // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. + if (!this.activeUpdateNudgeId) { + return status + } + if ( + status.state === 'idle' || + status.state === 'checking' || + status.state === 'not-available' + ) { + return status + } + return { ...status, activeNudgeId: this.activeUpdateNudgeId } + } + + /** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ + protected sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { + const pendingUserInitiatedCheckVariant = this.pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + const shouldPreserveNudgeForPublishingWindow = + this.publishingWindowLastGoodCheck !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + if (this.awaitingNudgeCheckOutcome) { + if (status.state === 'available') { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. + this.deferPendingUpdateNudgeUntilRetry() + } else { + this.awaitingNudgeCheckOutcome = false + } + } else if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'error' + ) { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. + this.deferPendingUpdateNudgeUntilRetry() + } else { + // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. + if (this.activeUpdateNudgeId) { + this._setDismissedUpdateNudgeId?.(this.activeUpdateNudgeId) + } + this.clearPendingUpdateNudge() + } + } + } + + const sourcedStatus: UpdateStatus = + this.activeUpdateSource === 'release' + ? status + : { ...status, source: this.activeUpdateSource } + const decoratedStatus = this.decorateStatusWithActiveNudge(sourcedStatus) + + if (this.isUpdateCheckResultState(status.state)) { + this.finishActiveUpdateCheckAttempt() + } + + if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error' + ) { + this.clearPublishingWindowLastGoodCheck() + } + + // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. + if ( + decoratedStatus.state === 'downloading' || + decoratedStatus.state === 'error' || + decoratedStatus.state === 'idle' + ) { + this.downloadInFlight = false + } + if (shouldLaunchPendingUserInitiatedCheck) { + // Why: a forced status must still land before the queued check restarts the cycle. + if (options?.force) { + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + this.launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) + return + } + if (!options?.force && statusesEqual(this.currentStatus, decoratedStatus)) { + return + } + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + + protected abstract finishActiveUpdateCheckAttempt(): void + protected abstract isUpdateCheckResultState(state: UpdateStatus['state']): boolean + protected abstract launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void + protected abstract checkForUpdatesFromMenu(options?: UpdateCheckOptions): void +} diff --git a/src/main/updater/updater-types.ts b/src/main/updater/updater-types.ts new file mode 100644 index 00000000000..aeb91bfdbd1 --- /dev/null +++ b/src/main/updater/updater-types.ts @@ -0,0 +1,2 @@ +export type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +export type PrimaryEventSuppression = { failureKey: string; error: unknown }